diff --git a/src/db.rs b/src/db.rs index e0a4a17..4927381 100644 --- a/src/db.rs +++ b/src/db.rs @@ -455,6 +455,20 @@ enum Request { limit: usize, reply: oneshot::Sender>, }, + /// List schema entity names for a given identifier slot + /// (ADR-0022 §9). Used by the completion engine to offer + /// candidates for `TableName` / `Column` / + /// `RelationshipName` slots. `NewName` is rejected at + /// the caller — schema has nothing to offer for new + /// names — and never reaches the worker. + /// + /// Returns names in stable (alphabetical) order, no + /// duplicates. The reply is small even for projects with + /// hundreds of tables/columns. + ListNamesFor { + slot: crate::dsl::ident_slot::IdentSlot, + reply: oneshot::Sender, DbError>>, + }, } impl Database { @@ -799,6 +813,33 @@ impl Database { recv.await.map_err(|_| DbError::WorkerGone)? } + /// List schema entity names for an identifier slot + /// (ADR-0022 §9). + /// + /// Returns alphabetised, deduplicated names suitable for + /// the completion menu: + /// - `IdentSlot::TableName` → user tables (filters + /// `__rdbms_*` internal tables); + /// - `IdentSlot::Column` → distinct column names across + /// all user tables (v1 simplification — no + /// table-context binding); + /// - `IdentSlot::RelationshipName` → relationship + /// names from the metadata table; + /// - `IdentSlot::NewName` → returns `Ok(vec![])` + /// immediately without a worker round-trip (the user + /// invents these names). + pub async fn list_names_for( + &self, + slot: crate::dsl::ident_slot::IdentSlot, + ) -> Result, DbError> { + if !slot.completes_from_schema() { + return Ok(Vec::new()); + } + let (reply, recv) = oneshot::channel(); + self.send(Request::ListNamesFor { slot, reply }).await?; + recv.await.map_err(|_| DbError::WorkerGone)? + } + async fn send(&self, req: Request) -> Result<(), DbError> { self.inbox.send(req).await.map_err(|_| DbError::WorkerGone) } @@ -1120,6 +1161,59 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req let result = do_find_rows_matching(conn, &table, &column, &value, limit); let _ = reply.send(result); } + Request::ListNamesFor { slot, reply } => { + let result = do_list_names_for(conn, slot); + let _ = reply.send(result); + } + } +} + +/// Schema-name lookup for the completion engine +/// (ADR-0022 §9). `NewName` never reaches here — the public +/// `list_names_for` short-circuits. +fn do_list_names_for( + conn: &Connection, + slot: crate::dsl::ident_slot::IdentSlot, +) -> Result, DbError> { + use crate::dsl::ident_slot::IdentSlot; + match slot { + IdentSlot::NewName => Ok(Vec::new()), + IdentSlot::TableName => do_list_tables(conn), + IdentSlot::Column => { + // Distinct column names across all user tables. + // v1 simplification: no table-context binding + // (ADR-0022 stage 6 note). + let mut stmt = conn + .prepare(&format!( + "SELECT DISTINCT column_name \ + FROM {META_TABLE} \ + ORDER BY column_name;" + )) + .map_err(DbError::from_rusqlite)?; + let rows = stmt + .query_map([], |row| row.get::<_, String>(0)) + .map_err(DbError::from_rusqlite)?; + let mut out = Vec::new(); + for row in rows { + out.push(row.map_err(DbError::from_rusqlite)?); + } + Ok(out) + } + IdentSlot::RelationshipName => { + let mut stmt = conn + .prepare(&format!( + "SELECT name FROM {REL_TABLE} ORDER BY name;" + )) + .map_err(DbError::from_rusqlite)?; + let rows = stmt + .query_map([], |row| row.get::<_, String>(0)) + .map_err(DbError::from_rusqlite)?; + let mut out = Vec::new(); + for row in rows { + out.push(row.map_err(DbError::from_rusqlite)?); + } + Ok(out) + } } } @@ -6966,4 +7060,121 @@ mod tests { let desc = db.describe_table("Order Lines".to_string(), None).await.unwrap(); assert_eq!(desc.name, "Order Lines"); } + + // ---- list_names_for (ADR-0022 §9, stage 7) ---- + + #[tokio::test] + async fn list_names_for_new_name_short_circuits_to_empty() { + // The user invents new names; schema has nothing to + // offer. The public method short-circuits before + // touching the worker. + let db = db(); + let names = db + .list_names_for(crate::dsl::ident_slot::IdentSlot::NewName) + .await + .unwrap(); + assert!(names.is_empty()); + } + + #[tokio::test] + async fn list_names_for_table_returns_user_tables_alphabetised() { + let db = db(); + make_id_table(&db, "Customers").await; + make_id_table(&db, "Orders").await; + let names = db + .list_names_for(crate::dsl::ident_slot::IdentSlot::TableName) + .await + .unwrap(); + assert_eq!(names, vec!["Customers".to_string(), "Orders".to_string()]); + } + + #[tokio::test] + async fn list_names_for_table_filters_internal_metadata_tables() { + // The internal __rdbms_* tables must never appear in + // the completion menu (ADR-0002 user-facing posture). + let db = db(); + make_id_table(&db, "Customers").await; + let names = db + .list_names_for(crate::dsl::ident_slot::IdentSlot::TableName) + .await + .unwrap(); + assert_eq!(names, vec!["Customers".to_string()]); + for n in &names { + assert!( + !n.starts_with("__rdbms_"), + "internal table leaked into completion: {n}", + ); + } + } + + #[tokio::test] + async fn list_names_for_column_returns_distinct_columns_alphabetised() { + let db = db(); + // Two tables sharing a column name `id`, with different + // additional columns. + db.create_table( + "Customers".to_string(), + vec![col("id", Type::Serial), col("name", Type::Text)], + vec!["id".to_string()], + None, + ) + .await + .unwrap(); + db.create_table( + "Orders".to_string(), + vec![col("id", Type::Serial), col("total", Type::Real)], + vec!["id".to_string()], + None, + ) + .await + .unwrap(); + let names = db + .list_names_for(crate::dsl::ident_slot::IdentSlot::Column) + .await + .unwrap(); + // `id` appears once despite being in both tables (DISTINCT). + assert_eq!( + names, + vec!["id".to_string(), "name".to_string(), "total".to_string()], + ); + } + + #[tokio::test] + async fn list_names_for_relationship_returns_named_relationships() { + let db = db(); + db.create_table( + "Customers".to_string(), + vec![col("id", Type::Serial)], + vec!["id".to_string()], + None, + ) + .await + .unwrap(); + db.create_table( + "Orders".to_string(), + vec![col("id", Type::Serial), col("CustId", Type::Int)], + vec!["id".to_string()], + None, + ) + .await + .unwrap(); + db.add_relationship( + Some("cust_orders".to_string()), + "Customers".to_string(), + "id".to_string(), + "Orders".to_string(), + "CustId".to_string(), + crate::dsl::ReferentialAction::NoAction, + crate::dsl::ReferentialAction::NoAction, + false, + None, + ) + .await + .unwrap(); + let names = db + .list_names_for(crate::dsl::ident_slot::IdentSlot::RelationshipName) + .await + .unwrap(); + assert_eq!(names, vec!["cust_orders".to_string()]); + } }