From eff2ee8d1423e51b4d1247697998cb5d388fabf4 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Tue, 19 May 2026 14:04:36 +0000 Subject: [PATCH] refactor: ColumnSpec / AddColumn carry constraint fields (ADR-0029 scaffolding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand ColumnSpec and Command::AddColumn with the four ADR-0029 constraint slots (not_null, unique, default, check), all defaulting off; `Database::add_column` now takes a ColumnSpec. No behaviour change — the grammar to set the fields and the DDL to enforce them land in the following commits. Isolated here so those commits stay readable. Adds ColumnSpec::new for the unconstrained case; 110 call sites updated. 1172 tests pass; clippy clean. --- src/app.rs | 14 +-- src/db.rs | 161 ++++++++++-------------- src/dsl/command.rs | 43 ++++++- src/dsl/grammar/ddl.rs | 11 +- src/dsl/parser.rs | 29 ++++- src/dsl/walker/mod.rs | 9 +- src/runtime.rs | 25 +++- tests/friendly_enrichment.rs | 34 ++--- tests/iteration2_persistence.rs | 36 +++--- tests/iteration3_rebuild.rs | 27 ++-- tests/iteration4a_rebuild_command.rs | 4 +- tests/iteration4b_lifecycle_commands.rs | 2 +- tests/iteration5_export_import.rs | 4 +- tests/project_lifecycle.rs | 10 +- tests/walking_skeleton.rs | 22 ++-- 15 files changed, 237 insertions(+), 194 deletions(-) diff --git a/src/app.rs b/src/app.rs index 926d73e..fc2c244 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2209,10 +2209,7 @@ mod tests { command, &Command::CreateTable { name: "Customers".to_string(), - columns: vec![crate::dsl::ColumnSpec { - name: "id".to_string(), - ty: Type::Serial, - }], + columns: vec![crate::dsl::ColumnSpec::new("id", Type::Serial)], primary_key: vec!["id".to_string()], }, ); @@ -2419,10 +2416,7 @@ mod tests { let mut app = App::new(); let cmd = Command::CreateTable { name: "Customers".to_string(), - columns: vec![crate::dsl::ColumnSpec { - name: "id".to_string(), - ty: Type::Serial, - }], + columns: vec![crate::dsl::ColumnSpec::new("id", Type::Serial)], primary_key: vec!["id".to_string()], }; let desc = sample_description("Customers"); @@ -3174,6 +3168,10 @@ mod tests { table: "T".to_string(), column: "Name".to_string(), ty: Type::Text, + not_null: false, + unique: false, + default: None, + check: None, }, ); } diff --git a/src/db.rs b/src/db.rs index 1fdeda1..03549ca 100644 --- a/src/db.rs +++ b/src/db.rs @@ -446,8 +446,7 @@ enum Request { }, AddColumn { table: String, - column: String, - ty: Type, + column: ColumnSpec, source: Option, reply: oneshot::Sender>, }, @@ -670,15 +669,13 @@ impl Database { pub async fn add_column( &self, table: String, - column: String, - ty: Type, + column: ColumnSpec, source: Option, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::AddColumn { table, column, - ty, source, reply, }) @@ -1142,7 +1139,6 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req Request::AddColumn { table, column, - ty, source, reply, } => { @@ -1152,7 +1148,6 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req source.as_deref(), &table, &column, - ty, )); } Request::DropColumn { @@ -1932,14 +1927,13 @@ fn do_add_column( persistence: Option<&Persistence>, source: Option<&str>, table: &str, - column: &str, - ty: Type, + column: &ColumnSpec, ) -> Result { - let auto_generated = matches!(ty, Type::Serial | Type::ShortId); + let auto_generated = matches!(column.ty, Type::Serial | Type::ShortId); if !auto_generated { - return do_add_plain_column(conn, persistence, source, table, column, ty); + return do_add_plain_column(conn, persistence, source, table, column); } - do_add_auto_generated_column(conn, persistence, source, table, column, ty) + do_add_auto_generated_column(conn, persistence, source, table, column) } /// Plain ALTER-TABLE path for non-auto-generated types. @@ -1948,9 +1942,12 @@ fn do_add_plain_column( persistence: Option<&Persistence>, source: Option<&str>, table: &str, - column: &str, - ty: Type, + spec: &ColumnSpec, ) -> Result { + // ADR-0029: the constraint DDL suffix is emitted here once + // the constraint grammar lands; for now name + type only. + let ty = spec.ty; + let column = spec.name.as_str(); let ddl = format!( "ALTER TABLE {tbl} ADD COLUMN {col} {sqlite_type};", tbl = quote_ident(table), @@ -1994,11 +1991,12 @@ fn do_add_auto_generated_column( persistence: Option<&Persistence>, source: Option<&str>, table: &str, - column: &str, - ty: Type, + spec: &ColumnSpec, ) -> Result { use rusqlite::types::Value as RV; + let ty = spec.ty; + let column = spec.name.as_str(); let old_schema = read_schema(conn, table)?; if old_schema.columns.iter().any(|c| c.name == column) { return Err(DbError::Unsupported(format!( @@ -5473,10 +5471,7 @@ mod tests { } fn col(name: &str, ty: Type) -> ColumnSpec { - ColumnSpec { - name: name.to_string(), - ty, - } + ColumnSpec::new(name, ty) } /// Convenience: a `serial`-PK table with a single `id` column. @@ -5589,7 +5584,7 @@ mod tests { let db = db(); make_id_table(&db, "Customers").await; let result = db - .add_column("Customers".to_string(), "Name".to_string(), Type::Text, None) + .add_column("Customers".to_string(), ColumnSpec::new("Name".to_string(), Type::Text), None) .await .unwrap(); let desc = &result.description; @@ -5609,7 +5604,7 @@ mod tests { // datetime, decimal — all backed by TEXT). make_id_table(&db, "T").await; for ty in [Type::Date, Type::DateTime, Type::Decimal, Type::ShortId] { - db.add_column("T".to_string(), format!("c_{ty}"), ty, None) + db.add_column("T".to_string(), ColumnSpec::new(format!("c_{ty}"), ty), None) .await .unwrap(); } @@ -5671,7 +5666,7 @@ mod tests { make_id_table(&db, "T").await; for ty in [Type::Text, Type::Int, Type::Real, Type::Bool, Type::ShortId] { let col_name = format!("c_{ty}"); - db.add_column("T".to_string(), col_name.clone(), ty, None) + db.add_column("T".to_string(), ColumnSpec::new(col_name.clone(), ty), None) .await .unwrap_or_else(|e| panic!("type {ty} failed: {e}")); } @@ -5688,7 +5683,7 @@ mod tests { let db = db(); make_id_table(&db, "T").await; let result = db - .add_column("T".to_string(), "code".to_string(), Type::Serial, None) + .add_column("T".to_string(), ColumnSpec::new("code".to_string(), Type::Serial), None) .await .unwrap(); let code = result @@ -5712,7 +5707,7 @@ mod tests { /// (text) and insert N rows, populating just `Name`. async fn make_table_with_n_rows(db: &Database, table: &str, count: usize) { make_id_table(db, table).await; - db.add_column(table.to_string(), "Name".to_string(), Type::Text, None) + db.add_column(table.to_string(), ColumnSpec::new("Name".to_string(), Type::Text), None) .await .unwrap(); for i in 0..count { @@ -5732,7 +5727,7 @@ mod tests { let db = db(); make_table_with_n_rows(&db, "T", 3).await; let result = db - .add_column("T".to_string(), "seq".to_string(), Type::Serial, None) + .add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None) .await .unwrap(); let seq = result @@ -5765,7 +5760,7 @@ mod tests { let db = db(); make_table_with_n_rows(&db, "T", 3).await; let result = db - .add_column("T".to_string(), "tag".to_string(), Type::ShortId, None) + .add_column("T".to_string(), ColumnSpec::new("tag".to_string(), Type::ShortId), None) .await .unwrap(); let tag = result @@ -5800,7 +5795,7 @@ mod tests { // columns continue to use SQLite's rowid alias. let db = db(); make_table_with_n_rows(&db, "T", 0).await; - db.add_column("T".to_string(), "seq".to_string(), Type::Serial, None) + db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None) .await .unwrap(); // Insert three rows providing only `Name`. The seq @@ -5833,7 +5828,7 @@ mod tests { // accepted (ADR-0018 Resolution 2). let db = db(); make_table_with_n_rows(&db, "T", 0).await; - db.add_column("T".to_string(), "seq".to_string(), Type::Serial, None) + db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None) .await .unwrap(); // Insert with explicit seq=100. @@ -5872,7 +5867,7 @@ mod tests { // confirming the engine refuses. let db = db(); make_table_with_n_rows(&db, "T", 2).await; - db.add_column("T".to_string(), "seq".to_string(), Type::Serial, None) + db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None) .await .unwrap(); // Attempt to UPDATE one row to have the same `seq` value @@ -5928,7 +5923,7 @@ mod tests { async fn add_column_to_missing_table_returns_no_such_table() { let db = db(); let err = db - .add_column("Ghost".to_string(), "x".to_string(), Type::Text, None) + .add_column("Ghost".to_string(), ColumnSpec::new("x".to_string(), Type::Text), None) .await .unwrap_err(); match err { @@ -5943,7 +5938,7 @@ mod tests { async fn drop_column_removes_column_and_data() { let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), "Score".to_string(), Type::Int, None) + db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Int), None) .await .unwrap(); db.insert( @@ -5993,12 +5988,7 @@ mod tests { // Customers(id PK) ← Orders(cust_id FK) make_id_table(&db, "Customers").await; make_id_table(&db, "Orders").await; - db.add_column( - "Orders".to_string(), - "cust_id".to_string(), - Type::Int, - None, - ) + db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None) .await .unwrap(); db.add_relationship( @@ -6043,7 +6033,7 @@ mod tests { /// something indexable. async fn make_indexable_table(db: &Database, name: &str) { make_id_table(db, name).await; - db.add_column(name.to_string(), "Email".to_string(), Type::Text, None) + db.add_column(name.to_string(), ColumnSpec::new("Email".to_string(), Type::Text), None) .await .expect("add Email column"); } @@ -6081,10 +6071,10 @@ mod tests { async fn add_index_composite_auto_name_joins_columns() { let db = db(); make_id_table(&db, "Orders").await; - db.add_column("Orders".to_string(), "CustId".to_string(), Type::Int, None) + db.add_column("Orders".to_string(), ColumnSpec::new("CustId".to_string(), Type::Int), None) .await .unwrap(); - db.add_column("Orders".to_string(), "Day".to_string(), Type::Date, None) + db.add_column("Orders".to_string(), ColumnSpec::new("Day".to_string(), Type::Date), None) .await .unwrap(); let desc = db @@ -6107,7 +6097,7 @@ mod tests { async fn add_index_rejects_duplicate_name() { let db = db(); make_indexable_table(&db, "Customers").await; - db.add_column("Customers".to_string(), "Nick".to_string(), Type::Text, None) + db.add_column("Customers".to_string(), ColumnSpec::new("Nick".to_string(), Type::Text), None) .await .unwrap(); db.add_index( @@ -6306,7 +6296,7 @@ mod tests { // unrelated column must survive the rebuild (ADR-0025). let db = db(); make_indexable_table(&db, "T").await; - db.add_column("T".to_string(), "Score".to_string(), Type::Int, None) + db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Int), None) .await .unwrap(); db.add_index( @@ -6339,7 +6329,7 @@ mod tests { async fn rename_column_updates_schema_and_metadata() { let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), "Old".to_string(), Type::Text, None) + db.add_column("T".to_string(), ColumnSpec::new("Old".to_string(), Type::Text), None) .await .unwrap(); let desc = db @@ -6358,12 +6348,7 @@ mod tests { let db = db(); make_id_table(&db, "Customers").await; make_id_table(&db, "Orders").await; - db.add_column( - "Orders".to_string(), - "cust_id".to_string(), - Type::Int, - None, - ) + db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None) .await .unwrap(); db.add_relationship( @@ -6423,10 +6408,10 @@ mod tests { async fn rename_column_refuses_collision() { let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), "A".to_string(), Type::Text, None) + db.add_column("T".to_string(), ColumnSpec::new("A".to_string(), Type::Text), None) .await .unwrap(); - db.add_column("T".to_string(), "B".to_string(), Type::Text, None) + db.add_column("T".to_string(), ColumnSpec::new("B".to_string(), Type::Text), None) .await .unwrap(); let err = db @@ -6440,7 +6425,7 @@ mod tests { async fn rename_column_refuses_identity_rename() { let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), "A".to_string(), Type::Text, None) + db.add_column("T".to_string(), ColumnSpec::new("A".to_string(), Type::Text), None) .await .unwrap(); let err = db @@ -6454,7 +6439,7 @@ mod tests { async fn change_column_type_works_for_compatible_data() { let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), "Score".to_string(), Type::Text, None) + db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Text), None) .await .unwrap(); // Insert numeric-looking strings. @@ -6523,12 +6508,7 @@ mod tests { let db = db(); make_id_table(&db, "Customers").await; make_id_table(&db, "Orders").await; - db.add_column( - "Orders".to_string(), - "cust_id".to_string(), - Type::Int, - None, - ) + db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None) .await .unwrap(); db.add_relationship( @@ -6572,12 +6552,7 @@ mod tests { let db = db(); make_id_table(&db, "Customers").await; make_id_table(&db, "Orders").await; - db.add_column( - "Orders".to_string(), - "cust_id".to_string(), - Type::Int, - None, - ) + db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None) .await .unwrap(); db.add_relationship( @@ -6613,7 +6588,7 @@ mod tests { // table. let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), "Score".to_string(), Type::Real, None) + db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Real), None) .await .unwrap(); for v in ["3.14", "2.71"] { @@ -6664,7 +6639,7 @@ mod tests { // the lossy count. let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), "Score".to_string(), Type::Real, None) + db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Real), None) .await .unwrap(); for v in ["3.14", "2.71", "5.0"] { @@ -6699,7 +6674,7 @@ mod tests { // does NOT help (per ADR-0017 §5 / §2 step 3). let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), "Note".to_string(), Type::Text, None) + db.add_column("T".to_string(), ColumnSpec::new("Note".to_string(), Type::Text), None) .await .unwrap(); for v in ["abc", "123", "xyz"] { @@ -6748,7 +6723,7 @@ mod tests { // [client-side] note is expected. let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), "Flag".to_string(), Type::Int, None) + db.add_column("T".to_string(), ColumnSpec::new("Flag".to_string(), Type::Int), None) .await .unwrap(); for v in ["0", "1", "0"] { @@ -6796,7 +6771,7 @@ mod tests { // help (incompatible is not lossy). let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), "Flag".to_string(), Type::Int, None) + db.add_column("T".to_string(), ColumnSpec::new("Flag".to_string(), Type::Int), None) .await .unwrap(); for v in ["0", "1", "2"] { @@ -6848,7 +6823,7 @@ mod tests { // engine coercion; the note is suppressed. let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), "Score".to_string(), Type::Text, None) + db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Text), None) .await .unwrap(); for v in ["1", "2", "3"] { @@ -6933,7 +6908,7 @@ mod tests { async fn change_column_type_blob_target_refused_statically() { let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), "Note".to_string(), Type::Text, None) + db.add_column("T".to_string(), ColumnSpec::new("Note".to_string(), Type::Text), None) .await .unwrap(); let err = db @@ -6956,12 +6931,7 @@ mod tests { let db = db(); make_id_table(&db, "Customers").await; make_id_table(&db, "Orders").await; - db.add_column( - "Orders".to_string(), - "cust_id".to_string(), - Type::Int, - None, - ) + db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None) .await .unwrap(); db.add_relationship( @@ -7004,7 +6974,7 @@ mod tests { // note, and the structural change goes through. let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), "Note".to_string(), Type::Text, None) + db.add_column("T".to_string(), ColumnSpec::new("Note".to_string(), Type::Text), None) .await .unwrap(); let result = db @@ -7037,7 +7007,7 @@ mod tests { // existing values are preserved. let db = db(); make_table_with_n_rows(&db, "T", 0).await; - db.add_column("T".to_string(), "code".to_string(), Type::Int, None) + db.add_column("T".to_string(), ColumnSpec::new("code".to_string(), Type::Int), None) .await .unwrap(); // Insert a few rows with explicit code values. @@ -7087,7 +7057,7 @@ mod tests { // uniqueness-collision diagnostic. let db = db(); make_table_with_n_rows(&db, "T", 0).await; - db.add_column("T".to_string(), "code".to_string(), Type::Int, None) + db.add_column("T".to_string(), ColumnSpec::new("code".to_string(), Type::Int), None) .await .unwrap(); // Two rows with the same code. @@ -7129,7 +7099,7 @@ mod tests { // to route through int. let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), "A".to_string(), Type::Text, None) + db.add_column("T".to_string(), ColumnSpec::new("A".to_string(), Type::Text), None) .await .unwrap(); let err = db @@ -7160,7 +7130,7 @@ mod tests { // [client-side] note reports the auto-fill count. let db = db(); make_table_with_n_rows(&db, "T", 0).await; - db.add_column("T".to_string(), "code".to_string(), Type::Int, None) + db.add_column("T".to_string(), ColumnSpec::new("code".to_string(), Type::Int), None) .await .unwrap(); // Three rows: one with code=5, two with NULL. @@ -7216,7 +7186,7 @@ mod tests { // text column get fresh shortids (ADR-0018 §3). let db = db(); make_table_with_n_rows(&db, "T", 0).await; - db.add_column("T".to_string(), "tag".to_string(), Type::Text, None) + db.add_column("T".to_string(), ColumnSpec::new("tag".to_string(), Type::Text), None) .await .unwrap(); // One row with a valid shortid value, two with NULL. @@ -7270,12 +7240,7 @@ mod tests { let db = db(); make_id_table(&db, "Customers").await; make_id_table(&db, "Orders").await; - db.add_column( - "Orders".to_string(), - "cust_id".to_string(), - Type::Int, - None, - ) + db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None) .await .unwrap(); db.add_relationship( @@ -7308,7 +7273,7 @@ mod tests { async fn change_column_type_no_op_to_same_type_errors() { let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), "A".to_string(), Type::Int, None) + db.add_column("T".to_string(), ColumnSpec::new("A".to_string(), Type::Int), None) .await .unwrap(); let err = db @@ -7345,7 +7310,7 @@ mod tests { None) .await .unwrap(); - db.add_column("Orders".to_string(), "CustId".to_string(), Type::Int, None) + db.add_column("Orders".to_string(), ColumnSpec::new("CustId".to_string(), Type::Int), None) .await .unwrap(); } @@ -7517,7 +7482,7 @@ mod tests { .await .unwrap(); // Wrong type — text instead of int. - db.add_column("Orders".to_string(), "CustId".to_string(), Type::Text, None) + db.add_column("Orders".to_string(), ColumnSpec::new("CustId".to_string(), Type::Text), None) .await .unwrap(); @@ -7561,7 +7526,7 @@ mod tests { None) .await .unwrap(); - db.add_column("Orders".to_string(), "CustName".to_string(), Type::Text, None) + db.add_column("Orders".to_string(), ColumnSpec::new("CustName".to_string(), Type::Text), None) .await .unwrap(); let err = db @@ -7690,7 +7655,7 @@ mod tests { async fn add_relationship_with_duplicate_name_errors() { let db = db(); customers_orders_setup(&db).await; - db.add_column("Orders".to_string(), "OtherCust".to_string(), Type::Int, None) + db.add_column("Orders".to_string(), ColumnSpec::new("OtherCust".to_string(), Type::Int), None) .await .unwrap(); db.add_relationship( @@ -8604,7 +8569,7 @@ mod tests { ) .await .unwrap(); - db.add_column("T".to_string(), "seq".to_string(), Type::Serial, None) + db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None) .await .unwrap(); // Read the persisted YAML straight from disk. @@ -8647,7 +8612,7 @@ mod tests { ) .await .unwrap(); - db.add_column("T".to_string(), "seq".to_string(), Type::Serial, None) + db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None) .await .unwrap(); // Tear down the .db file and rebuild from yaml + csvs. diff --git a/src/dsl/command.rs b/src/dsl/command.rs index 1686e88..e75b4c8 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -15,13 +15,42 @@ use crate::dsl::action::ReferentialAction; use crate::dsl::types::Type; use crate::dsl::value::Value; -/// A column at table-creation time: a name and a user-facing -/// type. Constraints beyond `PRIMARY KEY` (NOT NULL, UNIQUE, -/// CHECK, DEFAULT) come in later iterations. +/// A column at table-creation time: a name, a user-facing +/// type, and its column-level constraints (ADR-0029). +/// +/// `PRIMARY KEY` is not represented here — it is a table-level +/// property carried separately in `Command::CreateTable`'s +/// `primary_key` list, since it may span columns. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ColumnSpec { pub name: String, pub ty: Type, + /// `NOT NULL` — the column rejects `NULL` (ADR-0029). + pub not_null: bool, + /// `UNIQUE` — non-`NULL` values must be distinct (ADR-0029). + pub unique: bool, + /// `DEFAULT ` — the value used when an `insert` + /// omits this column (ADR-0029). + pub default: Option, + /// `CHECK ()` — every row must satisfy this boolean + /// expression (ADR-0029). + pub check: Option, +} + +impl ColumnSpec { + /// A column spec carrying no constraints — the common case + /// for callers and tests that do not exercise ADR-0029. + #[must_use] + pub fn new(name: impl Into, ty: Type) -> Self { + Self { + name: name.into(), + ty, + not_null: false, + unique: false, + default: None, + check: None, + } + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -39,10 +68,18 @@ pub enum Command { DropTable { name: String, }, + /// Add a column to an existing table. The column carries + /// its constraints from the same suffix grammar as + /// `create table` (ADR-0029); `check` is `None` until the + /// CHECK grammar lands. AddColumn { table: String, column: String, ty: Type, + not_null: bool, + unique: bool, + default: Option, + check: Option, }, /// Remove a column from a table. Refused if the column is /// part of the primary key or is involved in a declared diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs index 97d15f2..ca6f2f5 100644 --- a/src/dsl/grammar/ddl.rs +++ b/src/dsl/grammar/ddl.rs @@ -616,6 +616,12 @@ fn build_add(path: &MatchedPath) -> Result { table: require_ident(path, "table_name")?, column: require_ident(path, "column_name")?, ty, + // Constraint suffix is wired in once the + // constraint grammar lands (ADR-0029). + not_null: false, + unique: false, + default: None, + check: None, }) } Some("1") => build_add_relationship(path), @@ -907,10 +913,7 @@ fn build_create_table(path: &MatchedPath) -> Result { let columns = pk_specs .iter() - .map(|(n, t)| ColumnSpec { - name: n.clone(), - ty: *t, - }) + .map(|(n, t)| ColumnSpec::new(n.clone(), *t)) .collect(); let primary_key = pk_specs.into_iter().map(|(n, _)| n).collect(); diff --git a/src/dsl/parser.rs b/src/dsl/parser.rs index 2fc0bf0..6a63e4d 100644 --- a/src/dsl/parser.rs +++ b/src/dsl/parser.rs @@ -358,10 +358,7 @@ mod tests { } fn col(name: &str, ty: Type) -> ColumnSpec { - ColumnSpec { - name: name.to_string(), - ty, - } + ColumnSpec::new(name, ty) } #[test] @@ -657,6 +654,10 @@ mod tests { table: "Customers".to_string(), column: "Name".to_string(), ty: Type::Text, + not_null: false, + unique: false, + default: None, + check: None, } ); } @@ -671,6 +672,10 @@ mod tests { table: "T".to_string(), column: "C".to_string(), ty: *ty, + not_null: false, + unique: false, + default: None, + check: None, } ); } @@ -684,6 +689,10 @@ mod tests { table: "Customers".to_string(), column: "Name".to_string(), ty: Type::Text, + not_null: false, + unique: false, + default: None, + check: None, } ); } @@ -696,6 +705,10 @@ mod tests { table: "Customers".to_string(), column: "Name".to_string(), ty: Type::Text, + not_null: false, + unique: false, + default: None, + check: None, } ); } @@ -708,6 +721,10 @@ mod tests { table: "Customers".to_string(), column: "Name".to_string(), ty: Type::Text, + not_null: false, + unique: false, + default: None, + check: None, } ); } @@ -720,6 +737,10 @@ mod tests { table: "T".to_string(), column: "Name".to_string(), ty: Type::Text, + not_null: false, + unique: false, + default: None, + check: None, } ); } diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index 5c502a5..a4a304a 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -1346,6 +1346,10 @@ mod tests { table: "Customers".to_string(), column: "Email".to_string(), ty: Type::Text, + not_null: false, + unique: false, + default: None, + check: None, } ); } @@ -1474,10 +1478,7 @@ mod tests { use crate::dsl::command::ColumnSpec; fn col(name: &str, ty: Type) -> ColumnSpec { - ColumnSpec { - name: name.to_string(), - ty, - } + ColumnSpec::new(name, ty) } #[test] diff --git a/src/runtime.rs b/src/runtime.rs index 1a4efb6..dcc13f0 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -32,7 +32,7 @@ use crate::db::{ AddColumnResult, ChangeColumnTypeResult, DataResult, Database, DbError, DeleteResult, DropColumnResult, InsertResult, QueryPlan, TableDescription, UpdateResult, }; -use crate::dsl::Command; +use crate::dsl::{Command, ColumnSpec}; use crate::dsl::walker::Severity; use crate::event::AppEvent; use crate::project::{ @@ -1706,8 +1706,27 @@ async fn execute_command_typed( .drop_table(name, src) .await .map(|()| CommandOutcome::Schema(None)), - Command::AddColumn { table, column, ty } => database - .add_column(table, column, ty, src) + Command::AddColumn { + table, + column, + ty, + not_null, + unique, + default, + check, + } => database + .add_column( + table, + ColumnSpec { + name: column, + ty, + not_null, + unique, + default, + check, + }, + src, + ) .await .map(CommandOutcome::AddColumn), Command::DropColumn { diff --git a/tests/friendly_enrichment.rs b/tests/friendly_enrichment.rs index f7f1a83..c303b23 100644 --- a/tests/friendly_enrichment.rs +++ b/tests/friendly_enrichment.rs @@ -45,8 +45,8 @@ fn enrich_unique_insert_resolves_table_column_value_and_pinpoint() { db.create_table( "Customers".to_string(), vec![ - ColumnSpec { name: "id".to_string(), ty: Type::Int }, - ColumnSpec { name: "name".to_string(), ty: Type::Text }, + ColumnSpec::new("id".to_string(), Type::Int), + ColumnSpec::new("name".to_string(), Type::Text), ], vec!["id".to_string()], None, @@ -109,7 +109,7 @@ fn enrich_unique_insert_natural_order_short_form_resolves_value_via_schema() { rt().block_on(async { db.create_table( "thing".to_string(), - vec![ColumnSpec { name: "id".to_string(), ty: Type::Int }], + vec![ColumnSpec::new("id".to_string(), Type::Int)], vec!["id".to_string()], None, ) @@ -152,8 +152,8 @@ fn enrich_unique_update_resolves_value_from_assignments() { db.create_table( "Customers".to_string(), vec![ - ColumnSpec { name: "id".to_string(), ty: Type::Int }, - ColumnSpec { name: "name".to_string(), ty: Type::Text }, + ColumnSpec::new("id".to_string(), Type::Int), + ColumnSpec::new("name".to_string(), Type::Text), ], vec!["id".to_string()], None, @@ -215,8 +215,8 @@ fn enrich_not_null_resolves_table_and_column() { db.create_table( "T".to_string(), vec![ - ColumnSpec { name: "a".to_string(), ty: Type::Int }, - ColumnSpec { name: "b".to_string(), ty: Type::Text }, + ColumnSpec::new("a".to_string(), Type::Int), + ColumnSpec::new("b".to_string(), Type::Text), ], vec!["a".to_string(), "b".to_string()], None, @@ -258,7 +258,7 @@ fn enrich_fk_insert_resolves_parent_table_column_and_value() { rt().block_on(async { db.create_table( "Customers".to_string(), - vec![ColumnSpec { name: "id".to_string(), ty: Type::Int }], + vec![ColumnSpec::new("id".to_string(), Type::Int)], vec!["id".to_string()], None, ) @@ -267,8 +267,8 @@ fn enrich_fk_insert_resolves_parent_table_column_and_value() { db.create_table( "Orders".to_string(), vec![ - ColumnSpec { name: "id".to_string(), ty: Type::Int }, - ColumnSpec { name: "CustId".to_string(), ty: Type::Int }, + ColumnSpec::new("id".to_string(), Type::Int), + ColumnSpec::new("CustId".to_string(), Type::Int), ], vec!["id".to_string()], None, @@ -333,7 +333,7 @@ fn enrich_fk_insert_natural_order_multi_value_resolves_via_schema() { rt().block_on(async { db.create_table( "Customers".to_string(), - vec![ColumnSpec { name: "id".to_string(), ty: Type::Int }], + vec![ColumnSpec::new("id".to_string(), Type::Int)], vec!["id".to_string()], None, ) @@ -342,9 +342,9 @@ fn enrich_fk_insert_natural_order_multi_value_resolves_via_schema() { db.create_table( "Orders".to_string(), vec![ - ColumnSpec { name: "id".to_string(), ty: Type::Serial }, - ColumnSpec { name: "CustId".to_string(), ty: Type::Int }, - ColumnSpec { name: "Total".to_string(), ty: Type::Real }, + ColumnSpec::new("id".to_string(), Type::Serial), + ColumnSpec::new("CustId".to_string(), Type::Int), + ColumnSpec::new("Total".to_string(), Type::Real), ], vec!["id".to_string()], None, @@ -408,7 +408,7 @@ fn enrich_fk_delete_resolves_child_table() { rt().block_on(async { db.create_table( "Customers".to_string(), - vec![ColumnSpec { name: "id".to_string(), ty: Type::Int }], + vec![ColumnSpec::new("id".to_string(), Type::Int)], vec!["id".to_string()], None, ) @@ -417,8 +417,8 @@ fn enrich_fk_delete_resolves_child_table() { db.create_table( "Orders".to_string(), vec![ - ColumnSpec { name: "id".to_string(), ty: Type::Int }, - ColumnSpec { name: "CustId".to_string(), ty: Type::Int }, + ColumnSpec::new("id".to_string(), Type::Int), + ColumnSpec::new("CustId".to_string(), Type::Int), ], vec!["id".to_string()], None, diff --git a/tests/iteration2_persistence.rs b/tests/iteration2_persistence.rs index d56e46a..9a66f99 100644 --- a/tests/iteration2_persistence.rs +++ b/tests/iteration2_persistence.rs @@ -66,8 +66,8 @@ fn create_table_writes_yaml_and_history() { db.create_table( "Customers".to_string(), vec![ - ColumnSpec { name: "id".to_string(), ty: Type::Serial }, - ColumnSpec { name: "Name".to_string(), ty: Type::Text }, + ColumnSpec::new("id".to_string(), Type::Serial), + ColumnSpec::new("Name".to_string(), Type::Text), ], vec!["id".to_string()], Some("create table Customers with pk id(serial)".to_string()), @@ -96,8 +96,8 @@ fn insert_writes_csv_and_history() { db.create_table( "Customers".to_string(), vec![ - ColumnSpec { name: "id".to_string(), ty: Type::Serial }, - ColumnSpec { name: "Name".to_string(), ty: Type::Text }, + ColumnSpec::new("id".to_string(), Type::Serial), + ColumnSpec::new("Name".to_string(), Type::Text), ], vec!["id".to_string()], Some("create table Customers with pk id(serial)".to_string()), @@ -134,7 +134,7 @@ fn drop_table_removes_its_csv() { rt().block_on(async { db.create_table( "Customers".to_string(), - vec![ColumnSpec { name: "id".to_string(), ty: Type::Serial }], + vec![ColumnSpec::new("id".to_string(), Type::Serial)], vec!["id".to_string()], Some("create table Customers with pk id(serial)".to_string()), ) @@ -172,7 +172,7 @@ fn delete_with_cascade_rewrites_both_csvs() { rt().block_on(async { db.create_table( "Customers".to_string(), - vec![ColumnSpec { name: "id".to_string(), ty: Type::Serial }], + vec![ColumnSpec::new("id".to_string(), Type::Serial)], vec!["id".to_string()], Some("create table Customers with pk id(serial)".to_string()), ) @@ -181,8 +181,8 @@ fn delete_with_cascade_rewrites_both_csvs() { db.create_table( "Orders".to_string(), vec![ - ColumnSpec { name: "id".to_string(), ty: Type::Serial }, - ColumnSpec { name: "CustId".to_string(), ty: Type::Int }, + ColumnSpec::new("id".to_string(), Type::Serial), + ColumnSpec::new("CustId".to_string(), Type::Int), ], vec!["id".to_string()], Some("create table Orders with pk id(serial), CustId(int)".to_string()), @@ -259,8 +259,8 @@ fn create_table_does_not_write_csv_for_empty_table() { db.create_table( "Customers".to_string(), vec![ - ColumnSpec { name: "id".to_string(), ty: Type::Serial }, - ColumnSpec { name: "Name".to_string(), ty: Type::Text }, + ColumnSpec::new("id".to_string(), Type::Serial), + ColumnSpec::new("Name".to_string(), Type::Text), ], vec!["id".to_string()], Some("create table Customers with pk id(serial)".to_string()), @@ -288,8 +288,8 @@ fn delete_all_rows_removes_csv() { db.create_table( "Customers".to_string(), vec![ - ColumnSpec { name: "id".to_string(), ty: Type::Serial }, - ColumnSpec { name: "Name".to_string(), ty: Type::Text }, + ColumnSpec::new("id".to_string(), Type::Serial), + ColumnSpec::new("Name".to_string(), Type::Text), ], vec!["id".to_string()], Some("create table Customers with pk id(serial)".to_string()), @@ -330,7 +330,7 @@ fn show_table_appends_history_only() { rt().block_on(async { db.create_table( "Customers".to_string(), - vec![ColumnSpec { name: "id".to_string(), ty: Type::Serial }], + vec![ColumnSpec::new("id".to_string(), Type::Serial)], vec!["id".to_string()], Some("create table Customers with pk id(serial)".to_string()), ) @@ -363,7 +363,7 @@ fn failed_command_does_not_append_history_or_change_yaml() { rt().block_on(async { db.create_table( "Customers".to_string(), - vec![ColumnSpec { name: "id".to_string(), ty: Type::Serial }], + vec![ColumnSpec::new("id".to_string(), Type::Serial)], vec!["id".to_string()], Some("create table Customers with pk id(serial)".to_string()), ) @@ -375,7 +375,7 @@ fn failed_command_does_not_append_history_or_change_yaml() { let err = db .create_table( "Customers".to_string(), - vec![ColumnSpec { name: "id".to_string(), ty: Type::Serial }], + vec![ColumnSpec::new("id".to_string(), Type::Serial)], vec!["id".to_string()], Some("create table Customers with pk id(serial)".to_string()), ) @@ -404,7 +404,7 @@ fn project_yaml_carries_relationship_after_add() { rt().block_on(async { db.create_table( "Customers".to_string(), - vec![ColumnSpec { name: "id".to_string(), ty: Type::Serial }], + vec![ColumnSpec::new("id".to_string(), Type::Serial)], vec!["id".to_string()], None, ) @@ -413,8 +413,8 @@ fn project_yaml_carries_relationship_after_add() { db.create_table( "Orders".to_string(), vec![ - ColumnSpec { name: "id".to_string(), ty: Type::Serial }, - ColumnSpec { name: "CustId".to_string(), ty: Type::Int }, + ColumnSpec::new("id".to_string(), Type::Serial), + ColumnSpec::new("CustId".to_string(), Type::Int), ], vec!["id".to_string()], None, diff --git a/tests/iteration3_rebuild.rs b/tests/iteration3_rebuild.rs index 18c11b4..40cea86 100644 --- a/tests/iteration3_rebuild.rs +++ b/tests/iteration3_rebuild.rs @@ -44,8 +44,8 @@ fn rebuild_restores_schema_only_project() { db.create_table( "Customers".to_string(), vec![ - ColumnSpec { name: "id".to_string(), ty: Type::Serial }, - ColumnSpec { name: "Name".to_string(), ty: Type::Text }, + ColumnSpec::new("id".to_string(), Type::Serial), + ColumnSpec::new("Name".to_string(), Type::Text), ], vec!["id".to_string()], Some("create table Customers with pk id(serial)".to_string()), @@ -98,8 +98,8 @@ fn rebuild_restores_rows_from_csv() { db.create_table( "Customers".to_string(), vec![ - ColumnSpec { name: "id".to_string(), ty: Type::Serial }, - ColumnSpec { name: "Name".to_string(), ty: Type::Text }, + ColumnSpec::new("id".to_string(), Type::Serial), + ColumnSpec::new("Name".to_string(), Type::Text), ], vec!["id".to_string()], Some("create".to_string()), @@ -165,7 +165,7 @@ fn rebuild_restores_relationships_and_cascade_behaviour() { rt().block_on(async { db.create_table( "Customers".to_string(), - vec![ColumnSpec { name: "id".to_string(), ty: Type::Serial }], + vec![ColumnSpec::new("id".to_string(), Type::Serial)], vec!["id".to_string()], Some("create".to_string()), ) @@ -174,8 +174,8 @@ fn rebuild_restores_relationships_and_cascade_behaviour() { db.create_table( "Orders".to_string(), vec![ - ColumnSpec { name: "id".to_string(), ty: Type::Serial }, - ColumnSpec { name: "CustId".to_string(), ty: Type::Int }, + ColumnSpec::new("id".to_string(), Type::Serial), + ColumnSpec::new("CustId".to_string(), Type::Int), ], vec!["id".to_string()], Some("create".to_string()), @@ -265,8 +265,8 @@ fn rebuild_reports_fatal_error_on_bad_csv_row() { db.create_table( "Numbers".to_string(), vec![ - ColumnSpec { name: "id".to_string(), ty: Type::Serial }, - ColumnSpec { name: "n".to_string(), ty: Type::Int }, + ColumnSpec::new("id".to_string(), Type::Serial), + ColumnSpec::new("n".to_string(), Type::Int), ], vec!["id".to_string()], Some("create".to_string()), @@ -326,7 +326,7 @@ fn rebuild_preserves_created_at_from_yaml() { rt().block_on(async { db.create_table( "T".to_string(), - vec![ColumnSpec { name: "id".to_string(), ty: Type::Serial }], + vec![ColumnSpec::new("id".to_string(), Type::Serial)], vec!["id".to_string()], Some("create".to_string()), ) @@ -377,8 +377,7 @@ fn rebuild_preserves_created_at_from_yaml() { // describe is read-only; force a rewrite by adding a column. db.add_column( "T".to_string(), - "Note".to_string(), - Type::Text, + ColumnSpec::new("Note", Type::Text), Some("add column".to_string()), ) .await @@ -410,8 +409,8 @@ fn rebuild_restores_indexes() { db.create_table( "Customers".to_string(), vec![ - ColumnSpec { name: "id".to_string(), ty: Type::Serial }, - ColumnSpec { name: "Email".to_string(), ty: Type::Text }, + ColumnSpec::new("id".to_string(), Type::Serial), + ColumnSpec::new("Email".to_string(), Type::Text), ], vec!["id".to_string()], Some("create table Customers with pk id(serial)".to_string()), diff --git a/tests/iteration4a_rebuild_command.rs b/tests/iteration4a_rebuild_command.rs index 7670006..311ad07 100644 --- a/tests/iteration4a_rebuild_command.rs +++ b/tests/iteration4a_rebuild_command.rs @@ -131,8 +131,8 @@ fn rebuild_against_populated_db_wipes_and_reloads() { db.create_table( "Customers".to_string(), vec![ - ColumnSpec { name: "id".to_string(), ty: Type::Serial }, - ColumnSpec { name: "Name".to_string(), ty: Type::Text }, + ColumnSpec::new("id".to_string(), Type::Serial), + ColumnSpec::new("Name".to_string(), Type::Text), ], vec!["id".to_string()], Some("create".to_string()), diff --git a/tests/iteration4b_lifecycle_commands.rs b/tests/iteration4b_lifecycle_commands.rs index 6148cc3..78b0c99 100644 --- a/tests/iteration4b_lifecycle_commands.rs +++ b/tests/iteration4b_lifecycle_commands.rs @@ -388,7 +388,7 @@ fn temp_with_a_table_is_no_longer_unmodified() { rt.block_on(async { db.create_table( "T".to_string(), - vec![ColumnSpec { name: "id".to_string(), ty: Type::Serial }], + vec![ColumnSpec::new("id".to_string(), Type::Serial)], vec!["id".to_string()], Some("create".to_string()), ) diff --git a/tests/iteration5_export_import.rs b/tests/iteration5_export_import.rs index 84bc7dd..43676f6 100644 --- a/tests/iteration5_export_import.rs +++ b/tests/iteration5_export_import.rs @@ -299,8 +299,8 @@ fn end_to_end_export_then_import_real_project() { db.create_table( "Customers".to_string(), vec![ - ColumnSpec { name: "id".to_string(), ty: Type::Serial }, - ColumnSpec { name: "Name".to_string(), ty: Type::Text }, + ColumnSpec::new("id".to_string(), Type::Serial), + ColumnSpec::new("Name".to_string(), Type::Text), ], vec!["id".to_string()], Some("create table Customers with pk id(serial)".to_string()), diff --git a/tests/project_lifecycle.rs b/tests/project_lifecycle.rs index f06df99..2ba090a 100644 --- a/tests/project_lifecycle.rs +++ b/tests/project_lifecycle.rs @@ -165,14 +165,8 @@ fn db_persists_across_open_close_cycles() { db.create_table( "Customers".to_string(), vec![ - rdbms_playground::dsl::ColumnSpec { - name: "id".to_string(), - ty: rdbms_playground::dsl::Type::Serial, - }, - rdbms_playground::dsl::ColumnSpec { - name: "Name".to_string(), - ty: rdbms_playground::dsl::Type::Text, - }, + rdbms_playground::dsl::ColumnSpec::new("id".to_string(), rdbms_playground::dsl::Type::Serial), + rdbms_playground::dsl::ColumnSpec::new("Name".to_string(), rdbms_playground::dsl::Type::Text), ], vec!["id".to_string()], None) diff --git a/tests/walking_skeleton.rs b/tests/walking_skeleton.rs index 01b7cf6..f86edd0 100644 --- a/tests/walking_skeleton.rs +++ b/tests/walking_skeleton.rs @@ -90,10 +90,7 @@ fn typing_then_submitting_a_dsl_command_emits_execute_action() { &actions, &Command::CreateTable { name: "Customers".to_string(), - columns: vec![ColumnSpec { - name: "id".to_string(), - ty: Type::Serial, - }], + columns: vec![ColumnSpec::new("id".to_string(), Type::Serial)], primary_key: vec!["id".to_string()], }, ); @@ -271,10 +268,7 @@ fn create_table_flow_updates_tables_list_and_structure_view() { let actions = submit(&mut app); let expected_cmd = Command::CreateTable { name: "Customers".to_string(), - columns: vec![ColumnSpec { - name: "id".to_string(), - ty: Type::Serial, - }], + columns: vec![ColumnSpec::new("id".to_string(), Type::Serial)], primary_key: vec!["id".to_string()], }; assert_one_execute_dsl(&actions, &expected_cmd); @@ -326,6 +320,10 @@ fn add_column_flow_updates_structure_view() { table: "Customers".to_string(), column: "Name".to_string(), ty: Type::Text, + not_null: false, + unique: false, + default: None, + check: None, }, ); @@ -338,6 +336,10 @@ fn add_column_flow_updates_structure_view() { table: "Customers".to_string(), column: "Name".to_string(), ty: Type::Text, + not_null: false, + unique: false, + default: None, + check: None, }, description: Some(updated.clone()), }); @@ -479,6 +481,10 @@ fn add_relationship_flow_shows_inbound_section_on_parent() { table: "Customers".to_string(), column: "extra".to_string(), ty: Type::Text, + not_null: false, + unique: false, + default: None, + check: None, }, description: Some(customers), });