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,
|
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()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user