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:
@@ -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(", "));
|
||||
|
||||
Reference in New Issue
Block a user