diff --git a/src/app.rs b/src/app.rs index 93a51d6..1726e04 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1421,6 +1421,16 @@ impl App { RelationshipSelector::Named { .. } => (Operation::DropRelationship, None, None), }, C::AddIndex { table, .. } => (Operation::AddIndex, Some(table.as_str()), None), + C::AddConstraint { table, column, .. } => ( + Operation::AddConstraint, + Some(table.as_str()), + Some(column.as_str()), + ), + C::DropConstraint { table, column, .. } => ( + Operation::DropConstraint, + Some(table.as_str()), + Some(column.as_str()), + ), C::DropIndex { selector } => match selector { IndexSelector::Columns { table, .. } => { (Operation::DropIndex, Some(table.as_str()), None) diff --git a/src/completion.rs b/src/completion.rs index 445f9e8..99f150b 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -844,17 +844,19 @@ mod tests { fn multi_candidate_position_offers_add_subcommands() { // After `add ` the parser expects `column` (for // `add column ...`), `index` (for `add index ...`, - // ADR-0025), and `1` (the opener for + // ADR-0025), `constraint` (for `add constraint ...`, + // ADR-0029 §2.2), and `1` (the opener for // `add 1:n relationship ...`). The completion engine - // sections keyword candidates (`column`, `index`) - // ahead of the `1:n` composite literal, so the literal - // sorts last even though `add 1:n` is declared second. + // sections keyword candidates ahead of the `1:n` + // composite literal, so the literal sorts last even + // though `add 1:n` is declared second. let cs = cands("add ", 4); assert_eq!( cs, vec![ "column".to_string(), "index".to_string(), + "constraint".to_string(), "1:n".to_string(), ] ); @@ -1113,10 +1115,11 @@ mod tests { } #[test] - fn drop_offers_all_four_subcommands() { + fn drop_offers_all_five_subcommands() { // `drop` branches: column / relationship / table / index - // (ADR-0025). Candidates follow grammar declaration - // order, so `index` — added last — appears last. + // (ADR-0025) / constraint (ADR-0029 §2.2). Candidates + // follow grammar declaration order, so `constraint` — + // added last — appears last. let cs = cands("drop ", 5); assert_eq!( cs, @@ -1125,6 +1128,7 @@ mod tests { "relationship".to_string(), "table".to_string(), "index".to_string(), + "constraint".to_string(), ], ); } @@ -1686,15 +1690,20 @@ mod tests { c.sort_by(|a, b| a.text.cmp(&b.text)); c } - // `add ` exposes `column`, `1:n` and `index` — the - // alphabetic ranker reorders them. + // `add ` exposes `column`, `1:n`, `index` and + // `constraint` — the alphabetic ranker reorders them. let cache = SchemaCache::default(); let comp = candidates_at_cursor_with("add ", 4, &cache, alphabetic_ranker) .expect("some completion"); let texts: Vec = comp.candidates.into_iter().map(|c| c.text).collect(); assert_eq!( texts, - vec!["1:n".to_string(), "column".to_string(), "index".to_string()] + vec![ + "1:n".to_string(), + "column".to_string(), + "constraint".to_string(), + "index".to_string(), + ] ); } diff --git a/src/db.rs b/src/db.rs index fa3d478..0d558ab 100644 --- a/src/db.rs +++ b/src/db.rs @@ -32,8 +32,8 @@ use tracing::{debug, info}; use crate::dsl::action::ReferentialAction; use crate::dsl::command::{ - ChangeColumnMode, Command, CompareOp, Expr, IndexSelector, Operand, Predicate, - RelationshipSelector, RowFilter, + ChangeColumnMode, Command, CompareOp, Constraint, ConstraintKind, Expr, IndexSelector, + Operand, Predicate, RelationshipSelector, RowFilter, }; use crate::dsl::ColumnSpec; use crate::dsl::shortid; @@ -521,6 +521,25 @@ enum Request { source: Option, reply: oneshot::Sender>, }, + /// Add a column-level constraint to an existing column + /// (ADR-0029 §2.2). The post-rebuild `TableDescription` + /// flows back through the standard auto-show path. + AddConstraint { + table: String, + column: String, + constraint: Constraint, + source: Option, + reply: oneshot::Sender>, + }, + /// Remove a column-level constraint from an existing column + /// (ADR-0029 §2.2). + DropConstraint { + table: String, + column: String, + kind: ConstraintKind, + source: Option, + reply: oneshot::Sender>, + }, Insert { table: String, columns: Option>, @@ -746,6 +765,48 @@ impl Database { recv.await.map_err(|_| DbError::WorkerGone)? } + /// Add a column-level constraint to an existing column + /// (ADR-0029 §2.2). + pub async fn add_constraint( + &self, + table: String, + column: String, + constraint: Constraint, + source: Option, + ) -> Result { + let (reply, recv) = oneshot::channel(); + self.send(Request::AddConstraint { + table, + column, + constraint, + source, + reply, + }) + .await?; + recv.await.map_err(|_| DbError::WorkerGone)? + } + + /// Remove a column-level constraint from an existing column + /// (ADR-0029 §2.2). + pub async fn drop_constraint( + &self, + table: String, + column: String, + kind: ConstraintKind, + source: Option, + ) -> Result { + let (reply, recv) = oneshot::channel(); + self.send(Request::DropConstraint { + table, + column, + kind, + source, + reply, + }) + .await?; + recv.await.map_err(|_| DbError::WorkerGone)? + } + pub async fn rename_column( &self, table: String, @@ -1292,6 +1353,38 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req &selector, )); } + Request::AddConstraint { + table, + column, + constraint, + source, + reply, + } => { + let _ = reply.send(do_add_constraint( + conn, + persistence, + source.as_deref(), + &table, + &column, + &constraint, + )); + } + Request::DropConstraint { + table, + column, + kind, + source, + reply, + } => { + let _ = reply.send(do_drop_constraint( + conn, + persistence, + source.as_deref(), + &table, + &column, + kind, + )); + } Request::Insert { table, columns, @@ -1813,13 +1906,22 @@ fn column_constraints_sql(spec: &ColumnSpec) -> Result { /// against the column's user-facing type (ADR-0029). `None` /// when the column carries no default. fn default_sql_literal(spec: &ColumnSpec) -> Result, DbError> { - let Some(value) = &spec.default else { - return Ok(None); - }; + match &spec.default { + Some(value) => Ok(Some(value_to_default_sql(value, &spec.name, spec.ty)?)), + None => Ok(None), + } +} + +/// The SQL literal for a `DEFAULT` `value` on a `column` of +/// user-facing type `ty` (ADR-0029) — the value-bound, +/// type-checked rendering shared by `default_sql_literal` (the +/// create-table / add-column path) and `do_add_constraint` +/// (the `add constraint default` path). +fn value_to_default_sql(value: &Value, column: &str, ty: Type) -> Result { let bound = value - .bind_for_column(&spec.name, spec.ty) + .bind_for_column(column, ty) .map_err(|e| DbError::InvalidValue(e.to_string()))?; - Ok(Some(sql_literal(&bound_to_sqlite_value(&bound)))) + Ok(sql_literal(&bound_to_sqlite_value(&bound))) } /// Compile a `CHECK` expression to inline SQL (ADR-0029 §4 / @@ -2344,6 +2446,510 @@ fn do_add_constrained_column_via_rebuild( }) } +// ================================================================= +// add constraint / drop constraint (ADR-0029 §2.2 / §5 / §9) +// ================================================================= + +/// Add a column-level constraint to an existing column +/// (ADR-0029 §2.2). Steps: resolve the column; apply the §9 +/// redundant-on-PK and §6 default-on-auto-generated refusals; +/// run the §5 dry-run for the constraints existing data can +/// violate (`not null` / `unique` / `check`); then apply the +/// constraint through the rebuild-table primitive — SQLite's +/// `ALTER TABLE` cannot add these to an existing column. +fn do_add_constraint( + conn: &Connection, + persistence: Option<&Persistence>, + source: Option<&str>, + table: &str, + column: &str, + constraint: &Constraint, +) -> Result { + let old_schema = read_schema(conn, table)?; + let (col_is_pk, col_user_type) = { + let col = old_schema + .columns + .iter() + .find(|c| c.name == column) + .ok_or_else(|| DbError::Sqlite { + message: format!("no such column: {table}.{column}"), + kind: SqliteErrorKind::NoSuchColumn, + })?; + (col.primary_key, col.user_type) + }; + let single_column_pk = old_schema.primary_key.len() == 1; + + // ADR-0029 §9 — a constraint the primary key already + // implies is a friendly refusal, not a silent no-op. + match constraint { + Constraint::NotNull if col_is_pk => { + return Err(DbError::Unsupported(format!( + "`{table}.{column}` is a primary-key column, so it is already \ + NOT NULL — there is no constraint to add." + ))); + } + Constraint::Unique if col_is_pk && single_column_pk => { + return Err(DbError::Unsupported(format!( + "`{table}.{column}` is a single-column primary key, so it is \ + already UNIQUE — there is no constraint to add." + ))); + } + // ADR-0029 §6 — an auto-generated column fills its own + // values, so a `default` would be a second, ambiguous + // source of "the value when none is given". + Constraint::Default(_) + if matches!(col_user_type, Some(Type::Serial | Type::ShortId)) => + { + return Err(DbError::Unsupported(format!( + "`{table}.{column}` is a {ty} column — it auto-fills its own \ + values, so it cannot also carry a `default`.", + ty = col_user_type.expect("matched Some above").keyword(), + ))); + } + _ => {} + } + + // Compile the CHECK once — reused by the dry-run predicate + // and the column DDL / metadata write. + let check_sql: Option = match constraint { + Constraint::Check(expr) => Some(compile_check_sql(expr, &old_schema)), + _ => None, + }; + + // ADR-0029 §5 — refuse, before any SQL write, when the + // existing rows violate the constraint. `default` never + // touches existing rows, so it skips the dry-run. + let refusal = match constraint { + Constraint::NotNull => dry_run_not_null(conn, &old_schema, table, column)?, + Constraint::Unique => dry_run_unique(conn, &old_schema, table, column)?, + Constraint::Check(_) => dry_run_check( + conn, + &old_schema, + table, + column, + check_sql.as_deref().expect("check_sql set for a Check constraint"), + )?, + Constraint::Default(_) => None, + }; + if let Some(message) = refusal { + return Err(DbError::Unsupported(message)); + } + + // Build the post-add schema: the target column gains the + // constraint; every other column carries over unchanged. + let mut new_schema = old_schema.clone(); + { + let target = new_schema + .columns + .iter_mut() + .find(|c| c.name == column) + .expect("column existence checked above"); + match constraint { + Constraint::NotNull => target.notnull = true, + Constraint::Unique => target.unique = true, + Constraint::Default(value) => { + let ty = target.user_type.ok_or_else(|| { + DbError::Unsupported(format!( + "`{table}.{column}` has no user-type metadata; \ + cannot bind a default value." + )) + })?; + target.default_sql = Some(value_to_default_sql(value, column, ty)?); + } + Constraint::Check(_) => target.check.clone_from(&check_sql), + } + } + + let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> { + // Only CHECK needs a metadata write — NOT NULL / UNIQUE / + // DEFAULT are recoverable from the engine's own catalog + // (ADR-0029 §7). + if matches!(constraint, Constraint::Check(_)) { + tx.execute( + &format!( + "UPDATE {META_TABLE} SET check_expr = ?1 \ + WHERE table_name = ?2 AND column_name = ?3;" + ), + rusqlite::params![check_sql, table, column], + ) + .map_err(DbError::from_rusqlite)?; + } + let changes = Changes { + schema_dirty: true, + rewritten_tables: vec![table.to_string()], + ..Changes::default() + }; + finalize_persistence(tx, persistence, source, &changes)?; + Ok(()) + }; + + rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates)?; + do_describe_table(conn, table) +} + +/// Remove a column-level constraint from an existing column +/// (ADR-0029 §2.2). Removing a constraint cannot violate the +/// data, so there is no dry-run; the §9 PK-implied refusals +/// still apply, and dropping a constraint the column does not +/// carry is itself a friendly refusal. +fn do_drop_constraint( + conn: &Connection, + persistence: Option<&Persistence>, + source: Option<&str>, + table: &str, + column: &str, + kind: ConstraintKind, +) -> Result { + let old_schema = read_schema(conn, table)?; + let (col_is_pk, present) = { + let col = old_schema + .columns + .iter() + .find(|c| c.name == column) + .ok_or_else(|| DbError::Sqlite { + message: format!("no such column: {table}.{column}"), + kind: SqliteErrorKind::NoSuchColumn, + })?; + let present = match kind { + ConstraintKind::NotNull => col.notnull, + ConstraintKind::Unique => col.unique, + ConstraintKind::Default => col.default_sql.is_some(), + ConstraintKind::Check => col.check.is_some(), + }; + (col.primary_key, present) + }; + let single_column_pk = old_schema.primary_key.len() == 1; + + // ADR-0029 §9 — the primary key still enforces these, so + // there is nothing for `drop constraint` to remove. + match kind { + ConstraintKind::NotNull if col_is_pk => { + return Err(DbError::Unsupported(format!( + "`{table}.{column}` is a primary-key column — its NOT NULL is \ + enforced by the primary key and cannot be dropped." + ))); + } + ConstraintKind::Unique if col_is_pk && single_column_pk => { + return Err(DbError::Unsupported(format!( + "`{table}.{column}` is a single-column primary key — its \ + UNIQUE is enforced by the primary key and cannot be dropped." + ))); + } + _ => {} + } + + if !present { + return Err(DbError::Unsupported(format!( + "`{table}.{column}` has no {kind} constraint to drop.", + kind = kind.label(), + ))); + } + + let mut new_schema = old_schema.clone(); + { + let target = new_schema + .columns + .iter_mut() + .find(|c| c.name == column) + .expect("column existence checked above"); + match kind { + ConstraintKind::NotNull => target.notnull = false, + ConstraintKind::Unique => target.unique = false, + ConstraintKind::Default => target.default_sql = None, + ConstraintKind::Check => target.check = None, + } + } + + let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> { + if kind == ConstraintKind::Check { + tx.execute( + &format!( + "UPDATE {META_TABLE} SET check_expr = NULL \ + WHERE table_name = ?1 AND column_name = ?2;" + ), + rusqlite::params![table, column], + ) + .map_err(DbError::from_rusqlite)?; + } + let changes = Changes { + schema_dirty: true, + rewritten_tables: vec![table.to_string()], + ..Changes::default() + }; + finalize_persistence(tx, persistence, source, &changes)?; + Ok(()) + }; + + rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates)?; + do_describe_table(conn, table) +} + +/// A row's primary-key cell values paired with its value in +/// the column under test — the unit an ADR-0029 §5 dry-run +/// scans. +type DryRunRow = (Vec, rusqlite::types::Value); + +/// Read the primary-key cell values of every row of `table` +/// matching `where_sql`, paired with the row's value in +/// `column`. The shared read behind the three ADR-0029 §5 +/// dry-runs; `where_sql` is built from `quote_ident`-quoted +/// names and `compile_check_sql` output, never raw user text. +fn read_constraint_dry_run_rows( + conn: &Connection, + schema: &ReadSchema, + table: &str, + column: &str, + where_sql: &str, +) -> Result, DbError> { + use rusqlite::types::Value as RV; + let pk_columns = &schema.primary_key; + let mut select_idents: Vec = + pk_columns.iter().map(|c| quote_ident(c)).collect(); + select_idents.push(quote_ident(column)); + let sql = format!( + "SELECT {cols} FROM {tbl} WHERE {pred};", + cols = select_idents.join(", "), + tbl = quote_ident(table), + pred = where_sql, + ); + let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?; + let pk_count = pk_columns.len(); + let mut out = Vec::new(); + let mut rows = stmt.query([]).map_err(DbError::from_rusqlite)?; + while let Some(r) = rows.next().map_err(DbError::from_rusqlite)? { + let mut pk_values: Vec = Vec::with_capacity(pk_count); + for i in 0..pk_count { + pk_values.push(r.get(i).map_err(DbError::from_rusqlite)?); + } + let target: RV = r.get(pk_count).map_err(DbError::from_rusqlite)?; + out.push((pk_values, target)); + } + Ok(out) +} + +/// Header cells identifying an offending row in a §5 dry-run +/// table — the primary-key columns. The DSL always creates a +/// primary key, so the no-PK fallback (the constrained column +/// itself) is defensive only. +fn dry_run_id_headers(schema: &ReadSchema, column: &str) -> Vec { + if schema.primary_key.is_empty() { + vec![column.to_string()] + } else { + pk_header_cells(&schema.primary_key) + } +} + +fn dry_run_id_alignments(schema: &ReadSchema) -> Vec { + if schema.primary_key.is_empty() { + vec![Alignment::Left] + } else { + pk_header_alignments(&schema.primary_key, schema) + } +} + +fn dry_run_id_cells( + schema: &ReadSchema, + pk_values: &[rusqlite::types::Value], + target: &rusqlite::types::Value, +) -> Vec { + if schema.primary_key.is_empty() { + vec![render_value(target)] + } else { + pk_value_cells(pk_values) + } +} + +/// Assemble a §5 dry-run refusal: a summary line above the +/// pretty-printed table of offending rows. +fn render_constraint_dry_run( + summary: String, + headers: &[String], + alignments: &[Alignment], + rows: Vec>, +) -> String { + let mut out = format!("{summary}\n\n"); + for line in render_diagnostic_table(headers, &rows, alignments) { + out.push_str(&line); + out.push('\n'); + } + out +} + +/// ADR-0029 §5 dry-run for `add constraint not null`: any row +/// whose target column holds `NULL` violates the constraint. +fn dry_run_not_null( + conn: &Connection, + schema: &ReadSchema, + table: &str, + column: &str, +) -> Result, DbError> { + let rows = read_constraint_dry_run_rows( + conn, + schema, + table, + column, + &format!("{col} IS NULL", col = quote_ident(column)), + )?; + if rows.is_empty() { + return Ok(None); + } + let total = rows.len(); + let headers = dry_run_id_headers(schema, column); + let alignments = dry_run_id_alignments(schema); + let visible = total.min(DIAGNOSTIC_ROW_CAP); + let mut out_rows: Vec> = Vec::with_capacity(visible + 1); + for (pk, target) in rows.iter().take(visible) { + out_rows.push(dry_run_id_cells(schema, pk, target)); + } + if total > visible { + out_rows.push(more_row(headers.len(), total - visible)); + } + Ok(Some(render_constraint_dry_run( + crate::t!( + "db.diagnostic.add_not_null_summary", + table = table, + column = column, + total = total, + ), + &headers, + &alignments, + out_rows, + ))) +} + +/// ADR-0029 §5 dry-run for `add constraint unique`: any +/// non-`NULL` value shared by two or more rows collides +/// (SQL's "NULLs are distinct" rule means `NULL`s never do). +fn dry_run_unique( + conn: &Connection, + schema: &ReadSchema, + table: &str, + column: &str, +) -> Result, DbError> { + use std::collections::BTreeMap; + + let rows = read_constraint_dry_run_rows( + conn, + schema, + table, + column, + &format!("{col} IS NOT NULL", col = quote_ident(column)), + )?; + let mut groups: BTreeMap> = BTreeMap::new(); + for (pk, target) in rows { + groups + .entry(render_value(&target)) + .or_default() + .push((pk, target)); + } + let collisions: Vec<(String, Vec)> = groups + .into_iter() + .filter(|(_, members)| members.len() >= 2) + .collect(); + if collisions.is_empty() { + return Ok(None); + } + + let total = collisions.len(); + let pk_label = if schema.primary_key.is_empty() { + column.to_string() + } else { + schema.primary_key.join(", ") + }; + let headers = vec![ + crate::t!("db.diagnostic.header_value"), + crate::t!("db.diagnostic.header_source_rows", pk_label = pk_label), + ]; + let alignments = vec![Alignment::Left, Alignment::Left]; + let visible = total.min(DIAGNOSTIC_ROW_CAP); + let mut out_rows: Vec> = Vec::with_capacity(visible + 1); + for (value, members) in collisions.iter().take(visible) { + let ids = members + .iter() + .take(5) + .map(|(pk, target)| { + if schema.primary_key.is_empty() { + render_value(target) + } else { + pk_value_cells_inline(pk) + } + }) + .collect::>() + .join(", "); + let ids = if members.len() > 5 { + format!("{ids}, …") + } else { + ids + }; + out_rows.push(vec![value.clone(), ids]); + } + if total > visible { + out_rows.push(more_row(headers.len(), total - visible)); + } + Ok(Some(render_constraint_dry_run( + crate::t!( + "db.diagnostic.add_unique_summary", + table = table, + column = column, + total = total, + ), + &headers, + &alignments, + out_rows, + ))) +} + +/// ADR-0029 §5 dry-run for `add constraint check`: any row for +/// which the compiled `check_sql` is definitively false +/// violates the constraint. (`NOT (expr)` is true only when +/// `expr` is false — a `NULL` result, which the engine's CHECK +/// also tolerates, does not select the row.) +fn dry_run_check( + conn: &Connection, + schema: &ReadSchema, + table: &str, + column: &str, + check_sql: &str, +) -> Result, DbError> { + let rows = read_constraint_dry_run_rows( + conn, + schema, + table, + column, + &format!("NOT ({check_sql})"), + )?; + if rows.is_empty() { + return Ok(None); + } + let total = rows.len(); + let mut headers = dry_run_id_headers(schema, column); + headers.push(crate::t!("db.diagnostic.header_value")); + let mut alignments = dry_run_id_alignments(schema); + alignments.push(Alignment::Left); + let visible = total.min(DIAGNOSTIC_ROW_CAP); + let mut out_rows: Vec> = Vec::with_capacity(visible + 1); + for (pk, target) in rows.iter().take(visible) { + let mut cells = dry_run_id_cells(schema, pk, target); + cells.push(render_value(target)); + out_rows.push(cells); + } + if total > visible { + out_rows.push(more_row(headers.len(), total - visible)); + } + Ok(Some(render_constraint_dry_run( + crate::t!( + "db.diagnostic.add_check_summary", + table = table, + column = column, + total = total, + rule = check_sql, + ), + &headers, + &alignments, + out_rows, + ))) +} + /// Generate `count` shortid values that don't collide with each /// other or with `existing` (a slice of currently-stored /// shortid values, used during change-column-to-shortid). Up to @@ -3732,11 +4338,13 @@ fn schema_to_ddl(table: &str, schema: &ReadSchema) -> String { if single_inline_pk && col.primary_key { clause.push_str(" PRIMARY KEY"); } - // Inline UNIQUE for non-PK columns flagged unique - // (ADR-0018 §4). PK columns get UNIQUE implicitly via - // PRIMARY KEY; double-emitting would still be valid SQL - // but generates a redundant index. - if col.unique && !col.primary_key { + // Inline UNIQUE for columns flagged unique (ADR-0018 §4, + // ADR-0029 §9). A *single-column* PK is already UNIQUE + // via PRIMARY KEY, so suppress a redundant index there; + // a *compound*-PK member is not individually unique, so + // an explicit UNIQUE on it is a real, distinct rule. + let single_column_pk = schema.primary_key.len() == 1; + if col.unique && !(col.primary_key && single_column_pk) { clause.push_str(" UNIQUE"); } // ADR-0029 DEFAULT — echoed verbatim from the value @@ -9482,4 +10090,337 @@ mod tests { .unwrap(); assert_eq!(names, vec!["cust_orders".to_string()]); } + + // --- add constraint / drop constraint (ADR-0029 §2.2) ----- + + /// A `Constraint::Check` whose expression references + /// `column` of type `ty`, parsed from `check_dsl`. + fn check_constraint(column: &str, ty: Type, check_dsl: &str) -> Constraint { + Constraint::Check( + col_c_check(column, ty, check_dsl) + .check + .expect("the CHECK expression parses"), + ) + } + + /// `People` plus an all-NULL plain `int` column `x`. + async fn people_with_null_column(db: &Database) { + people_table(db).await; + db.add_column("People".to_string(), col("x", Type::Int), None) + .await + .expect("a plain nullable column adds"); + } + + #[tokio::test] + async fn add_constraint_not_null_succeeds_on_clean_column() { + let db = db(); + people_table(&db).await; // every row has a non-null Name + let desc = db + .add_constraint( + "People".to_string(), + "Name".to_string(), + Constraint::NotNull, + None, + ) + .await + .expect("NOT NULL applies — no row holds a null"); + assert!( + desc.columns.iter().find(|c| c.name == "Name").unwrap().notnull, + "the column is now NOT NULL", + ); + } + + #[tokio::test] + async fn add_constraint_not_null_refused_when_rows_are_null() { + let db = db(); + people_with_null_column(&db).await; // `x` is null in every row + let result = db + .add_constraint("People".to_string(), "x".to_string(), Constraint::NotNull, None) + .await; + let err = result.expect_err("NOT NULL is refused — rows hold null"); + assert!( + format!("{err}").contains("null"), + "the refusal explains the null rows: {err}", + ); + } + + #[tokio::test] + async fn add_constraint_unique_succeeds_on_distinct_values() { + let db = db(); + people_table(&db).await; // Names are all distinct + let desc = db + .add_constraint( + "People".to_string(), + "Name".to_string(), + Constraint::Unique, + None, + ) + .await + .expect("UNIQUE applies — every Name is distinct"); + assert!(desc.columns.iter().find(|c| c.name == "Name").unwrap().unique); + } + + #[tokio::test] + async fn add_constraint_unique_refused_on_duplicate_values() { + let db = db(); + people_table(&db).await; // Age 35 appears for Bob and Dave + let result = db + .add_constraint("People".to_string(), "Age".to_string(), Constraint::Unique, None) + .await; + assert!( + result.is_err(), + "UNIQUE is refused — the value 35 appears in two rows", + ); + } + + #[tokio::test] + async fn add_constraint_check_succeeds_when_data_satisfies_it() { + let db = db(); + people_table(&db).await; // every Age is >= 0 + let desc = db + .add_constraint( + "People".to_string(), + "Age".to_string(), + check_constraint("Age", Type::Int, "Age >= 0"), + None, + ) + .await + .expect("the CHECK applies — all rows satisfy it"); + assert!(desc.columns.iter().find(|c| c.name == "Age").unwrap().check.is_some()); + } + + #[tokio::test] + async fn add_constraint_check_refused_when_rows_violate_it() { + let db = db(); + people_table(&db).await; // Alice is 25, Bob/Dave are 35 + let result = db + .add_constraint( + "People".to_string(), + "Age".to_string(), + check_constraint("Age", Type::Int, "Age >= 40"), + None, + ) + .await; + assert!(result.is_err(), "the CHECK is refused — three rows are under 40"); + } + + #[tokio::test] + async fn add_constraint_default_succeeds() { + let db = db(); + people_table(&db).await; + let desc = db + .add_constraint( + "People".to_string(), + "Age".to_string(), + Constraint::Default(Value::Number("0".to_string())), + None, + ) + .await + .expect("a DEFAULT applies — it never touches existing rows"); + assert_eq!( + desc.columns.iter().find(|c| c.name == "Age").unwrap().default.as_deref(), + Some("0"), + ); + } + + #[tokio::test] + async fn add_constraint_check_is_enforced_after_being_added() { + let db = db(); + people_table(&db).await; + db.add_constraint( + "People".to_string(), + "Age".to_string(), + check_constraint("Age", Type::Int, "Age >= 0"), + None, + ) + .await + .unwrap(); + let bad = db + .insert( + "People".to_string(), + None, + vec![ + Value::Text("Eve".to_string()), + Value::Number("-1".to_string()), + Value::Bool(true), + ], + None, + ) + .await; + assert!(bad.is_err(), "an insert violating the new CHECK is refused"); + } + + #[tokio::test] + async fn add_constraint_not_null_on_pk_column_is_refused() { + let db = db(); + people_table(&db).await; + let result = db + .add_constraint("People".to_string(), "id".to_string(), Constraint::NotNull, None) + .await; + assert!(result.is_err(), "a PK column is already NOT NULL (ADR-0029 §9)"); + } + + #[tokio::test] + async fn add_constraint_unique_on_single_column_pk_is_refused() { + let db = db(); + people_table(&db).await; + let result = db + .add_constraint("People".to_string(), "id".to_string(), Constraint::Unique, None) + .await; + assert!( + result.is_err(), + "a single-column PK is already UNIQUE (ADR-0029 §9)", + ); + } + + #[tokio::test] + async fn add_constraint_unique_on_compound_pk_member_is_allowed() { + // A compound PK does not make its members individually + // unique, so `unique` on one of them is meaningful. + let db = db(); + db.create_table( + "T".to_string(), + vec![col("a", Type::Int), col("b", Type::Int)], + vec!["a".to_string(), "b".to_string()], + None, + ) + .await + .unwrap(); + let desc = db + .add_constraint("T".to_string(), "a".to_string(), Constraint::Unique, None) + .await + .expect("UNIQUE on a compound-PK member is allowed"); + assert!(desc.columns.iter().find(|c| c.name == "a").unwrap().unique); + } + + #[tokio::test] + async fn add_constraint_default_on_serial_column_is_refused() { + let db = db(); + people_table(&db).await; + let result = db + .add_constraint( + "People".to_string(), + "id".to_string(), + Constraint::Default(Value::Number("5".to_string())), + None, + ) + .await; + assert!( + result.is_err(), + "a serial column auto-fills its own values (ADR-0029 §6)", + ); + } + + #[tokio::test] + async fn add_constraint_to_missing_column_errors() { + let db = db(); + people_table(&db).await; + let result = db + .add_constraint("People".to_string(), "ghost".to_string(), Constraint::Unique, None) + .await; + assert!(result.is_err(), "no such column"); + } + + #[tokio::test] + async fn drop_constraint_removes_not_null() { + let db = db(); + people_table(&db).await; + db.add_constraint( + "People".to_string(), + "Name".to_string(), + Constraint::NotNull, + None, + ) + .await + .unwrap(); + let desc = db + .drop_constraint( + "People".to_string(), + "Name".to_string(), + ConstraintKind::NotNull, + None, + ) + .await + .expect("the NOT NULL is dropped"); + assert!( + !desc.columns.iter().find(|c| c.name == "Name").unwrap().notnull, + "the column is nullable again", + ); + } + + #[tokio::test] + async fn drop_constraint_check_clears_the_constraint() { + let db = db(); + people_table(&db).await; + db.add_constraint( + "People".to_string(), + "Age".to_string(), + check_constraint("Age", Type::Int, "Age >= 0"), + None, + ) + .await + .unwrap(); + let desc = db + .drop_constraint( + "People".to_string(), + "Age".to_string(), + ConstraintKind::Check, + None, + ) + .await + .expect("the CHECK is dropped"); + assert!( + desc.columns.iter().find(|c| c.name == "Age").unwrap().check.is_none(), + "the CHECK is gone from the structure view", + ); + // With the CHECK gone, a previously-forbidden value inserts. + db.insert( + "People".to_string(), + None, + vec![ + Value::Text("Eve".to_string()), + Value::Number("-1".to_string()), + Value::Bool(true), + ], + None, + ) + .await + .expect("the CHECK no longer applies"); + } + + #[tokio::test] + async fn drop_constraint_not_null_on_pk_is_refused() { + let db = db(); + people_table(&db).await; + let result = db + .drop_constraint( + "People".to_string(), + "id".to_string(), + ConstraintKind::NotNull, + None, + ) + .await; + assert!( + result.is_err(), + "the PK still enforces NOT NULL — nothing to drop (ADR-0029 §9)", + ); + } + + #[tokio::test] + async fn drop_constraint_absent_constraint_is_refused() { + let db = db(); + people_table(&db).await; // `Name` carries no UNIQUE + let result = db + .drop_constraint( + "People".to_string(), + "Name".to_string(), + ConstraintKind::Unique, + None, + ) + .await; + assert!( + result.is_err(), + "dropping a constraint the column never had is a friendly refusal", + ); + } } diff --git a/src/dsl/command.rs b/src/dsl/command.rs index e75b4c8..ca41dc6 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -53,6 +53,60 @@ impl ColumnSpec { } } +/// A column-level constraint with its payload (ADR-0029 §3). +/// +/// Produced by `add constraint to .`. +/// `Default` / `Check` carry the value / expression; `NotNull` +/// and `Unique` are payload-free. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Constraint { + NotNull, + Unique, + Default(Value), + Check(Expr), +} + +impl Constraint { + /// The bare constraint kind, dropping any payload — used for + /// the `[ok]` summary line and log output. + #[must_use] + pub const fn kind(&self) -> ConstraintKind { + match self { + Self::NotNull => ConstraintKind::NotNull, + Self::Unique => ConstraintKind::Unique, + Self::Default(_) => ConstraintKind::Default, + Self::Check(_) => ConstraintKind::Check, + } + } +} + +/// The kind of a column-level constraint, without a payload. +/// +/// Produced by `drop constraint from .` +/// (ADR-0029 §3) — naming the kind is enough, since at most one +/// constraint of each kind exists per column. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConstraintKind { + NotNull, + Unique, + Default, + Check, +} + +impl ConstraintKind { + /// Upper-case SQL-style label for user-facing messages + /// (`NOT NULL`, `UNIQUE`, `DEFAULT`, `CHECK`). + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::NotNull => "NOT NULL", + Self::Unique => "UNIQUE", + Self::Default => "DEFAULT", + Self::Check => "CHECK", + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum Command { CreateTable { @@ -150,6 +204,22 @@ pub enum Command { DropIndex { selector: IndexSelector, }, + /// Add a column-level constraint to an existing column + /// (ADR-0029 §2.2). Applied through the rebuild-table + /// primitive after a §5 dry-run guards populated columns. + AddConstraint { + table: String, + column: String, + constraint: Constraint, + }, + /// Remove a column-level constraint from an existing column + /// (ADR-0029 §2.2). Naming the `kind` is enough — at most + /// one constraint of each kind exists per column. + DropConstraint { + table: String, + column: String, + kind: ConstraintKind, + }, /// Re-display a table's structure in the output. Doesn't /// change schema; useful when the user wants to look at a /// table they aren't currently DDL'ing on. @@ -498,6 +568,8 @@ impl Command { Self::DropRelationship { .. } => "drop relationship", Self::AddIndex { .. } => "add index", Self::DropIndex { .. } => "drop index", + Self::AddConstraint { .. } => "add constraint", + Self::DropConstraint { .. } => "drop constraint", Self::ShowTable { .. } => "show table", Self::Insert { .. } => "insert into", Self::Update { .. } => "update", @@ -536,6 +608,8 @@ impl Command { | Self::DropColumn { table, .. } | Self::RenameColumn { table, .. } | Self::ChangeColumnType { table, .. } + | Self::AddConstraint { table, .. } + | Self::DropConstraint { table, .. } | Self::Insert { table, .. } | Self::Update { table, .. } | Self::Delete { table, .. } => table, @@ -598,6 +672,12 @@ impl Command { "from {parent_table}.{parent_column} to {child_table}.{child_column}" ), }, + // A constraint command's subject is the dotted + // `.` it acts on (ADR-0029 §2.2). + Self::AddConstraint { table, column, .. } + | Self::DropConstraint { table, column, .. } => { + format!("{table}.{column}") + } _ => self.target_table().to_string(), } } diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs index 4bb12c8..ceaf7c3 100644 --- a/src/dsl/grammar/ddl.rs +++ b/src/dsl/grammar/ddl.rs @@ -13,7 +13,8 @@ use crate::dsl::action::ReferentialAction; use crate::dsl::command::{ - ChangeColumnMode, ColumnSpec, Command, Expr, IndexSelector, RelationshipSelector, + ChangeColumnMode, ColumnSpec, Command, Constraint, ConstraintKind, Expr, IndexSelector, + RelationshipSelector, }; use crate::dsl::value::Value; use crate::dsl::grammar::{ @@ -283,7 +284,8 @@ const DROP_INDEX: Node = Node::Seq(DROP_INDEX_NODES); // drop entry — `drop (table|column|relationship|index) ...` // ================================================================= -const DROP_CHOICES: &[Node] = &[DROP_COLUMN, DROP_RELATIONSHIP, DROP_TABLE, DROP_INDEX]; +const DROP_CHOICES: &[Node] = + &[DROP_COLUMN, DROP_RELATIONSHIP, DROP_TABLE, DROP_INDEX, DROP_CONSTRAINT]; const DROP_SHAPE: Node = Node::Choice(DROP_CHOICES); // ================================================================= @@ -410,7 +412,7 @@ const ADD_INDEX: Node = Node::Seq(ADD_INDEX_NODES); // add entry — `add (column|1:n relationship|index) …` // ================================================================= -const ADD_CHOICES: &[Node] = &[ADD_COLUMN, ADD_RELATIONSHIP, ADD_INDEX]; +const ADD_CHOICES: &[Node] = &[ADD_COLUMN, ADD_RELATIONSHIP, ADD_INDEX, ADD_CONSTRAINT]; const ADD_SHAPE: Node = Node::Choice(ADD_CHOICES); // ================================================================= @@ -564,6 +566,7 @@ fn build_drop(path: &MatchedPath) -> Result { }) } } + Some("constraint") => build_drop_constraint(path), Some("relationship") => { // Endpoints form has `from` as the third Word. let has_from = path @@ -634,6 +637,7 @@ fn build_add(path: &MatchedPath) -> Result { table: require_ident(path, "table_name")?, columns: collect_idents(path, "column_name"), }), + Some("constraint") => build_add_constraint(path), _ => Err(ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "unknown add subcommand".to_string())], @@ -764,6 +768,72 @@ fn build_change_column(path: &MatchedPath) -> Result { }) } +/// Build an `add constraint to .` command +/// (ADR-0029 §2.2). The `` reuses the §2.1 +/// `COLUMN_CONSTRAINT` Choice, so exactly one of the four +/// constraint kinds is matched; `collect_column_constraints` +/// recovers it. The §9 redundancy and §5 dry-run checks are +/// execution-time (the parser has no schema) and live in the +/// database worker. +fn build_add_constraint(path: &MatchedPath) -> Result { + let (not_null, unique, default, check) = collect_column_constraints(path)?; + let constraint = if not_null { + Constraint::NotNull + } else if unique { + Constraint::Unique + } else if let Some(value) = default { + Constraint::Default(value) + } else if let Some(expr) = check { + Constraint::Check(expr) + } else { + return Err(ValidationError { + message_key: "parse.error_wrapper", + args: vec![("detail", "add constraint needs a constraint".to_string())], + }); + }; + Ok(Command::AddConstraint { + table: require_ident(path, "table_name")?, + column: require_ident(path, "column_name")?, + constraint, + }) +} + +/// Build a `drop constraint from .` command +/// (ADR-0029 §2.2). `drop` names only the kind — the +/// `DROP_CONSTRAINT_KIND` Choice is payload-free, so the kind +/// is recovered from which keyword(s) the path matched. +fn build_drop_constraint(path: &MatchedPath) -> Result { + let words: Vec<&'static str> = path + .items + .iter() + .filter_map(|i| match &i.kind { + MatchedKind::Word(w) => Some(*w), + _ => None, + }) + .collect(); + // `not` appears only in the `not null` Seq, so its presence + // alone identifies the kind. + let kind = if words.contains(&"not") { + ConstraintKind::NotNull + } else if words.contains(&"unique") { + ConstraintKind::Unique + } else if words.contains(&"default") { + ConstraintKind::Default + } else if words.contains(&"check") { + ConstraintKind::Check + } else { + return Err(ValidationError { + message_key: "parse.error_wrapper", + args: vec![("detail", "drop constraint needs a constraint kind".to_string())], + }); + }; + Ok(Command::DropConstraint { + table: require_ident(path, "table_name")?, + column: require_ident(path, "column_name")?, + kind, + }) +} + // ================================================================= // CommandNodes // ================================================================= @@ -778,6 +848,7 @@ pub static DROP: CommandNode = CommandNode { "parse.usage.drop_column", "parse.usage.drop_relationship", "parse.usage.drop_index", + "parse.usage.drop_constraint", ],}; pub static ADD: CommandNode = CommandNode { @@ -789,6 +860,7 @@ pub static ADD: CommandNode = CommandNode { "parse.usage.add_column", "parse.usage.add_relationship", "parse.usage.add_index", + "parse.usage.add_constraint", ],}; pub static RENAME: CommandNode = CommandNode { @@ -825,9 +897,10 @@ const COL_NAME: Node = Node::Hinted { }; // ADR-0029 column-constraint suffix — `not null`, `unique`, -// `default `. (`check ()` joins in a later -// ADR-0029 step.) One shared fragment: `create table` uses it -// here; `add column` and `add constraint` reuse it later. +// `default `, `check ()`. One shared fragment: +// `create table` uses it here, `add column` reuses it as its +// type suffix, and `add constraint` reuses the individual +// `COLUMN_CONSTRAINT` Choice for its constraint slot. const NOT_NULL_NODES: &[Node] = &[ Node::Word(Word::keyword("not")), Node::Word(Word::keyword("null")), @@ -867,6 +940,53 @@ const COLUMN_CONSTRAINT_SUFFIX: Node = Node::Repeated { min: 0, }; +// ================================================================= +// add_constraint / drop_constraint — `add constraint +// to .` / `drop constraint from .` +// (ADR-0029 §2.2) +// ================================================================= + +// Payload-free keyword nodes for `drop constraint` — naming the +// kind is enough, since at most one constraint of each kind +// exists per column. `not null` / `unique` reuse the §2.1 +// keyword-only nodes; `default` / `check` need bare-keyword +// variants here (their §2.1 forms carry a literal / expression +// payload that `drop` does not take). +const DROP_DEFAULT_KEYWORD: Node = Node::Word(Word::keyword("default")); +const DROP_CHECK_KEYWORD: Node = Node::Word(Word::keyword("check")); +const DROP_CONSTRAINT_KIND_CHOICES: &[Node] = &[ + NOT_NULL_CONSTRAINT, + UNIQUE_CONSTRAINT, + DROP_DEFAULT_KEYWORD, + DROP_CHECK_KEYWORD, +]; +const DROP_CONSTRAINT_KIND: Node = Node::Choice(DROP_CONSTRAINT_KIND_CHOICES); + +// The dotted `
.` target — the same `Ident '.' +// Ident` shape `add 1:n relationship` uses for its endpoints. +// `writes_table: true` on the table ident (via `TABLE_NAME_ +// EXISTING`) narrows the `.` slot's completion +// candidates to that table's columns. +const CONSTRAINT_TARGET_NODES: &[Node] = + &[TABLE_NAME_EXISTING, Node::Punct('.'), COLUMN_NAME]; +const CONSTRAINT_TARGET: Node = Node::Seq(CONSTRAINT_TARGET_NODES); + +const ADD_CONSTRAINT_NODES: &[Node] = &[ + Node::Word(Word::keyword("constraint")), + COLUMN_CONSTRAINT, + Node::Word(Word::keyword("to")), + CONSTRAINT_TARGET, +]; +const ADD_CONSTRAINT: Node = Node::Seq(ADD_CONSTRAINT_NODES); + +const DROP_CONSTRAINT_NODES: &[Node] = &[ + Node::Word(Word::keyword("constraint")), + DROP_CONSTRAINT_KIND, + Node::Word(Word::keyword("from")), + CONSTRAINT_TARGET, +]; +const DROP_CONSTRAINT: Node = Node::Seq(DROP_CONSTRAINT_NODES); + const COL_SPEC_NODES: &[Node] = &[ COL_NAME, Node::Punct('('), @@ -1117,7 +1237,7 @@ pub static CREATE: CommandNode = CommandNode { #[cfg(test)] mod constraint_tests { - use super::Command; + use super::{Command, Constraint, ConstraintKind}; use crate::dsl::command::ColumnSpec; use crate::dsl::parser::parse_command; use crate::dsl::value::Value; @@ -1234,4 +1354,89 @@ mod constraint_tests { ); assert!(cols[0].check.is_some()); } + + // --- `add constraint` / `drop constraint` (ADR-0029 §2.2) --- + + #[test] + fn add_constraint_not_null_parses() { + match parse_command("add constraint not null to Users.email").expect("parse") { + Command::AddConstraint { + table, + column, + constraint, + } => { + assert_eq!(table, "Users"); + assert_eq!(column, "email"); + assert_eq!(constraint, Constraint::NotNull); + } + other => panic!("expected AddConstraint, got {other:?}"), + } + } + + #[test] + fn add_constraint_unique_parses() { + match parse_command("add constraint unique to Users.email").expect("parse") { + Command::AddConstraint { constraint, .. } => { + assert_eq!(constraint, Constraint::Unique); + } + other => panic!("expected AddConstraint, got {other:?}"), + } + } + + #[test] + fn add_constraint_default_parses() { + match parse_command("add constraint default 18 to Users.age").expect("parse") { + Command::AddConstraint { constraint, .. } => { + assert_eq!( + constraint, + Constraint::Default(Value::Number("18".to_string())) + ); + } + other => panic!("expected AddConstraint, got {other:?}"), + } + } + + #[test] + fn add_constraint_check_parses() { + match parse_command("add constraint check (age >= 0) to Users.age").expect("parse") + { + Command::AddConstraint { + column, constraint, .. + } => { + assert_eq!(column, "age"); + assert!(matches!(constraint, Constraint::Check(_))); + } + other => panic!("expected AddConstraint, got {other:?}"), + } + } + + #[test] + fn drop_constraint_not_null_parses() { + match parse_command("drop constraint not null from Users.email").expect("parse") { + Command::DropConstraint { + table, + column, + kind, + } => { + assert_eq!(table, "Users"); + assert_eq!(column, "email"); + assert_eq!(kind, ConstraintKind::NotNull); + } + other => panic!("expected DropConstraint, got {other:?}"), + } + } + + #[test] + fn drop_constraint_each_kind_parses() { + for (input, expected) in [ + ("drop constraint unique from T.c", ConstraintKind::Unique), + ("drop constraint default from T.c", ConstraintKind::Default), + ("drop constraint check from T.c", ConstraintKind::Check), + ] { + match parse_command(input).expect("parse") { + Command::DropConstraint { kind, .. } => assert_eq!(kind, expected), + other => panic!("expected DropConstraint for {input:?}, got {other:?}"), + } + } + } } diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index db4da62..05574f0 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -514,6 +514,14 @@ mod usage_key_tests { let cases = [ ("add column to T: c (int)", "parse.usage.add_column"), ("add index on T (c)", "parse.usage.add_index"), + ( + "add constraint unique to T.c", + "parse.usage.add_constraint", + ), + ( + "drop constraint check from T.c", + "parse.usage.drop_constraint", + ), ( "add 1:n relationship from A.x to B.y", "parse.usage.add_relationship", diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index d06b3fe..96011b4 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -207,12 +207,14 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ // code, not the catalog, because spacing is alignment- // sensitive in the multi-entry case. ("parse.usage.add_column", &[]), + ("parse.usage.add_constraint", &[]), ("parse.usage.add_index", &[]), ("parse.usage.add_relationship", &[]), ("parse.usage.change_column", &[]), ("parse.usage.create_table", &[]), ("parse.usage.delete", &[]), ("parse.usage.drop_column", &[]), + ("parse.usage.drop_constraint", &[]), ("parse.usage.drop_index", &[]), ("parse.usage.drop_relationship", &[]), ("parse.usage.drop_table", &[]), @@ -385,6 +387,19 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ &["count", "action", "child_table", "rel", "on_delete"], ), // ---- change-column dry-run diagnostics (per ADR-0017) ---- + // ---- add-constraint dry-run diagnostics (per ADR-0029 §5) ---- + ( + "db.diagnostic.add_check_summary", + &["table", "column", "total", "rule"], + ), + ( + "db.diagnostic.add_not_null_summary", + &["table", "column", "total"], + ), + ( + "db.diagnostic.add_unique_summary", + &["table", "column", "total"], + ), ("db.diagnostic.force_conversion_hint", &[]), ("db.diagnostic.header_becomes", &[]), ("db.diagnostic.header_from", &[]), diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index e111ec4..6c6d3f4 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -432,6 +432,7 @@ parse: drop_index: |- drop index drop index on
([, ...]) + drop_constraint: "drop constraint (not null | unique | default | check) from
." add_column: "add column [to] [table]
: ()" add_relationship: |- add 1:n relationship [as ] @@ -439,6 +440,11 @@ parse: [on delete ] [on update ] [--create-fk] add_index: "add index [as ] on
([, ...])" + add_constraint: |- + add constraint not null to
.+ add constraint unique to
.+ add constraint default to
.+ add constraint check () to
.rename_column: "rename column [in] [table]
: to " change_column: |- change column [in] [table]
: () @@ -727,6 +733,17 @@ db: # Follow-up suggestion appended to the lossy diagnostic # (only — incompatibles can't be force-overridden). force_conversion_hint: "if you want to execute this conversion in spite of the problems, re-run with `--force-conversion`." + # `add constraint ...` dry-run refusals (ADR-0029 §5). + # Surface when the column's existing rows would violate the + # constraint being added; the offending rows follow in a + # diagnostic table. There is no force override — the user + # fixes the data and retries. + add_not_null_summary: |- + Cannot add NOT NULL to `{table}.{column}`: {total} row(s) hold a null value. + add_unique_summary: |- + Cannot add UNIQUE to `{table}.{column}`: {total} value(s) appear in more than one row. + add_check_summary: |- + Cannot add this CHECK to `{table}.{column}`: {total} row(s) do not satisfy `{rule}`. # ---- DSL command success summaries (ADR-0019 §9 sweep) -------------- ok: diff --git a/src/friendly/translate.rs b/src/friendly/translate.rs index cc6c3bd..3679f7e 100644 --- a/src/friendly/translate.rs +++ b/src/friendly/translate.rs @@ -67,6 +67,8 @@ pub enum Operation { DropRelationship, AddIndex, DropIndex, + AddConstraint, + DropConstraint, Query, Rebuild, Replay, @@ -96,6 +98,8 @@ impl Operation { Self::DropRelationship => "drop relationship", Self::AddIndex => "add index", Self::DropIndex => "drop index", + Self::AddConstraint => "add constraint", + Self::DropConstraint => "drop constraint", Self::Query => "query", Self::Rebuild => "rebuild", Self::Replay => "replay", diff --git a/src/runtime.rs b/src/runtime.rs index dcc13f0..f954d18 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1789,6 +1789,22 @@ async fn execute_command_typed( .drop_index(selector, src) .await .map(|d| CommandOutcome::Schema(Some(d))), + Command::AddConstraint { + table, + column, + constraint, + } => database + .add_constraint(table, column, constraint, src) + .await + .map(|d| CommandOutcome::Schema(Some(d))), + Command::DropConstraint { + table, + column, + kind, + } => database + .drop_constraint(table, column, kind, src) + .await + .map(|d| CommandOutcome::Schema(Some(d))), Command::ShowTable { name } => database .describe_table(name, src) .await diff --git a/tests/typing_surface/mod.rs b/tests/typing_surface/mod.rs index 0c2cfc7..fd3ec7c 100644 --- a/tests/typing_surface/mod.rs +++ b/tests/typing_surface/mod.rs @@ -208,6 +208,8 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String { DropRelationship { .. } => "DropRelationship".into(), AddIndex { .. } => "AddIndex".into(), DropIndex { .. } => "DropIndex".into(), + AddConstraint { .. } => "AddConstraint".into(), + DropConstraint { .. } => "DropConstraint".into(), ShowTable { .. } => "ShowTable".into(), Insert { .. } => "Insert".into(), Update { .. } => "Update".into(), diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__add_relationship__after_add_offers_relationship_branch@after_add.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__add_relationship__after_add_offers_relationship_branch@after_add.snap index 798e76f..e38d356 100644 --- a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__add_relationship__after_add_offers_relationship_branch@after_add.snap +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__add_relationship__after_add_offers_relationship_branch@after_add.snap @@ -18,6 +18,10 @@ Assessment { text: "index", kind: Keyword, }, + Candidate { + text: "constraint", + kind: Keyword, + }, Candidate { text: "1:n", kind: Keyword, @@ -42,6 +46,10 @@ Assessment { text: "index", kind: Keyword, }, + Candidate { + text: "constraint", + kind: Keyword, + }, Candidate { text: "1:n", kind: Keyword, diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__after_add_offers_index_branch@after_add_index_branch.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__after_add_offers_index_branch@after_add_index_branch.snap index 0d3630c..39df4b8 100644 --- a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__after_add_offers_index_branch@after_add_index_branch.snap +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__after_add_offers_index_branch@after_add_index_branch.snap @@ -18,6 +18,10 @@ Assessment { text: "index", kind: Keyword, }, + Candidate { + text: "constraint", + kind: Keyword, + }, Candidate { text: "1:n", kind: Keyword, @@ -42,6 +46,10 @@ Assessment { text: "index", kind: Keyword, }, + Candidate { + text: "constraint", + kind: Keyword, + }, Candidate { text: "1:n", kind: Keyword, diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__after_drop_offers_index_branch@drop_index_branch.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__after_drop_offers_index_branch@drop_index_branch.snap index 9aeccf7..371d46e 100644 --- a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__after_drop_offers_index_branch@drop_index_branch.snap +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__after_drop_offers_index_branch@drop_index_branch.snap @@ -26,6 +26,10 @@ Assessment { text: "index", kind: Keyword, }, + Candidate { + text: "constraint", + kind: Keyword, + }, ], selected: None, }, @@ -54,6 +58,10 @@ Assessment { text: "index", kind: Keyword, }, + Candidate { + text: "constraint", + kind: Keyword, + }, ], }, ),