constraints: CHECK — check (<expr>) at create table & add column (ADR-0029)

The fourth constraint. `check ( <expr> )` reuses the ADR-0026
WHERE-expression grammar via `Subgrammar`, so a check is
written in the same language as a `where` filter.

- Grammar: a `CHECK_CONSTRAINT` arm joins the shared
  constraint-suffix Choice; `consume_check_expr` extracts the
  parenthesised expression (paren-depth aware) into
  `ColumnSpec.check` / `Command::AddColumn.check`.
- Storage: the parsed `Expr` is compiled once to inline SQL
  (`compile_check_sql` — `compile_expr` + ADR-0028's
  param-inliner) and stored in that form everywhere — a new
  `check_expr` column in `__rdbms_playground_columns`,
  `project.yaml`'s `ColumnSchema.check`, and the column DDL
  emitted by `do_create_table` / `schema_to_ddl`.
- `add column … check` routes through the rebuild primitive
  (SQLite's `ALTER … ADD COLUMN` cannot carry it); a CHECK on
  a serial/shortid column is create-table-only and refused at
  add-column with a friendly message.
- `describe` surfaces the CHECK. ADR-0029 §7/§8 updated to the
  SQL-form decision — double-quoted identifiers, consistent
  with ADR-0028's `explain` display SQL.

1201 tests pass (+8); clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-19 16:42:18 +00:00
parent 58d8958822
commit 942222bfc9
11 changed files with 421 additions and 73 deletions
+92 -10
View File
@@ -13,7 +13,7 @@
use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{
ChangeColumnMode, ColumnSpec, Command, IndexSelector, RelationshipSelector,
ChangeColumnMode, ColumnSpec, Command, Expr, IndexSelector, RelationshipSelector,
};
use crate::dsl::value::Value;
use crate::dsl::grammar::{
@@ -27,7 +27,7 @@ use crate::dsl::grammar::{
/// candidates (ADR-0024 §HintMode-per-node).
const NEW_NAME_HINT: HintMode = HintMode::ForceProse("hint.ambient_typing_name");
use crate::dsl::types::Type;
use crate::dsl::walker::outcome::{MatchedKind, MatchedPath};
use crate::dsl::walker::outcome::{MatchedItem, MatchedKind, MatchedPath};
// =================================================================
// Building blocks
@@ -616,7 +616,8 @@ fn build_add(path: &MatchedPath) -> Result<Command, ValidationError> {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?;
let (not_null, unique, default) = collect_column_constraints(path)?;
let (not_null, unique, default, check) =
collect_column_constraints(path)?;
Ok(Command::AddColumn {
table: require_ident(path, "table_name")?,
column: require_ident(path, "column_name")?,
@@ -624,8 +625,7 @@ fn build_add(path: &MatchedPath) -> Result<Command, ValidationError> {
not_null,
unique,
default,
// CHECK joins in a later ADR-0029 step.
check: None,
check,
})
}
Some("1") => build_add_relationship(path),
@@ -842,8 +842,20 @@ const DEFAULT_CONSTRAINT_NODES: &[Node] = &[
];
const DEFAULT_CONSTRAINT: Node = Node::Seq(DEFAULT_CONSTRAINT_NODES);
// `check ( <expr> )` — the expression is the ADR-0026 WHERE
// grammar, reached through `Subgrammar` (ADR-0029 §2.1). The
// parentheses match SQL's `CHECK (…)` and give the parser an
// unambiguous end for the expression.
const CHECK_CONSTRAINT_NODES: &[Node] = &[
Node::Word(Word::keyword("check")),
Node::Punct('('),
Node::Subgrammar(&super::expr::OR_EXPR),
Node::Punct(')'),
];
const CHECK_CONSTRAINT: Node = Node::Seq(CHECK_CONSTRAINT_NODES);
const COLUMN_CONSTRAINT_CHOICES: &[Node] =
&[NOT_NULL_CONSTRAINT, UNIQUE_CONSTRAINT, DEFAULT_CONSTRAINT];
&[NOT_NULL_CONSTRAINT, UNIQUE_CONSTRAINT, DEFAULT_CONSTRAINT, CHECK_CONSTRAINT];
const COLUMN_CONSTRAINT: Node = Node::Choice(COLUMN_CONSTRAINT_CHOICES);
/// Zero-or-more constraints — the suffix after a column's
@@ -894,18 +906,49 @@ const CREATE_TABLE_NODES: &[Node] = &[
];
const CREATE_TABLE: Node = Node::Seq(CREATE_TABLE_NODES);
/// Consume a `check` constraint's `( <expr> )` from `items`,
/// which must be positioned just after the `Word("check")`,
/// and build the ADR-0026 expression (ADR-0029 §2.1). The
/// grammar's `Seq` guarantees the surrounding `(` … `)`;
/// paren depth handles a parenthesised sub-expression inside.
fn consume_check_expr(
items: &mut std::iter::Peekable<std::slice::Iter<'_, MatchedItem>>,
) -> Result<Expr, ValidationError> {
items.next(); // the opening `(`
let mut depth = 1usize;
let mut expr_items: Vec<MatchedItem> = Vec::new();
for inner in items.by_ref() {
match &inner.kind {
MatchedKind::Punct('(') => {
depth += 1;
expr_items.push(inner.clone());
}
MatchedKind::Punct(')') => {
depth -= 1;
if depth == 0 {
break;
}
expr_items.push(inner.clone());
}
_ => expr_items.push(inner.clone()),
}
}
super::expr::build_expr(&expr_items)
}
/// Collect the ADR-0029 constraint suffix from a
/// single-column command's matched path (`add column`),
/// returning the `(not_null, unique, default)` triple. The
/// scan reacts only to the four constraint keywords, so
/// returning the `(not_null, unique, default, check)` tuple.
/// The scan reacts only to the constraint keywords, so
/// passing the whole path is safe. (`create table`'s
/// multi-column collection is inline in `build_create_table`.)
fn collect_column_constraints(
path: &MatchedPath,
) -> Result<(bool, bool, Option<Value>), ValidationError> {
) -> Result<(bool, bool, Option<Value>, Option<Expr>), ValidationError> {
let mut not_null = false;
let mut unique = false;
let mut default = None;
let mut check = None;
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
match &item.kind {
@@ -929,10 +972,13 @@ fn collect_column_constraints(
})?;
default = Some(value);
}
MatchedKind::Word("check") => {
check = Some(consume_check_expr(&mut items)?);
}
_ => {}
}
}
Ok((not_null, unique, default))
Ok((not_null, unique, default, check))
}
/// The friendly error for declaring a constraint a
@@ -1005,6 +1051,13 @@ fn build_create_table(path: &MatchedPath) -> Result<Command, ValidationError> {
last.default = Some(value);
}
}
// `check ( <expr> )` (ADR-0029 §2.1).
MatchedKind::Word("check") => {
let expr = consume_check_expr(&mut items)?;
if let Some(last) = columns.last_mut() {
last.check = Some(expr);
}
}
_ => {}
}
}
@@ -1152,4 +1205,33 @@ mod constraint_tests {
other => panic!("expected AddColumn, got {other:?}"),
}
}
#[test]
fn create_table_parses_a_check_constraint() {
let cols = create_columns("create table T with pk age(int) check (age >= 0)");
assert_eq!(cols.len(), 1);
assert!(cols[0].check.is_some(), "the column carries a CHECK");
}
#[test]
fn add_column_parses_a_check_constraint() {
match parse_command("add column to T: age (int) check (age >= 0 and age < 150)")
.expect("parse")
{
Command::AddColumn { check, .. } => {
assert!(check.is_some(), "the column carries a CHECK");
}
other => panic!("expected AddColumn, got {other:?}"),
}
}
#[test]
fn check_with_a_parenthesised_sub_expression_parses() {
// The check's own parens plus a nested group — the
// builder's paren-depth scan must pair them correctly.
let cols = create_columns(
"create table T with pk n(int) check ((n > 0) or (n < -10))",
);
assert!(cols[0].check.is_some());
}
}