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:
@@ -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(),
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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 <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(
|
||||
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<String>,
|
||||
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.
|
||||
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<ReadSchema, DbError> {
|
||||
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<TableDescription, DbError> {
|
||||
// `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<String> = row.get(4)?;
|
||||
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,
|
||||
// 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<ColumnDescription> = 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<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]
|
||||
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),
|
||||
},
|
||||
],
|
||||
|
||||
+15
-3
@@ -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<String> = 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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user