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:
+44
-8
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user