ADR-0018 implementation: auto-fill contracts for serial and shortid

Generalises serial and shortid beyond their previous restricted
forms:

- `serial` is no longer restricted to single-column PK. Non-PK
  serial columns get an emitted UNIQUE constraint and use
  application-side MAX(col)+1 at INSERT time (rowid alias still
  drives the PK case for free; per ADR-0010 worker-thread
  serialisation, the read-then-insert sequence is safe).
- `shortid` columns auto-fill existing null cells when the
  column is materialised — `add column T: x (shortid)` on a
  non-empty table no longer leaves rows in a not-really-valid
  NULL state.
- `int -> serial` joins the type-change matrix as always-clean
  identity (closes the asymmetry vs `text -> shortid`); other
  sources are refused with a route-via-int hint.
- `change column T: x (serial|shortid)` fills null source
  cells with sequence / generated values in the same rebuild
  transaction.

Internal infrastructure:

- ReadColumn gains `unique: bool`; read_schema detects single-
  column UNIQUE indexes via pragma_index_list /
  pragma_index_info; schema_to_ddl emits inline UNIQUE for
  non-PK columns.
- ColumnSchema (persistence) gains `unique: bool` so the flag
  survives YAML round-trip and rebuild-from-text reconstructs
  it faithfully — preserves the "serial -> int leaves UNIQUE
  in place" promise across save/load cycles.
- ChangeColumnTypeResult.client_side now carries `auto_filled`
  + `auto_fill_kind` alongside `transformed` + `lossy`; the
  app handler renders separate note lines when both apply.
- AddColumnResult is a new return type carrying pre-rendered
  [client-side] note lines for the auto-fill paths.

Tests: 519 -> 534 (+15). Clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-08 14:32:19 +00:00
parent 7dfa718c6e
commit 5bb0a147f0
10 changed files with 1718 additions and 97 deletions
+44 -8
View File
@@ -54,13 +54,14 @@ pub fn static_refusal(src: Type, target: Type) -> Option<String> {
if src == target {
return Some(format!("column is already `{src}`."));
}
if matches!(target, Type::Serial) {
return Some(
"the 'serial' type carries auto-increment primary-key semantics \
that only apply at create-table time; pick a different target \
type (e.g. `int`)."
.to_string(),
);
// ADR-0018 §8: `int → serial` is allowed via the matrix.
// Other sources to serial are refused — the user routes
// via int first if needed.
if matches!(target, Type::Serial) && !matches!(src, Type::Int) {
return Some(format!(
"to convert from `{src}` to `serial`, change the column to `int` \
first; only `int → serial` is supported directly."
));
}
if matches!(src, Type::Blob) || matches!(target, Type::Blob) {
return Some(format!(
@@ -98,6 +99,14 @@ const fn is_in_matrix(src: Type, target: Type) -> bool {
// stored values"). Storage stays INTEGER; we treat
// it as an identity transformer.
| (Serial, Int)
// int -> serial: ADR-0018 §8. Storage stays
// INTEGER, the metadata flips to "auto-generated"
// and the column gains UNIQUE if non-PK. The
// matrix entry is identity at the value level;
// uniqueness and auto-fill of nulls happen at the
// change-column orchestration layer, not in the
// per-cell transformer.
| (Int, Serial)
| (Bool, Int | Real | Decimal | Text)
| (Decimal | Date | DateTime | ShortId | Real, Text)
// Per-cell-classified
@@ -141,6 +150,16 @@ pub fn transform_cell(src: Type, target: Type, value: &Value) -> CellOutcome {
Value::Integer(i) => CellOutcome::Clean(Value::Integer(*i)),
other => unexpected_storage("serial", other),
},
// int -> serial: identity at the storage class level
// (both INTEGER); the conversion adds the auto-
// generated contract and (for non-PK columns) UNIQUE.
// Per-cell transformer is identity; the change-column
// orchestrator handles null auto-fill and uniqueness.
// ADR-0018 §8.
(Int, Serial) => match value {
Value::Integer(i) => CellOutcome::Clean(Value::Integer(*i)),
other => unexpected_storage("int", other),
},
// ---- Always-clean: bool source (stored as INTEGER 0/1) ----
(Bool, Int) => match value {
@@ -568,12 +587,29 @@ mod tests {
}
#[test]
fn anything_to_serial_is_statically_refused() {
fn non_int_sources_to_serial_are_statically_refused() {
// ADR-0018 §8: only `int → serial` is allowed directly.
// Other sources have to route via int. (Same-type
// `serial → serial` is the no-op identity refusal.)
for &src in Type::all() {
if matches!(src, Type::Int | Type::Serial) {
continue;
}
assert!(static_refusal(src, Type::Serial).is_some(), "{src:?}");
}
}
#[test]
fn int_to_serial_is_allowed() {
// ADR-0018 §8: `int → serial` joins the matrix as
// always-clean identity. Non-null values pass through.
assert!(static_refusal(Type::Int, Type::Serial).is_none());
assert_eq!(
transform_cell(Type::Int, Type::Serial, &int(42)),
CellOutcome::Clean(int(42))
);
}
#[test]
fn anything_involving_blob_is_statically_refused() {
for &other in Type::all() {