`create table … with pk` now parses the column-constraint suffix; combined with the commit-1 db layer, a constrained table works end to end. - A shared constraint-suffix grammar fragment — `not null`, `unique`, `default <literal>` — sits after each column's `(type)` group; `build_create_table` walks the matched path per column and folds the constraints into `ColumnSpec`. - §9 redundancy check: every `with pk` column is a primary-key column, so `not null` (any) and `unique` (single-column PK) are rejected with a friendly error (`parse.custom.constraint_redundant_on_pk`). - `project.yaml` round-trip: `ColumnSchema` gains `not_null` / `default`; the YAML reader/writer and `build_read_schema` carry them, so `rebuild` / `export` / `import` preserve constraints. - ADR-0029 §2.1's example corrected — `create table` columns are all PK columns, so its suffix is for `default` / `check`; `docs/simple-mode-limitations.md` records that non-PK columns at create time need advanced mode. CHECK is deferred to the next commit. 1184 tests pass (+7); clippy clean.
20 KiB
ADR-0029: Column constraints (NOT NULL / UNIQUE / CHECK / DEFAULT)
Status
Accepted
Context
The requirements checklist (C3) commits to the full schema-
constraint surface. Most of it has landed across earlier ADRs:
primary keys, single and compound, at create-table time;
foreign keys with ON DELETE / ON UPDATE actions
(ADR-0013); indexes (ADR-0025). What remains are the four
column-level constraints every relational schema relies on:
NOT NULL, UNIQUE, CHECK, and DEFAULT.
They are core pedagogy. A learner cannot model a real schema without saying "this column is required", "these values must be distinct", "this value must be in range", or "use this when none is given". Until now the playground can only teach the primary key — which bundles NOT NULL + UNIQUE invisibly — and the relational shape of foreign keys. The four standalone constraints are the missing half of "what a column is".
Three things make this the right moment, and shape the decision:
- The rebuild-table primitive exists (ADR-0013). SQLite's
ALTER TABLEcannot addNOT NULL/UNIQUE/CHECKto an existing column; the rebuild dance is the accepted recipe, and it is already load-bearing for change-column- type and every relationship operation. - The per-cell dry-run model exists (ADR-0017). Applying a constraint to a populated column is the same shape of problem as a type change: scan the existing data, classify it, and refuse with a learner-friendly table of offending rows before any SQL writes. ADR-0029 reuses that ethos.
- The WHERE-expression grammar exists (ADR-0026). A
CHECKconstraint is a boolean expression over the column. Rather than invent a second expression language,CHECKreuses the ADR-0026Exprgrammar through theSubgrammarnode — the same expression language the learner already meets inwherefilters.
PRIMARY KEY is not revisited here. It keeps its dedicated
with pk … clause: it is table-level (compound keys span
columns), it is declared once, and folding it into the
per-column constraint suffix would muddy both surfaces. A PK
column is implicitly NOT NULL and UNIQUE; this ADR makes
that explicit in §9 by rejecting redundant declarations.
Decision
1. The constraint vocabulary
Four column-level constraints, each appearing at most once per column in v1:
| Constraint | Payload | Meaning |
|---|---|---|
not null |
— | the column rejects NULL |
unique |
— | all non-NULL values must be distinct |
default |
a value literal | value used when an insert omits the column |
check |
a parenthesised Expr |
every row must satisfy the boolean expression |
The payload literal for default is an ordinary value literal
per the ADR-0014 value grammar. The check expression is an
ADR-0026 Expr, reached through Subgrammar — see §2.
unique follows SQLite's standard semantics: multiple NULLs
do not collide (SQL's "NULLs are distinct" rule). This is
stated explicitly because it is a common learner surprise and
the friendly-error and dry-run text both depend on it.
2. Grammar — declaring constraints
2.1 The constraint suffix (create table, add column)
A column spec gains an optional, repeatable constraint suffix
after the (type) group:
create table Books with pk isbn(text) check (length(isbn) = 13)
add column to Books: title (text) not null
add column to Books: stock (int) default 0 check (stock >= 0)
Where each constraint is useful. The simple-mode create table … with pk … declares only primary-key columns —
every column in the with pk list is part of the primary key
(non-PK columns are added afterward with add column; see
docs/simple-mode-limitations.md). Since a PK column is
already NOT NULL and UNIQUE, §9 rejects those two as
redundant there — so on a create table column the suffix is
useful for default / check. not null / unique come
into their own on add column (non-PK columns) and on add constraint. The suffix grammar is nonetheless shared
verbatim across all three surfaces; §9 does the rejecting.
- Standard SQL writes constraints after the data type
(
email TEXT NOT NULL UNIQUE). The playground brackets the type asemail(text)— a pre-existing convention this ADR does not relitigate — so the faithful adaptation is the same keyword order after the bracketed type. - Constraint keywords are case-insensitive (ADR-0009) and may
appear in any order. Each kind at most once; a repeat
(
not null not null) is a friendly error naming the duplicated constraint. default <literal>takes one value literal.check ( <expr> )parenthesises its expression — matching SQL'sCHECK (…), and giving the parser an unambiguous end for the expression before the next constraint or the column-separating comma. The expression is the ADR-0026Exprgrammar viaNode::Subgrammar(&expr::OR_EXPR).- The column-separating comma of the
with pklist follows the last constraint of a column, exactly as today.
The constraint suffix is one shared grammar fragment, used by
both create table's column list and add column.
2.2 Constraints on an existing column (add constraint … / drop constraint …)
Modifying the constraints of an already-created column is a
first-class v1 surface. It reuses the established add / drop
verb families and the dotted <Table>.<column> reference
(ADR-0013's add 1:n relationship from <P>.<col>):
add constraint not null to Users.email
add constraint unique to Users.email
add constraint default 18 to Users.age
add constraint check (age >= 0) to Users.age
drop constraint not null from Users.email
drop constraint unique from Users.email
drop constraint default from Users.age
drop constraint check from Users.age
add constraint <constraint> to <T>.<col>anddrop constraint <constraint-kind> from <T>.<col>join the existingadd/dropcommandChoices as new forms. The form wordconstraintdiscriminates them — exactly ascolumn/index/relationshipdiscriminate the otheradd/dropforms. Everyadd/dropstays uniformly<verb> <noun> …, which keeps the grammar hierarchy clean.- After
add constraint, the<constraint>is exactly the §2.1 suffix vocabulary:not null,unique,default <literal>,check ( <expr> ). The constraint grammar fragment is shared a third time. - After
drop constraint, only the kind is named —not null,unique,default,check— since at most one of each exists per column.
3. AST
ColumnSpec (src/dsl/command.rs) gains the four constraint
slots — each kind is at-most-one, so they are fields, not a
Vec:
pub struct ColumnSpec {
pub name: String,
pub ty: Type,
pub not_null: bool,
pub unique: bool,
pub default: Option<Value>,
pub check: Option<Expr>,
}
Command::AddColumn carries the same four slots alongside its
table / column / ty.
Two new commands cover §2.2:
Command::AddConstraint { table: String, column: String, constraint: Constraint }
Command::DropConstraint { table: String, column: String, kind: ConstraintKind }
pub enum Constraint { NotNull, Unique, Default(Value), Check(Expr) }
pub enum ConstraintKind { NotNull, Unique, Default, Check }
Constraint carries the payload (Default / Check);
ConstraintKind is payload-free, for drop.
4. SQLite mapping and enforcement
Every constraint maps directly to native SQLite column-DDL — nothing is enforced application-side that SQLite can enforce itself, consistent with the STRICT-tables posture (ADR-0002):
"email" TEXT NOT NULL UNIQUE
"age" INTEGER DEFAULT 18 CHECK ("age" >= 0)
not null→NOT NULL;unique→UNIQUE;default v→DEFAULT <literal>.check→CHECK (<sql-expr>). TheExpris compiled to SQL the same way WHERE expressions are (compile_expr), except DDL admits no?parameters, so literals are inlined — the inlining helper from ADR-0028 §3 (inline_params_for_display) already does exactly this. Identifiers are double-quoted; the emitted SQL names no engine product, so the ADR-0002 rule holds.
Constraint DDL flows through the existing column-DDL
assembly (schema_to_ddl), so the rebuild-table primitive and
every ALTER path pick it up automatically once the constraint
data reaches ReadColumn.
5. Applying a constraint to a populated column — the dry-run
add constraint not null / unique / check against a
column that already holds data can be violated by that
data. Per
the ADR-0017 ethos, the violation is caught by a dry-run
pass that runs before any SQL writes, and reported as a
learner-friendly table — never as a raw engine error.
| Command | Pre-flight check |
|---|---|
add constraint not null |
SELECT … WHERE "col" IS NULL — any row is a violation |
add constraint unique |
non-NULL values grouped; any group of size > 1 collides |
add constraint check (expr) |
SELECT … WHERE NOT (expr) — any row is a violation |
add constraint default |
none — DEFAULT never touches existing rows |
drop constraint <any> |
none — removing a constraint cannot violate data |
On a violation the command is refused and the offending
rows are rendered through the pretty-table renderer
(ADR-0016 / ADR-0017 §7) — rows identified by primary-key
value(s), capped at 100 with a trailing … and N more row.
For add constraint unique, colliding rows are grouped by
their shared value, mirroring ADR-0017 §7's
uniqueness-collision table.
When the dry-run passes, the constraint is applied through the
rebuild-table primitive (ADR-0013) — SQLite cannot ALTER a
constraint onto an existing column. An empty table passes the
dry-run trivially.
There is no --force override: a constraint that the data
violates is not something the learner should be able to wave
through. They fix the data (with update / delete) and
retry. This is stricter than ADR-0017's --force-conversion
because a constraint is a rule, not a lossy transform — a
forced-through violation would leave the schema lying about
the data.
6. DEFAULT semantics
- A
defaultlets aninsertomit the column in the explicit-column form (insert into T (a, b) values …); the omitted column takes its default. The natural-order short form (insert into T values (…)) still requires every non-auto-generated column positionally — there is no positional "skip", exactly as in SQL. add constraint defaultaffects only future inserts; existing rows are untouched (SQL-standard behaviour, and why §5 needs no dry-run for it).defaultis rejected on aserialorshortidcolumn: those carry their own auto-fill contract (ADR-0018), and a second source of "the value when none is given" would be ambiguous. The error says so.add column … not nullon a non-empty table requires adefaultin the same spec — the new column isNULLin every existing row, whichNOT NULLforbids. Without a default the command is refused with a message that explains the rule and points atdefault. On an empty table it is fine. (This is SQLite's ownADD COLUMNrule, surfaced as a friendly error rather than a raw one.)add column … unique default <v>on a table with more than one row is refused: every existing row would receive the same default value, colliding immediately. The error explains the collision.
7. Storage and round-trip
playground.db is a derived artifact (ADR-0004 / ADR-0015);
constraints must round-trip through project.yaml or they
vanish on rebuild / export / import.
project.yaml— theColumnSchemarecord gainsnot_null: bool,default: Option<Value>, andcheck: Option<String>. (unique: boolalready exists, from ADR-0018'sserial/shortidcontract.) Thecheckexpression is stored as its canonical DSL text — see theExprtext renderer below.- Metadata table —
NOT NULL,UNIQUE, andDEFAULTare all recoverable from SQLite itself (pragma_table_info'snotnullanddflt_value;pragma_index_listoriginu), so they need no metadata row.CHECKis not exposed by any pragma — only by the rawsqlite_masterSQL, which is in engine syntax. So__rdbms_playground_columnscarries a nullablecheck_expr TEXTcolumn holding the canonical DSL text, keepingdescribeindependent of engine-syntax parsing. It is part of the internal table'sCREATE TABLEdefinition — there are no existing databases to migrate. Expr→ DSL text renderer — a newrender_expr(small; theExprtree from ADR-0026 is shallow) produces canonical DSL text for an expression. One renderer, three consumers:project.yamlserialization, thecheck_exprmetadata column, and the structure view (§8). The check round-trips text →Expr(re-parsed on load) → text.
8. Structure rendering
describe_table already reports primary_key and notnull;
ColumnDescription gains unique: bool, default: Option<String>, and check: Option<String>.
constraints_display (output_render.rs) extends to list
every constraint a column carries:
┌───────┬──────┬───────────────────────────────────┐
│ Name │ Type │ Constraints │
├───────┼──────┼───────────────────────────────────┤
│ id │ serial │ PK │
│ email │ text │ NOT NULL, UNIQUE │
│ age │ int │ DEFAULT 18, CHECK age >= 0 │
└───────┴──────┴───────────────────────────────────┘
The CHECK expression renders in DSL form (render_expr),
not engine SQL.
9. PK columns — redundant and impossible constraints
A primary-key column is always implicitly NOT NULL. A
single-column primary key is additionally UNIQUE; the
columns of a compound primary key are not individually
unique — only the combination is.
This makes the rules precise:
- Declaring
not nullon any PK column — in the suffix or viaadd constraint … to— is a friendly error: the PK already implies it. The message says so, rather than silently ignoring it, so the learner learns why. - Declaring
uniqueon a single-column-PK column is likewise a friendly error. Declaringuniqueon a column of a compound PK is allowed and meaningful — it adds an individual-uniqueness rule the compound PK does not provide. drop constraint not nullfrom any PK column, anddrop constraint uniquefrom a single-column-PK column, are friendly errors: the primary key still enforces them, so there is nothing to drop.defaultandcheckon a PK column are always allowed — the PK implies neither.
Friendly errors here are deliberate teaching moments, not silent no-ops: the playground favours clarity over permissiveness even where SQLite itself would tolerate the redundant declaration.
10. Friendly errors
The friendly-error layer (ADR-0019) already enriches NOT NULL and UNIQUE violations from insert / update. Two
additions:
CHECKviolation — SQLite reportsCHECK constraint failed; a new catalog entry renders it as "the value for<col>breaks the rule<check-expr>", with the offending value and the DSL-form expression.- The §5 / §6 refusals — populated-column dry-run
failures, the
not null/unique/serial+defaultrejections — are all friendly catalog strings, with the dry-run tables rendered per ADR-0016.
11. Out of scope
- OOS-1. Table-level
CHECKconstraints spanning multiple columns. v1'scheckis per-column; its expression typically references the column it sits on. A table-levelcheck (…)clause is a clean follow-up. - OOS-2. More than one
CHECKper column. v1 caps at one; SQL allows several. - OOS-3. Non-literal
DEFAULTs —CURRENT_TIMESTAMP, expressions. v1 takes a value literal only. - OOS-4. Named constraints (
CONSTRAINT <name> CHECK …). The playground's constraints are anonymous; only indexes and relationships carry user names. - OOS-5. Inline
REFERENCESforeign keys in the column suffix. Relationships keep their dedicatedadd 1:n relationshipcommand (ADR-0013). - OOS-6.
--force-style overrides for §5 — deliberately omitted (see §5).
Consequences
ColumnSpecandCommand::AddColumngrow four constraint slots; two new commands (AddConstraint/DropConstraint) and two new AST enums (Constraint/ConstraintKind) land. Every exhaustivematch Commandgains arms.- A shared constraint-suffix grammar fragment is used in three
places: the
create tablecolumn list,add column, and theadd constraint … tocommand.CHECKreuses the ADR-0026Exprgrammar throughSubgrammar. - The rebuild-table primitive (ADR-0013) becomes the path for
add constraint …/drop constraint …on populated columns, joining its existing change-column-type and relationship users. - A pre-flight dry-run (§5), in the ADR-0017 style, guards every populated-column constraint addition; its violation tables go through the ADR-0016 pretty-table renderer.
- The internal metadata table
__rdbms_playground_columnscarries a newcheck_exprcolumn — its first change since ADR-0012. - A new
Expr→ DSL-text renderer (render_expr) is added; it is also reusable by any future feature that needs to show an expression back to the user. project.yaml'sColumnSchemagrows three fields; the format stays backward-compatible (the new keys default to "absent" —not_null: false, nodefault, nocheck).- The friendly-error layer gains a
CHECK-violation entry.
Implementation notes
A sensible build order, each step test-guarded:
- AST + the constraint grammar fragment.
ColumnSpec/Commandchanges; the shared constraint-suffix grammar;create tableandadd columnparse constraints into the AST. No execution yet — but every exhaustivematchcompiles. (Combine with step 2 if thematchbreakage makes a green commit impossible, exactly as ADR-0028 did.) - Execution at create / add time.
schema_to_ddlemits the constraint DDL;do_create_table/do_add_columnhonour it, including the §6add columnrules. TheExpr→ SQL compile forCHECK. - Storage round-trip.
ColumnSchemafields, thecheck_exprmetadata column,render_expr, and theproject.yamlread/write paths. add constraint …/drop constraint …. The two commands, the rebuild-table path, and the §5 dry-run with its pretty-table refusals.- Rendering + friendly errors.
constraints_display, theCHECK-violation catalog entry, the structure view. - Typing-surface matrix cells for the new grammar.
See also
- ADR-0002 — STRICT tables; "no engine name in user-facing
strings" (the compiled
CHECKSQL names no product). - ADR-0009 — DSL syntax conventions; keyword-led clauses.
- ADR-0012 — internal column metadata;
__rdbms_playground_columns. - ADR-0013 — the rebuild-table primitive, reused by §5; the
dotted
<Table>.<column>reference reused by §2.2. - ADR-0014 — the value-literal grammar, reused by
default. - ADR-0016 — pretty-table rendering for the §5 dry-run tables.
- ADR-0017 — the per-cell dry-run ethos §5 follows.
- ADR-0018 —
serial/shortidauto-fill, whydefaultis rejected on those columns; the existinguniquefield. - ADR-0026 — the
ExprgrammarCHECKreuses viaSubgrammar. - ADR-0028 — the literal-inlining helper reused to compile
CHECKexpressions into parameter-free DDL.