feat: ADR-0035 4a — SQL CREATE TABLE command, worker, and exit gate

Command + builder + worker for advanced-mode SQL CREATE TABLE
(sub-phase 4a), executed structurally through do_create_table:

- Command::SqlCreateTable + build_sql_create_table (ddl.rs): aliases via
  from_sql_name (incl. double precision), column- and table-level
  PRIMARY KEY, redundant-flag de-dup off a sole PK, IF NOT EXISTS.
  Advanced REGISTRY entry on the shared `create` word (SQL-first, DSL
  fallback); no-PK tables allowed (user-confirmed).
- Worker (db.rs): Request::SqlCreateTable + CreateOutcome + snapshot_then
  (one undo step); IF NOT EXISTS no-op (no snapshot, but journalled, like
  read-only commands). do_create_table inline-PK rule aligned with the
  rebuild generator schema_to_ddl — no round-trip DDL drift; serial
  autoincrement is independent of inline-PK (verified by round-trip
  tests).
- Runtime/App: dispatch + CommandOutcome::SchemaSkipped +
  AppEvent::DslCreateSkipped (structure + "already exists — skipped"
  note). Friendly catalog keys added (engine-neutral).

DEFAULT/CHECK/table-level UNIQUE are absent from the 4a grammar (parse
error with usage skeleton; friendly message + support land in the 4a.2
constraint slice) — user-confirmed.

Tests: type resolver, grammar shape, builder (incl. the PK
detection bug they caught), and tests/sql_create_table.rs (worker
round-trip, serial autoincrement first/non-first across rebuild, IF NOT
EXISTS no-op + journalling, no-PK table, one undo step) + a replay-as-
write test. 1739 pass / 0 fail / 1 ignored; clippy clean.

Exit gate: ADR-0035 Proposed -> Accepted (validated end-to-end by 4a);
README + requirements.md Q1 updated.
This commit is contained in:
claude@clouddev1
2026-05-25 10:04:28 +00:00
parent 80310929d7
commit 631074ff9c
18 changed files with 961 additions and 47 deletions
+105 -13
View File
@@ -457,6 +457,18 @@ enum Request {
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
/// Advanced-mode SQL `CREATE TABLE` (ADR-0035 §1, 4a). Executes
/// structurally through `do_create_table`; `if_not_exists` turns
/// an existing table into a no-op (`CreateOutcome::Skipped`, no
/// snapshot) instead of an error (ADR-0035 §4).
SqlCreateTable {
name: String,
columns: Vec<ColumnSpec>,
primary_key: Vec<String>,
if_not_exists: bool,
source: Option<String>,
reply: oneshot::Sender<Result<CreateOutcome, DbError>>,
},
DropTable {
name: String,
source: Option<String>,
@@ -813,6 +825,30 @@ impl Database {
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Advanced-mode SQL `CREATE TABLE` (ADR-0035 §1, 4a). Executes
/// structurally; returns whether the table was created or skipped
/// (the `IF NOT EXISTS` no-op, ADR-0035 §4).
pub async fn sql_create_table(
&self,
name: String,
columns: Vec<ColumnSpec>,
primary_key: Vec<String>,
if_not_exists: bool,
source: Option<String>,
) -> Result<CreateOutcome, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::SqlCreateTable {
name,
columns,
primary_key,
if_not_exists,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn drop_table(&self, name: String, source: Option<String>) -> Result<(), DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::DropTable { name, source, reply }).await?;
@@ -1653,6 +1689,42 @@ fn handle_request(
&primary_key,
));
}
Request::SqlCreateTable {
name,
columns,
primary_key,
if_not_exists,
source,
reply,
} => {
// `IF NOT EXISTS` on an existing table is a no-op: reply
// `Skipped` with the existing structure and take **no**
// snapshot (there is nothing to undo). The submitted line is
// still journalled — like other read-only / no-op commands
// (`show table`), it belongs in the complete journal
// (ADR-0034). ADR-0035 §4.
if if_not_exists && user_table_exists(conn, &name).unwrap_or(false) {
let result = do_describe_table(conn, &name).and_then(|desc| {
if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
p.append_history(text).map_err(DbError::from_persistence)?;
}
Ok(CreateOutcome::Skipped(desc))
});
let _ = reply.send(result);
} else {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
do_create_table(
conn,
persistence,
source.as_deref(),
&name,
&columns,
&primary_key,
)
.map(CreateOutcome::Created)
});
}
}
Request::DropTable {
name,
source,
@@ -2507,6 +2579,18 @@ fn insert_column_metadata(
Ok(())
}
/// The result of an advanced-mode SQL `CREATE TABLE` (ADR-0035 §4).
///
/// Either the table was created, or `IF NOT EXISTS` matched an
/// existing table and the statement was a no-op. Both carry the
/// table's structure so the runtime can render it; `Skipped` also
/// drives the "already exists — skipped" note.
#[derive(Debug)]
pub enum CreateOutcome {
Created(TableDescription),
Skipped(TableDescription),
}
fn do_create_table(
conn: &Connection,
persistence: Option<&Persistence>,
@@ -2525,14 +2609,21 @@ fn do_create_table(
));
}
// Generate the column list. For a single-column PK we inline
// `PRIMARY KEY` on the column itself, which is required for
// SQLite STRICT tables to give an `INTEGER PRIMARY KEY`
// column its rowid-alias semantics. For compound PKs (or
// when the single PK is on a non-first column) we emit a
// table-level constraint.
let single_inline_pk = primary_key.len() == 1 && columns.len() == 1
&& primary_key[0] == columns[0].name;
// Inline `PRIMARY KEY` on the column when the table has a single
// primary-key column and it is the **first** column — the exact
// rule [`schema_to_ddl`] uses on rebuild, so a table's DDL is
// identical whether freshly created or reconstructed (no
// round-trip drift). SQLite grants an inline single-column PK
// rowid-alias semantics; a compound PK, or a single PK that is not
// the first column, gets a table-level constraint. `serial`
// autoincrement does **not** depend on this — the insert path
// computes the next value itself (verified by the multi-column /
// rebuild round-trip tests) — so the choice is purely about
// matching the rebuild generator (ADR-0035 §6.4).
let inline_pk_col: Option<&str> = (primary_key.len() == 1
&& !columns.is_empty()
&& primary_key[0] == columns[0].name)
.then(|| primary_key[0].as_str());
// Compile each column's CHECK once (ADR-0029 §4) — reused
// by the DDL clause and the metadata insert below. The
@@ -2550,13 +2641,14 @@ fn do_create_table(
ident = quote_ident(&col.name),
sqlite_type = col.ty.sqlite_strict_type(),
);
if single_inline_pk {
if inline_pk_col == Some(col.name.as_str()) {
clause.push_str(" PRIMARY KEY");
}
// ADR-0029 column constraints. A single-column PK is
// already NOT NULL + UNIQUE; the grammar rejects
// redundant declarations (ADR-0029 §9) so a PK column
// never carries them here.
// already NOT NULL + UNIQUE; the simple-mode grammar
// rejects redundant declarations (ADR-0029 §9) and the
// SQL builder de-dups them (ADR-0035 §6.5), so an
// inline-PK column never carries them here.
clause.push_str(&column_constraints_sql(col)?);
if let Some(cs) = check_sql {
clause.push_str(&format!(" CHECK ({cs})"));
@@ -2570,7 +2662,7 @@ fn do_create_table(
columns = column_clauses.join(", "),
);
if !single_inline_pk && !primary_key.is_empty() {
if inline_pk_col.is_none() && !primary_key.is_empty() {
let pk_idents: Vec<String> = primary_key.iter().map(|n| quote_ident(n)).collect();
ddl.push_str(", PRIMARY KEY (");
ddl.push_str(&pk_idents.join(", "));