docs: ADR-0029 — column constraints (NOT NULL / UNIQUE / CHECK / DEFAULT)

Designs the remaining C3 surface: the four column-level
constraints declared in the column-spec suffix at `create
table` / `add column`, and modified on existing columns via
`add constraint … to` / `drop constraint … from`.

- A pre-flight dry-run (the ADR-0017 ethos) scans a populated
  column before applying NOT NULL / UNIQUE / CHECK and refuses
  with a pretty-table of offending rows; no `--force`.
- CHECK reuses the ADR-0026 expression grammar via Subgrammar.
- `__rdbms_playground_columns` carries a new `check_expr`
  column; the other three are recoverable from SQLite pragmas.
- README index updated.
This commit is contained in:
claude@clouddev1
2026-05-19 13:36:50 +00:00
parent 59351e0567
commit 7bfd213ab3
2 changed files with 451 additions and 0 deletions
+450
View File
@@ -0,0 +1,450 @@
# 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:
1. **The rebuild-table primitive exists** (ADR-0013). SQLite's
`ALTER TABLE` cannot add `NOT NULL` / `UNIQUE` / `CHECK` to
an existing column; the rebuild dance is the accepted
recipe, and it is already load-bearing for change-column-
type and every relationship operation.
2. **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.
3. **The WHERE-expression grammar exists** (ADR-0026). A
`CHECK` constraint is a boolean expression over the
column. Rather than invent a second expression language,
`CHECK` *reuses* the ADR-0026 `Expr` grammar through the
`Subgrammar` node — the same expression language the
learner already meets in `where` filters.
`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 `NULL`s
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 Users with pk
id(serial),
email(text) not null unique,
age(int) default 18 check (age >= 0)
add column to Orders: note (text) default ''
```
- Standard SQL writes constraints after the data type
(`email TEXT NOT NULL UNIQUE`). The playground brackets the
type as `email(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's `CHECK (…)`, and giving the parser an unambiguous end
for the expression before the next constraint or the
column-separating comma. The expression is the ADR-0026
`Expr` grammar via `Node::Subgrammar(&expr::OR_EXPR)`.
- The column-separating comma of the `with pk` list 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>` and `drop
constraint <constraint-kind> from <T>.<col>` join the
existing `add` / `drop` command `Choice`s as new forms. The
form word `constraint` discriminates them — exactly as
`column` / `index` / `relationship` discriminate the other
`add` / `drop` forms. Every `add` / `drop` stays 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`:
```rust
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:
```rust
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):
```sql
"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>)`. The `Expr` is 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 `default` lets an `insert` **omit** 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 default` affects only *future* inserts;
existing rows are untouched (SQL-standard behaviour, and why
§5 needs no dry-run for it).
- `default` is rejected on a `serial` or `shortid` column:
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 null` on a **non-empty** table requires a
`default` in the same spec — the new column is `NULL` in
every existing row, which `NOT NULL` forbids. Without a
default the command is refused with a message that explains
the rule and points at `default`. On an empty table it is
fine. (This is SQLite's own `ADD COLUMN` rule, 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`** — the `ColumnSchema` record gains
`not_null: bool`, `default: Option<Value>`, and
`check: Option<String>`. (`unique: bool` already exists,
from ADR-0018's `serial` / `shortid` contract.) The `check`
expression is stored as its **canonical DSL text** — see the
`Expr` text renderer below.
- **Metadata table** — `NOT NULL`, `UNIQUE`, and `DEFAULT` are
all recoverable from SQLite itself (`pragma_table_info`'s
`notnull` and `dflt_value`; `pragma_index_list` origin `u`),
so they need no metadata row. `CHECK` is *not* exposed by
any pragma — only by the raw `sqlite_master` SQL, which is
in engine syntax. So `__rdbms_playground_columns` carries a
nullable `check_expr TEXT` column holding the canonical DSL
text, keeping `describe` independent of engine-syntax
parsing. It is part of the internal table's `CREATE TABLE`
definition — there are no existing databases to migrate.
- **`Expr` → DSL text renderer** — a new `render_expr` (small;
the `Expr` tree from ADR-0026 is shallow) produces canonical
DSL text for an expression. One renderer, three consumers:
`project.yaml` serialization, the `check_expr` metadata
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 null` on any PK column — in the suffix or via
`add 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 `unique` on a single-column-PK column is likewise
a friendly error. Declaring `unique` on 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 null` from any PK column, and `drop
constraint unique` from a single-column-PK column, are
friendly errors: the primary key still enforces them, so
there is nothing to drop.
- `default` and `check` on 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:
- **`CHECK` violation** — SQLite reports `CHECK 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+default`
rejections — are all friendly catalog strings, with the
dry-run tables rendered per ADR-0016.
### 11. Out of scope
- **OOS-1.** Table-level `CHECK` constraints spanning multiple
columns. v1's `check` is per-column; its expression
typically references the column it sits on. A table-level
`check (…)` clause is a clean follow-up.
- **OOS-2.** More than one `CHECK` per column. v1 caps at one;
SQL allows several.
- **OOS-3.** Non-literal `DEFAULT`s — `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 `REFERENCES` foreign keys in the column
suffix. Relationships keep their dedicated `add 1:n
relationship` command (ADR-0013).
- **OOS-6.** `--force`-style overrides for §5 — deliberately
omitted (see §5).
## Consequences
- `ColumnSpec` and `Command::AddColumn` grow four constraint
slots; two new commands (`AddConstraint` / `DropConstraint`)
and two new AST enums (`Constraint` / `ConstraintKind`)
land. Every exhaustive `match Command` gains arms.
- A shared constraint-suffix grammar fragment is used in three
places: the `create table` column list, `add column`, and
the `add constraint … to` command. `CHECK` reuses the
ADR-0026 `Expr` grammar through `Subgrammar`.
- 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_columns`
carries a new `check_expr` column — 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`'s `ColumnSchema` grows three fields; the
format stays backward-compatible (the new keys default to
"absent" — `not_null: false`, no `default`, no `check`).
- The friendly-error layer gains a `CHECK`-violation entry.
## Implementation notes
A sensible build order, each step test-guarded:
1. **AST + the constraint grammar fragment.** `ColumnSpec` /
`Command` changes; the shared constraint-suffix grammar;
`create table` and `add column` parse constraints into the
AST. No execution yet — but every exhaustive `match`
compiles. (Combine with step 2 if the `match` breakage
makes a green commit impossible, exactly as ADR-0028 did.)
2. **Execution at create / add time.** `schema_to_ddl` emits
the constraint DDL; `do_create_table` / `do_add_column`
honour it, including the §6 `add column` rules. The
`Expr` → SQL compile for `CHECK`.
3. **Storage round-trip.** `ColumnSchema` fields, the
`check_expr` metadata column, `render_expr`, and the
`project.yaml` read/write paths.
4. **`add constraint …` / `drop constraint …`.** The two
commands, the rebuild-table path, and the §5 dry-run with
its pretty-table refusals.
5. **Rendering + friendly errors.** `constraints_display`,
the `CHECK`-violation catalog entry, the structure view.
6. **Typing-surface matrix cells** for the new grammar.
## See also
- ADR-0002 — STRICT tables; "no engine name in user-facing
strings" (the compiled `CHECK` SQL 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` / `shortid` auto-fill, why `default` is
rejected on those columns; the existing `unique` field.
- ADR-0026 — the `Expr` grammar `CHECK` reuses via `Subgrammar`.
- ADR-0028 — the literal-inlining helper reused to compile
`CHECK` expressions into parameter-free DDL.
+1
View File
@@ -34,3 +34,4 @@ This directory contains the project's ADRs, recorded per
- [ADR-0026 — Complex WHERE expressions](0026-complex-where-expressions.md) — **Accepted**, stratified recursive expression grammar (`AND`/`OR`/`NOT`, comparisons, `LIKE`, `IS NULL`, `IN`, `BETWEEN`) for `update` / `delete` / `show data` filters; `show data` gains `where` + `limit`; adds the `Subgrammar` node and a recursive `Expr` AST (`C5a`)
- [ADR-0027 — Input-field validity indicator](0027-input-validity-indicator.md) — **Accepted**, a debounced `[ERR]` / `[WRN]` marker at the input row's right edge, backed by a walker diagnostics-severity model (parse-outcome + schema-existence); advisory, never blocks submission (`S6`); Amendment 1 adds a `LIKE`-on-numeric-column WARNING
- [ADR-0028 — Query plans (`EXPLAIN QUERY PLAN`)](0028-query-plans.md) — **Accepted**, an `explain` prefix command over `show data` / `update` / `delete`; an annotated, span-styled plan tree; introduces the `OutputLine` styled-runs mechanism (ADR-0016's deferred per-span styling) (`QA1` / `QA2`)
- [ADR-0029 — Column constraints (NOT NULL / UNIQUE / CHECK / DEFAULT)](0029-column-constraints.md) — **Accepted**, the four column-level constraints declared in the column-spec suffix (`create table` / `add column`) and modified on existing columns via `add constraint …` / `drop constraint …`; a pre-flight dry-run guards populated columns; `CHECK` reuses the ADR-0026 expression grammar via `Subgrammar` (`C3`)