ADR-0017 implementation: per-cell type-change with override flags

Replaces the placeholder "trust STRICT" body of do_change_column_type
with the per-cell transformer matrix from ADR-0017. Adds:

- src/type_change.rs: CellOutcome { Clean / Lossy / Incompatible }
  + transform_cell + static_refusal covering every matrix pair
  from §3 (54 unit tests).
- --force-conversion and --dont-convert flags on `change column`
  (mutually exclusive at parse time per §5).
- Refined PK rule (§4.1): refused only when the column has an
  inbound FK and fk_target_type would change. Outbound-FK columns
  still refused outright (§4.2). PK / shortid uniqueness checked
  post-transformation (§4.3).
- Bordered diagnostic tables (lossy / incompatible / collision)
  via the pretty-table renderer (§7) — uses ADR-0016's primitives.
- [client-side] success note (§6) when any cell was rewritten.
- Friendly wrapper for engine-level errors under --dont-convert
  so no engine vocabulary leaks (ADR-0002 user-facing posture).

ADR-0017 §3 + §7 amended in place (with user sign-off): serial->int
added explicitly to the always-clean matrix, and diagnostic rows
identify themselves by PK value(s) rather than positional indices
(SQLite returns rows unordered without ORDER BY, so positional
"row 5" is unaddressable).

Tests: 449 -> 517 (+68). Clippy clean with nursery lints.
This commit is contained in:
claude@clouddev1
2026-05-08 13:21:07 +00:00
parent 545cbf5c0e
commit 00947b928c
12 changed files with 2598 additions and 137 deletions
+104 -5
View File
@@ -14,7 +14,9 @@ use chumsky::error::RichReason;
use chumsky::prelude::*;
use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{ColumnSpec, Command, RelationshipSelector, RowFilter};
use crate::dsl::command::{
ChangeColumnMode, ColumnSpec, Command, RelationshipSelector, RowFilter,
};
use crate::dsl::types::Type;
use crate::dsl::value::Value;
@@ -172,9 +174,10 @@ fn command_parser<'a>()
.then(identifier())
.map(|((table, old), new)| Command::RenameColumn { table, old, new });
// `change column [in] [table] <T>: <col> (<newtype>)`.
// Same shape as `add column` — the column-and-type
// tuple in parens — but the verb is `change`.
// `change column [in] [table] <T>: <col> (<newtype>) [flags]`
// where `flags` is at most one of `--force-conversion` /
// `--dont-convert` (mutually exclusive at parse time per
// ADR-0017 §5).
let change_column = keyword_ci("change")
.ignore_then(keyword_ci("column"))
.ignore_then(optional_keyword("in"))
@@ -185,7 +188,13 @@ fn command_parser<'a>()
.then_ignore(just('(').padded())
.then(type_keyword())
.then_ignore(just(')').padded())
.map(|((table, column), ty)| Command::ChangeColumnType { table, column, ty });
.then(change_column_flags())
.map(|(((table, column), ty), mode)| Command::ChangeColumnType {
table,
column,
ty,
mode,
});
let add_relationship = add_relationship_parser();
let drop_relationship = drop_relationship_parser();
@@ -555,6 +564,33 @@ fn create_fk_flag<'a>()
.map(|opt| opt.is_some())
}
/// Optional flags for `change column …` (ADR-0017 §5).
/// Allows zero or one of the two mutually-exclusive flags;
/// emits a custom parse error if both are present, naming both
/// flags so the user knows what the conflict is.
fn change_column_flags<'a>()
-> impl Parser<'a, &'a str, ChangeColumnMode, extra::Err<Rich<'a, char>>> + Clone {
let force = just("--force-conversion")
.padded()
.to(ChangeColumnMode::ForceConversion);
let dont = just("--dont-convert")
.padded()
.to(ChangeColumnMode::DontConvert);
choice((force, dont))
.repeated()
.collect::<Vec<_>>()
.try_map(|flags, span| match flags.as_slice() {
[] => Ok(ChangeColumnMode::Default),
[single] => Ok(*single),
_ => Err(Rich::custom(
span,
"`--force-conversion` and `--dont-convert` are mutually \
exclusive — pick one."
.to_string(),
)),
})
}
/// Parse the optional `with pk [<spec>]` clause that may follow
/// `create table <Name>`. Returns the list of (name, type) pairs
/// that form the primary key. An absent clause returns an empty
@@ -870,6 +906,7 @@ mod tests {
table: "Customers".to_string(),
column: "Score".to_string(),
ty: Type::Int,
mode: ChangeColumnMode::Default,
}
);
}
@@ -882,6 +919,7 @@ mod tests {
table: "Customers".to_string(),
column: "Score".to_string(),
ty: Type::Real,
mode: ChangeColumnMode::Default,
}
);
}
@@ -894,10 +932,71 @@ mod tests {
table: "Customers".to_string(),
column: "Score".to_string(),
ty: Type::Text,
mode: ChangeColumnMode::Default,
}
);
}
#[test]
fn change_column_with_force_conversion_flag() {
assert_eq!(
ok("change column Customers: Score (int) --force-conversion"),
Command::ChangeColumnType {
table: "Customers".to_string(),
column: "Score".to_string(),
ty: Type::Int,
mode: ChangeColumnMode::ForceConversion,
}
);
}
#[test]
fn change_column_with_dont_convert_flag() {
assert_eq!(
ok("change column Customers: Score (int) --dont-convert"),
Command::ChangeColumnType {
table: "Customers".to_string(),
column: "Score".to_string(),
ty: Type::Int,
mode: ChangeColumnMode::DontConvert,
}
);
}
#[test]
fn change_column_rejects_both_flags() {
let e = err(
"change column Customers: Score (int) --force-conversion --dont-convert",
);
match e {
ParseError::Invalid { message, .. } => {
assert!(
message.contains("--force-conversion")
&& message.contains("--dont-convert"),
"expected both flag names in error: {message}"
);
assert!(
message.contains("mutually exclusive") || message.contains("pick one"),
"{message}"
);
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn change_column_rejects_both_flags_in_either_order() {
// Both orderings — and same-flag-twice — should reject
// with a uniform "pick one" signal.
let e = err("change column T: c (int) --dont-convert --force-conversion");
match e {
ParseError::Invalid { message, .. } => {
assert!(message.contains("mutually exclusive"), "{message}");
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn drop_table_simple() {
assert_eq!(