ADR-0022 stage 7/8: schema query plumbing
Add `Request::ListNamesFor { slot, reply }` and the public
`Database::list_names_for(slot)` method. The completion
engine in stage 8 calls this on Tab when the cursor sits
on an identifier-typed slot.
Worker dispatch:
- TableName → user tables (filters __rdbms_*).
- Column → distinct column names across all user tables
(v1 simplification per the stage 6 IdentSlot note: no
table-context binding; the schema-completion engine in
stage 8 may refine).
- RelationshipName → relationship names from the
__rdbms_playground_relationships metadata table.
- NewName → short-circuited at the public method (no
worker round-trip).
Names are returned alphabetised + deduplicated. Filters
respect ADR-0002 — internal __rdbms_* tables never reach
the completion menu (covered by a regression test).
Tests: 705 passing, 0 failing, 1 ignored (700 baseline →
+5 list_names_for cases). Clippy clean.
Stage 8 wires this into the App as a Tab-triggered
completion mode. Note for the next session: stage 8 is by
far the largest of the eight stages — it touches App state
(completion mode), event routing (Tab/arrow/Enter/Esc/letter
behaviour while in completion mode), hint-panel render
variant, candidate filtering, integration tests. Several
fine-grained UX decisions (cursor position after accept,
panel height when candidate list overflows, what closes
the mode) want explicit user input rather than agent
guesswork. See "Stage 8 open questions" in the next
handoff for the list.
This commit is contained in:
@@ -455,6 +455,20 @@ enum Request {
|
||||
limit: usize,
|
||||
reply: oneshot::Sender<Result<DataResult, DbError>>,
|
||||
},
|
||||
/// 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<Result<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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()]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user