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:
claude@clouddev1
2026-05-10 17:50:21 +00:00
parent 6845df1475
commit aea3224da2
+211
View File
@@ -455,6 +455,20 @@ enum Request {
limit: usize, limit: usize,
reply: oneshot::Sender<Result<DataResult, DbError>>, 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 { impl Database {
@@ -799,6 +813,33 @@ impl Database {
recv.await.map_err(|_| DbError::WorkerGone)? 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> { async fn send(&self, req: Request) -> Result<(), DbError> {
self.inbox.send(req).await.map_err(|_| DbError::WorkerGone) 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 result = do_find_rows_matching(conn, &table, &column, &value, limit);
let _ = reply.send(result); 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(); let desc = db.describe_table("Order Lines".to_string(), None).await.unwrap();
assert_eq!(desc.name, "Order Lines"); 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()]);
}
} }