diff --git a/src/app.rs b/src/app.rs index fc2c244..c74a4da 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2173,6 +2173,8 @@ mod tests { sqlite_type: "INTEGER".to_string(), notnull: false, primary_key: true, + unique: false, + default: None, }], outbound_relationships: Vec::new(), inbound_relationships: Vec::new(), diff --git a/src/db.rs b/src/db.rs index 03549ca..911a688 100644 --- a/src/db.rs +++ b/src/db.rs @@ -28,7 +28,7 @@ use std::thread; use rusqlite::Connection; use tokio::sync::{mpsc, oneshot}; -use tracing::{debug, info, warn}; +use tracing::{debug, info}; use crate::dsl::action::ReferentialAction; use crate::dsl::command::{ @@ -129,6 +129,13 @@ pub struct ColumnDescription { pub sqlite_type: String, pub notnull: bool, pub primary_key: bool, + /// Carries a single-column `UNIQUE` constraint (ADR-0029). + /// A PK column is not flagged here — the `primary_key` + /// flag already conveys its implicit uniqueness. + pub unique: bool, + /// The column's `DEFAULT` expression as SQLite reports it, + /// or `None` (ADR-0029). + pub default: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1769,6 +1776,32 @@ fn quote_ident(name: &str) -> String { out } +/// Render a column's constraint-DDL suffix (ADR-0029) — the +/// ` NOT NULL` / ` UNIQUE` / ` DEFAULT ` fragment that +/// follows the column's type in a `CREATE TABLE` or `ALTER +/// TABLE ADD COLUMN`. The default literal is bound against the +/// column's user-facing type, so `default 18` on an `int` +/// column emits `DEFAULT 18` and `default 'x'` on a `text` +/// column emits `DEFAULT 'x'`. (`CHECK` joins this in a later +/// ADR-0029 step.) +fn column_constraints_sql(spec: &ColumnSpec) -> Result { + let mut sql = String::new(); + if spec.not_null { + sql.push_str(" NOT NULL"); + } + if spec.unique { + sql.push_str(" UNIQUE"); + } + if let Some(value) = &spec.default { + let bound = value + .bind_for_column(&spec.name, spec.ty) + .map_err(|e| DbError::InvalidValue(e.to_string()))?; + sql.push_str(" DEFAULT "); + sql.push_str(&sql_literal(&bound_to_sqlite_value(&bound))); + } + Ok(sql) +} + fn do_create_table( conn: &Connection, persistence: Option<&Persistence>, @@ -1806,6 +1839,11 @@ fn do_create_table( if single_inline_pk { 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. + clause.push_str(&column_constraints_sql(col)?); column_clauses.push(clause); } @@ -2014,6 +2052,7 @@ fn do_add_auto_generated_column( notnull: true, primary_key: false, unique: true, + default_sql: None, user_type: Some(ty), }); @@ -3243,6 +3282,11 @@ struct ReadColumn { /// avoids double-emitting. Compound UNIQUE is out of scope /// for v1 (ADR-0018 OOS-6 / future C3 work). unique: bool, + /// The column's `DEFAULT` expression as SQLite reports it + /// (`pragma_table_info.dflt_value`) — already a SQL + /// literal, echoed verbatim by `schema_to_ddl` so the + /// rebuild dance preserves it (ADR-0029). + default_sql: Option, user_type: Option, } @@ -3259,7 +3303,7 @@ fn read_schema(conn: &Connection, table: &str) -> Result { // Columns + PK from pragma_table_info, joined with our user-type metadata. let mut col_stmt = conn .prepare(&format!( - "SELECT pti.name, pti.type, pti.\"notnull\", pti.pk, m.user_type \ + "SELECT pti.name, pti.type, pti.\"notnull\", pti.pk, m.user_type, pti.dflt_value \ FROM pragma_table_info(?1) AS pti \ LEFT JOIN {META_TABLE} AS m \ ON m.table_name = ?1 AND m.column_name = pti.name \ @@ -3276,6 +3320,7 @@ fn read_schema(conn: &Connection, table: &str) -> Result { notnull: row.get::<_, i64>(2)? != 0, primary_key: row.get::<_, i64>(3)? != 0, unique: false, // filled in below from pragma_index_list + default_sql: row.get(5)?, user_type, }) }) @@ -3492,6 +3537,12 @@ fn schema_to_ddl(table: &str, schema: &ReadSchema) -> String { if col.unique && !col.primary_key { clause.push_str(" UNIQUE"); } + // ADR-0029 DEFAULT — echoed verbatim from the value + // SQLite reported, so the rebuild dance preserves it. + if let Some(default_sql) = &col.default_sql { + clause.push_str(" DEFAULT "); + clause.push_str(default_sql); + } clauses.push(clause); } @@ -3742,6 +3793,7 @@ fn do_add_relationship( notnull: false, primary_key: false, unique: false, + default_sql: None, user_type: Some(expected_child_type), }); } else { @@ -4099,47 +4151,25 @@ fn do_describe_table_request( } fn do_describe_table(conn: &Connection, name: &str) -> Result { - // `pragma_table_info` is a table-valued function in modern - // SQLite; using it as a SELECT lets us bind the table name - // via ? rather than splicing it into a PRAGMA statement. - // We LEFT JOIN our metadata table to recover the user-facing - // type each column was declared as. - let mut stmt = conn - .prepare(&format!( - "SELECT pti.name, pti.type, pti.\"notnull\", pti.pk, m.user_type \ - FROM pragma_table_info(?1) AS pti \ - LEFT JOIN {META_TABLE} AS m \ - ON m.table_name = ?1 AND m.column_name = pti.name \ - ORDER BY pti.cid;" - )) - .map_err(DbError::from_rusqlite)?; - let rows = stmt - .query_map([name], |row| { - let user_type_kw: Option = row.get(4)?; - let user_type = user_type_kw.and_then(|kw| kw.parse::().ok()); - Ok(ColumnDescription { - name: row.get(0)?, - user_type, - sqlite_type: row.get(1)?, - notnull: row.get::<_, i64>(2)? != 0, - primary_key: row.get::<_, i64>(3)? != 0, - }) + // Column info — including the ADR-0029 constraints — comes + // from `read_schema`, the single source of per-column truth + // (it joins `pragma_table_info` with our type metadata and + // detects single-column UNIQUE via `pragma_index_list`). A + // missing table surfaces as `read_schema`'s NoSuchTable. + let schema = read_schema(conn, name)?; + let columns: Vec = schema + .columns + .iter() + .map(|c| ColumnDescription { + name: c.name.clone(), + user_type: c.user_type, + sqlite_type: c.sqlite_type.clone(), + notnull: c.notnull, + primary_key: c.primary_key, + unique: c.unique, + default: c.default_sql.clone(), }) - .map_err(DbError::from_rusqlite)?; - let mut columns = Vec::new(); - for row in rows { - columns.push(row.map_err(DbError::from_rusqlite)?); - } - if columns.is_empty() { - // pragma_table_info returns no rows for a non-existent - // table, which we surface as a NoSuchTable error so - // describe_table is not silently empty. - warn!(name, "describe_table: no columns (table missing?)"); - return Err(DbError::Sqlite { - message: format!("no such table: {name}"), - kind: SqliteErrorKind::NoSuchTable, - }); - } + .collect(); let outbound_relationships = read_relationships_outbound(conn, name)?; let inbound_relationships = read_relationships_inbound(conn, name)?; @@ -5334,6 +5364,7 @@ fn build_read_schema(table: &TableSchema, relationships: &[RelationshipSchema]) notnull: false, primary_key: table.primary_key.contains(&c.name), unique: c.unique, + default_sql: None, user_type: Some(c.user_type), }) .collect(); @@ -8243,6 +8274,185 @@ mod tests { assert!(result.is_err(), "explaining a missing table should fail"); } + // --- column constraints at create-table (ADR-0029) ------ + + /// A `ColumnSpec` carrying the four constraint slots. + fn col_c( + name: &str, + ty: Type, + not_null: bool, + unique: bool, + default: Option, + ) -> ColumnSpec { + ColumnSpec { + name: name.to_string(), + ty, + not_null, + unique, + default, + check: None, + } + } + + /// Create `T(id serial pk, )`. + async fn table_with(db: &Database, extra: ColumnSpec) -> TableDescription { + db.create_table( + "T".to_string(), + vec![col("id", Type::Serial), extra], + vec!["id".to_string()], + None, + ) + .await + .expect("create table") + } + + #[tokio::test] + async fn create_table_not_null_column_rejects_a_null_insert() { + let db = db(); + table_with(&db, col_c("name", Type::Text, true, false, None)).await; + let ok = db + .insert( + "T".to_string(), + Some(vec!["name".to_string()]), + vec![Value::Text("Alice".to_string())], + None, + ) + .await; + assert!(ok.is_ok(), "a non-null value is accepted"); + let bad = db + .insert( + "T".to_string(), + Some(vec!["name".to_string()]), + vec![Value::Null], + None, + ) + .await; + assert!(bad.is_err(), "NULL into a NOT NULL column is refused"); + } + + #[tokio::test] + async fn create_table_unique_column_rejects_a_duplicate_insert() { + let db = db(); + table_with(&db, col_c("email", Type::Text, false, true, None)).await; + let insert_email = |v: &str| { + db.insert( + "T".to_string(), + Some(vec!["email".to_string()]), + vec![Value::Text(v.to_string())], + None, + ) + }; + assert!(insert_email("a@x.io").await.is_ok()); + assert!( + insert_email("a@x.io").await.is_err(), + "a duplicate value violates UNIQUE", + ); + assert!( + insert_email("b@x.io").await.is_ok(), + "a distinct value is still accepted", + ); + } + + #[tokio::test] + async fn create_table_default_applies_when_the_column_is_omitted() { + let db = db(); + db.create_table( + "T".to_string(), + vec![ + col("id", Type::Serial), + col("name", Type::Text), + col_c("tier", Type::Int, false, false, Some(Value::Number("3".to_string()))), + ], + vec!["id".to_string()], + None, + ) + .await + .unwrap(); + // Insert names only `name` — `tier` is omitted and so + // takes its DEFAULT; `id` auto-fills as a serial. + db.insert( + "T".to_string(), + Some(vec!["name".to_string()]), + vec![Value::Text("Alice".to_string())], + None, + ) + .await + .unwrap(); + let data = db + .query_data("T".to_string(), None, None, None) + .await + .unwrap(); + let tier_idx = data.columns.iter().position(|c| c == "tier").unwrap(); + assert_eq!( + data.rows[0][tier_idx].as_deref(), + Some("3"), + "the omitted column took its DEFAULT", + ); + } + + #[tokio::test] + async fn describe_surfaces_the_column_constraints() { + let db = db(); + let desc = table_with( + &db, + col_c( + "email", + Type::Text, + true, + true, + Some(Value::Text("none".to_string())), + ), + ) + .await; + let email = desc.columns.iter().find(|c| c.name == "email").unwrap(); + assert!(email.notnull, "NOT NULL is described"); + assert!(email.unique, "UNIQUE is described"); + assert_eq!( + email.default.as_deref(), + Some("'none'"), + "the DEFAULT literal is described", + ); + } + + #[tokio::test] + async fn rebuild_preserves_column_constraints() { + // A type change on one column rebuilds the whole table + // through `schema_to_ddl`; the constraints on the other + // columns must survive that round-trip. + let db = db(); + db.create_table( + "T".to_string(), + vec![ + col("id", Type::Serial), + col_c("email", Type::Text, true, true, None), + col_c("tier", Type::Int, false, false, Some(Value::Number("1".to_string()))), + ], + vec!["id".to_string()], + None, + ) + .await + .unwrap(); + // Change `tier`'s type — int -> decimal — forcing a rebuild. + db.change_column_type( + "T".to_string(), + "tier".to_string(), + Type::Decimal, + ChangeColumnMode::Default, + None, + ) + .await + .unwrap(); + let desc = db.describe_table("T".to_string(), None).await.unwrap(); + let email = desc.columns.iter().find(|c| c.name == "email").unwrap(); + assert!(email.notnull && email.unique, "email keeps NOT NULL + UNIQUE"); + let tier = desc.columns.iter().find(|c| c.name == "tier").unwrap(); + assert_eq!( + tier.default.as_deref(), + Some("1"), + "tier keeps its DEFAULT across the rebuild", + ); + } + #[tokio::test] async fn update_with_all_rows_affects_everything() { let db = db(); @@ -8513,6 +8723,7 @@ mod tests { notnull: false, primary_key: true, unique: false, + default_sql: None, user_type: Some(Type::Serial), }, ReadColumn { @@ -8521,6 +8732,7 @@ mod tests { notnull: false, primary_key: false, unique: true, + default_sql: None, user_type: Some(Type::Text), }, ], diff --git a/src/output_render.rs b/src/output_render.rs index 2f266f8..7df0334 100644 --- a/src/output_render.rs +++ b/src/output_render.rs @@ -354,12 +354,20 @@ fn type_display(c: &ColumnDescription) -> String { } fn constraints_display(c: &ColumnDescription) -> String { - let mut parts: Vec<&str> = Vec::new(); + let mut parts: Vec = Vec::new(); if c.primary_key { - parts.push("PK"); + parts.push("PK".to_string()); } if c.notnull { - parts.push("NOT NULL"); + parts.push("NOT NULL".to_string()); + } + // ADR-0029: a PK column's implicit uniqueness is already + // conveyed by `PK`; `unique` is only set for non-PK columns. + if c.unique { + parts.push("UNIQUE".to_string()); + } + if let Some(default) = &c.default { + parts.push(format!("DEFAULT {default}")); } parts.join(", ") } @@ -511,6 +519,8 @@ mod tests { }, notnull, primary_key: pk, + unique: false, + default: None, } } @@ -978,6 +988,8 @@ mod tests { sqlite_type: "INTEGER".to_string(), notnull: false, primary_key: false, + unique: false, + default: None, }], outbound_relationships: Vec::new(), inbound_relationships: Vec::new(), diff --git a/src/ui.rs b/src/ui.rs index 2ba9ef0..8818c6e 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1115,6 +1115,8 @@ mod tests { sqlite_type: "INTEGER".to_string(), notnull: false, primary_key: true, + unique: false, + default: None, }, ColumnDescription { name: "Name".to_string(), @@ -1122,6 +1124,8 @@ mod tests { sqlite_type: "TEXT".to_string(), notnull: false, primary_key: false, + unique: false, + default: None, }, ], outbound_relationships: Vec::new(), diff --git a/tests/walking_skeleton.rs b/tests/walking_skeleton.rs index f86edd0..2d9ad56 100644 --- a/tests/walking_skeleton.rs +++ b/tests/walking_skeleton.rs @@ -250,6 +250,8 @@ fn fake_table(name: &str, columns: &[(&str, Type, bool)]) -> TableDescription { sqlite_type: t.sqlite_strict_type().to_string(), notnull: false, primary_key: *pk, + unique: false, + default: None, }) .collect(), outbound_relationships: Vec::new(), @@ -415,6 +417,8 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() { sqlite_type: "INTEGER".to_string(), notnull: false, primary_key: true, + unique: false, + default: None, }], outbound_relationships: Vec::new(), inbound_relationships: vec![RelationshipEnd { @@ -464,6 +468,8 @@ fn add_relationship_flow_shows_inbound_section_on_parent() { sqlite_type: "INTEGER".to_string(), notnull: false, primary_key: true, + unique: false, + default: None, }], outbound_relationships: Vec::new(), inbound_relationships: vec![RelationshipEnd {