db: column-constraint infrastructure — NOT NULL / UNIQUE / DEFAULT (ADR-0029)

The database layer now honours the ColumnSpec constraint
fields end to end, ahead of the grammar that lets users type
them.

- `do_create_table` emits ` NOT NULL` / ` UNIQUE` / ` DEFAULT
  <literal>` per column via the new `column_constraints_sql`
  helper (the default literal bound against the column's type).
- `ReadColumn` gains `default_sql`, read from
  `pragma_table_info.dflt_value`; `schema_to_ddl` emits it, so
  the rebuild-table primitive preserves DEFAULT — it already
  preserved NOT NULL / UNIQUE.
- `ColumnDescription` gains `unique` / `default`;
  `do_describe_table` now sources columns from `read_schema`
  (one source of per-column truth) and `constraints_display`
  lists PK / NOT NULL / UNIQUE / DEFAULT.

No user-facing change yet — no grammar produces constrained
columns. Tests exercise creation, enforcement, describe, and
rebuild-preservation programmatically.

1177 tests pass (+5); clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-19 14:18:45 +00:00
parent eff2ee8d14
commit a60e879f20
5 changed files with 281 additions and 45 deletions
+2
View File
@@ -2173,6 +2173,8 @@ mod tests {
sqlite_type: "INTEGER".to_string(), sqlite_type: "INTEGER".to_string(),
notnull: false, notnull: false,
primary_key: true, primary_key: true,
unique: false,
default: None,
}], }],
outbound_relationships: Vec::new(), outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(), inbound_relationships: Vec::new(),
+254 -42
View File
@@ -28,7 +28,7 @@ use std::thread;
use rusqlite::Connection; use rusqlite::Connection;
use tokio::sync::{mpsc, oneshot}; use tokio::sync::{mpsc, oneshot};
use tracing::{debug, info, warn}; use tracing::{debug, info};
use crate::dsl::action::ReferentialAction; use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{ use crate::dsl::command::{
@@ -129,6 +129,13 @@ pub struct ColumnDescription {
pub sqlite_type: String, pub sqlite_type: String,
pub notnull: bool, pub notnull: bool,
pub primary_key: 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<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -1769,6 +1776,32 @@ fn quote_ident(name: &str) -> String {
out out
} }
/// Render a column's constraint-DDL suffix (ADR-0029) — the
/// ` NOT NULL` / ` UNIQUE` / ` DEFAULT <literal>` 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<String, DbError> {
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( fn do_create_table(
conn: &Connection, conn: &Connection,
persistence: Option<&Persistence>, persistence: Option<&Persistence>,
@@ -1806,6 +1839,11 @@ fn do_create_table(
if single_inline_pk { if single_inline_pk {
clause.push_str(" PRIMARY KEY"); 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); column_clauses.push(clause);
} }
@@ -2014,6 +2052,7 @@ fn do_add_auto_generated_column(
notnull: true, notnull: true,
primary_key: false, primary_key: false,
unique: true, unique: true,
default_sql: None,
user_type: Some(ty), user_type: Some(ty),
}); });
@@ -3243,6 +3282,11 @@ struct ReadColumn {
/// avoids double-emitting. Compound UNIQUE is out of scope /// avoids double-emitting. Compound UNIQUE is out of scope
/// for v1 (ADR-0018 OOS-6 / future C3 work). /// for v1 (ADR-0018 OOS-6 / future C3 work).
unique: bool, 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<String>,
user_type: Option<Type>, user_type: Option<Type>,
} }
@@ -3259,7 +3303,7 @@ fn read_schema(conn: &Connection, table: &str) -> Result<ReadSchema, DbError> {
// Columns + PK from pragma_table_info, joined with our user-type metadata. // Columns + PK from pragma_table_info, joined with our user-type metadata.
let mut col_stmt = conn let mut col_stmt = conn
.prepare(&format!( .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 \ FROM pragma_table_info(?1) AS pti \
LEFT JOIN {META_TABLE} AS m \ LEFT JOIN {META_TABLE} AS m \
ON m.table_name = ?1 AND m.column_name = pti.name \ ON m.table_name = ?1 AND m.column_name = pti.name \
@@ -3276,6 +3320,7 @@ fn read_schema(conn: &Connection, table: &str) -> Result<ReadSchema, DbError> {
notnull: row.get::<_, i64>(2)? != 0, notnull: row.get::<_, i64>(2)? != 0,
primary_key: row.get::<_, i64>(3)? != 0, primary_key: row.get::<_, i64>(3)? != 0,
unique: false, // filled in below from pragma_index_list unique: false, // filled in below from pragma_index_list
default_sql: row.get(5)?,
user_type, user_type,
}) })
}) })
@@ -3492,6 +3537,12 @@ fn schema_to_ddl(table: &str, schema: &ReadSchema) -> String {
if col.unique && !col.primary_key { if col.unique && !col.primary_key {
clause.push_str(" UNIQUE"); 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); clauses.push(clause);
} }
@@ -3742,6 +3793,7 @@ fn do_add_relationship(
notnull: false, notnull: false,
primary_key: false, primary_key: false,
unique: false, unique: false,
default_sql: None,
user_type: Some(expected_child_type), user_type: Some(expected_child_type),
}); });
} else { } else {
@@ -4099,47 +4151,25 @@ fn do_describe_table_request(
} }
fn do_describe_table(conn: &Connection, name: &str) -> Result<TableDescription, DbError> { fn do_describe_table(conn: &Connection, name: &str) -> Result<TableDescription, DbError> {
// `pragma_table_info` is a table-valued function in modern // Column info — including the ADR-0029 constraints — comes
// SQLite; using it as a SELECT lets us bind the table name // from `read_schema`, the single source of per-column truth
// via ? rather than splicing it into a PRAGMA statement. // (it joins `pragma_table_info` with our type metadata and
// We LEFT JOIN our metadata table to recover the user-facing // detects single-column UNIQUE via `pragma_index_list`). A
// type each column was declared as. // missing table surfaces as `read_schema`'s NoSuchTable.
let mut stmt = conn let schema = read_schema(conn, name)?;
.prepare(&format!( let columns: Vec<ColumnDescription> = schema
"SELECT pti.name, pti.type, pti.\"notnull\", pti.pk, m.user_type \ .columns
FROM pragma_table_info(?1) AS pti \ .iter()
LEFT JOIN {META_TABLE} AS m \ .map(|c| ColumnDescription {
ON m.table_name = ?1 AND m.column_name = pti.name \ name: c.name.clone(),
ORDER BY pti.cid;" user_type: c.user_type,
)) sqlite_type: c.sqlite_type.clone(),
.map_err(DbError::from_rusqlite)?; notnull: c.notnull,
let rows = stmt primary_key: c.primary_key,
.query_map([name], |row| { unique: c.unique,
let user_type_kw: Option<String> = row.get(4)?; default: c.default_sql.clone(),
let user_type = user_type_kw.and_then(|kw| kw.parse::<Type>().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,
})
}) })
.map_err(DbError::from_rusqlite)?; .collect();
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,
});
}
let outbound_relationships = read_relationships_outbound(conn, name)?; let outbound_relationships = read_relationships_outbound(conn, name)?;
let inbound_relationships = read_relationships_inbound(conn, name)?; let inbound_relationships = read_relationships_inbound(conn, name)?;
@@ -5334,6 +5364,7 @@ fn build_read_schema(table: &TableSchema, relationships: &[RelationshipSchema])
notnull: false, notnull: false,
primary_key: table.primary_key.contains(&c.name), primary_key: table.primary_key.contains(&c.name),
unique: c.unique, unique: c.unique,
default_sql: None,
user_type: Some(c.user_type), user_type: Some(c.user_type),
}) })
.collect(); .collect();
@@ -8243,6 +8274,185 @@ mod tests {
assert!(result.is_err(), "explaining a missing table should fail"); 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<Value>,
) -> ColumnSpec {
ColumnSpec {
name: name.to_string(),
ty,
not_null,
unique,
default,
check: None,
}
}
/// Create `T(id serial pk, <extra>)`.
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] #[tokio::test]
async fn update_with_all_rows_affects_everything() { async fn update_with_all_rows_affects_everything() {
let db = db(); let db = db();
@@ -8513,6 +8723,7 @@ mod tests {
notnull: false, notnull: false,
primary_key: true, primary_key: true,
unique: false, unique: false,
default_sql: None,
user_type: Some(Type::Serial), user_type: Some(Type::Serial),
}, },
ReadColumn { ReadColumn {
@@ -8521,6 +8732,7 @@ mod tests {
notnull: false, notnull: false,
primary_key: false, primary_key: false,
unique: true, unique: true,
default_sql: None,
user_type: Some(Type::Text), user_type: Some(Type::Text),
}, },
], ],
+15 -3
View File
@@ -354,12 +354,20 @@ fn type_display(c: &ColumnDescription) -> String {
} }
fn constraints_display(c: &ColumnDescription) -> String { fn constraints_display(c: &ColumnDescription) -> String {
let mut parts: Vec<&str> = Vec::new(); let mut parts: Vec<String> = Vec::new();
if c.primary_key { if c.primary_key {
parts.push("PK"); parts.push("PK".to_string());
} }
if c.notnull { 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(", ") parts.join(", ")
} }
@@ -511,6 +519,8 @@ mod tests {
}, },
notnull, notnull,
primary_key: pk, primary_key: pk,
unique: false,
default: None,
} }
} }
@@ -978,6 +988,8 @@ mod tests {
sqlite_type: "INTEGER".to_string(), sqlite_type: "INTEGER".to_string(),
notnull: false, notnull: false,
primary_key: false, primary_key: false,
unique: false,
default: None,
}], }],
outbound_relationships: Vec::new(), outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(), inbound_relationships: Vec::new(),
+4
View File
@@ -1115,6 +1115,8 @@ mod tests {
sqlite_type: "INTEGER".to_string(), sqlite_type: "INTEGER".to_string(),
notnull: false, notnull: false,
primary_key: true, primary_key: true,
unique: false,
default: None,
}, },
ColumnDescription { ColumnDescription {
name: "Name".to_string(), name: "Name".to_string(),
@@ -1122,6 +1124,8 @@ mod tests {
sqlite_type: "TEXT".to_string(), sqlite_type: "TEXT".to_string(),
notnull: false, notnull: false,
primary_key: false, primary_key: false,
unique: false,
default: None,
}, },
], ],
outbound_relationships: Vec::new(), outbound_relationships: Vec::new(),
+6
View File
@@ -250,6 +250,8 @@ fn fake_table(name: &str, columns: &[(&str, Type, bool)]) -> TableDescription {
sqlite_type: t.sqlite_strict_type().to_string(), sqlite_type: t.sqlite_strict_type().to_string(),
notnull: false, notnull: false,
primary_key: *pk, primary_key: *pk,
unique: false,
default: None,
}) })
.collect(), .collect(),
outbound_relationships: Vec::new(), outbound_relationships: Vec::new(),
@@ -415,6 +417,8 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() {
sqlite_type: "INTEGER".to_string(), sqlite_type: "INTEGER".to_string(),
notnull: false, notnull: false,
primary_key: true, primary_key: true,
unique: false,
default: None,
}], }],
outbound_relationships: Vec::new(), outbound_relationships: Vec::new(),
inbound_relationships: vec![RelationshipEnd { inbound_relationships: vec![RelationshipEnd {
@@ -464,6 +468,8 @@ fn add_relationship_flow_shows_inbound_section_on_parent() {
sqlite_type: "INTEGER".to_string(), sqlite_type: "INTEGER".to_string(),
notnull: false, notnull: false,
primary_key: true, primary_key: true,
unique: false,
default: None,
}], }],
outbound_relationships: Vec::new(), outbound_relationships: Vec::new(),
inbound_relationships: vec![RelationshipEnd { inbound_relationships: vec![RelationshipEnd {