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:
+22
-5
@@ -63,15 +63,17 @@ pub enum Command {
|
||||
},
|
||||
/// Change a column's type. Implemented via the
|
||||
/// rebuild-table primitive (ADR-0013) since SQLite's
|
||||
/// ALTER TABLE does not support type changes. Refused
|
||||
/// for PK columns and for columns involved in a declared
|
||||
/// relationship — those would require cascading FK
|
||||
/// type updates the v1 surface deliberately doesn't
|
||||
/// expose.
|
||||
/// ALTER TABLE does not support type changes.
|
||||
///
|
||||
/// Per ADR-0017 the actual conversion model is a per-cell
|
||||
/// dry-run against a curated transformer matrix; the two
|
||||
/// optional flags carried in `mode` let the user opt into
|
||||
/// lossy conversions or skip the client-side layer entirely.
|
||||
ChangeColumnType {
|
||||
table: String,
|
||||
column: String,
|
||||
ty: Type,
|
||||
mode: ChangeColumnMode,
|
||||
},
|
||||
/// Establish a 1:n relationship: parent_table.parent_column
|
||||
/// is the primary-key side; child_table.child_column is the
|
||||
@@ -125,6 +127,21 @@ pub enum Command {
|
||||
},
|
||||
}
|
||||
|
||||
/// Conversion mode for `change column …` (ADR-0017 §5).
|
||||
///
|
||||
/// `Default` runs the per-cell dry-run and refuses on lossy or
|
||||
/// incompatible cells. `ForceConversion` accepts lossy cells but
|
||||
/// still refuses incompatibles. `DontConvert` skips the entire
|
||||
/// client-side layer and lets the database's STRICT typing
|
||||
/// decide. `ForceConversion` and `DontConvert` are mutually
|
||||
/// exclusive at the grammar level.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChangeColumnMode {
|
||||
Default,
|
||||
ForceConversion,
|
||||
DontConvert,
|
||||
}
|
||||
|
||||
/// How an UPDATE / DELETE selects which rows to operate on.
|
||||
/// `Where` is the default safe form. `AllRows` is the explicit
|
||||
/// `--all-rows` flag opt-in for unfiltered operations.
|
||||
|
||||
+3
-1
@@ -17,7 +17,9 @@ pub mod types;
|
||||
pub mod value;
|
||||
|
||||
pub use action::ReferentialAction;
|
||||
pub use command::{ColumnSpec, Command, RelationshipSelector, RowFilter};
|
||||
pub use command::{
|
||||
ChangeColumnMode, ColumnSpec, Command, RelationshipSelector, RowFilter,
|
||||
};
|
||||
pub use parser::{ParseError, parse_command};
|
||||
pub use types::Type;
|
||||
pub use value::Value;
|
||||
|
||||
+104
-5
@@ -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!(
|
||||
|
||||
+2
-2
@@ -208,7 +208,7 @@ impl Value {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_date(s: &str) -> Result<(), String> {
|
||||
pub(crate) fn validate_date(s: &str) -> Result<(), String> {
|
||||
// Expect YYYY-MM-DD: 10 chars, two dashes at fixed positions.
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
|
||||
@@ -231,7 +231,7 @@ fn validate_date(s: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_datetime(s: &str) -> Result<(), String> {
|
||||
pub(crate) fn validate_datetime(s: &str) -> Result<(), String> {
|
||||
// Minimum: YYYY-MM-DDTHH:MM:SS = 19 chars. Allow optional
|
||||
// fractional seconds (.fff) and optional Z or ±HH:MM offset.
|
||||
if s.len() < 19 {
|
||||
|
||||
Reference in New Issue
Block a user