From a36fd681bce8f6ae5c74442bf6a99652f5875486 Mon Sep 17 00:00:00 2001 From: devfive Date: Thu, 20 Aug 2026 19:56:54 +0900 Subject: [PATCH 1/2] fix(query): treat modify_column_type.fill_with replacements as bare enum labels The schema docs for `modify_column_type.fill_with` showed the replacement already wrapped in SQL single quotes (`{"cancelled": "'pending'"}`), but `build_fill_with_updates` binds it with `Expr::val`, which escapes the value and adds its own quoting. A migration written exactly as documented emitted `SET "col" = E'\'FIXED\''`, storing the 7-character token `'FIXED'` instead of the 5-character label `FIXED`, and PostgreSQL rejected it with `invalid input value for enum`. Settle on the bare form as the documented contract and keep `Expr::val` binding, so replacements stay injection-safe: - Move the fill_with emission out of `modify_column_type/mod.rs` into a new `modify_column_type/fill_with.rs` (matches the layout already described in `crates/vespertide-query/AGENTS.md`). - Add `strip_legacy_outer_quotes`: when a replacement both starts and ends with a single quote, strip exactly one outer layer, warn once, and proceed, so existing migration files written against the old docs keep working. - Document the bare contract on `MigrationAction::ModifyColumnType.fill_with` and regenerate `schemas/migration.schema.json` (one description line). - Normalise every collected value through `strip_enum_quotes` in `collect_enum_fill_with_values`, so the revision prompt cannot reintroduce quoted values regardless of the injected prompt fn. Tests cover all three backends: a bare label gets exactly one quote layer, a pre-quoted label produces byte-identical SQL, and multiple mappings keep the BTreeMap key ordering. --- .../commands/revision/prompts/fill_with.rs | 7 +- .../src/commands/revision/tests/prompts.rs | 19 ++ crates/vespertide-core/src/action/mod.rs | 7 +- .../src/sql/modify_column_type/fill_with.rs | 212 ++++++++++++++++++ .../src/sql/modify_column_type/mod.rs | 46 +--- ...ayer@fill_with_bare_replacement_mysql.snap | 5 + ...r@fill_with_bare_replacement_postgres.snap | 5 + ...yer@fill_with_bare_replacement_sqlite.snap | 5 + ...red@fill_with_multiple_mappings_mysql.snap | 7 + ...@fill_with_multiple_mappings_postgres.snap | 7 + ...ed@fill_with_multiple_mappings_sqlite.snap | 7 + schemas/migration.schema.json | 2 +- 12 files changed, 283 insertions(+), 46 deletions(-) create mode 100644 crates/vespertide-query/src/sql/modify_column_type/fill_with.rs create mode 100644 crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__fill_with__tests__bare_replacement_gets_exactly_one_quote_layer@fill_with_bare_replacement_mysql.snap create mode 100644 crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__fill_with__tests__bare_replacement_gets_exactly_one_quote_layer@fill_with_bare_replacement_postgres.snap create mode 100644 crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__fill_with__tests__bare_replacement_gets_exactly_one_quote_layer@fill_with_bare_replacement_sqlite.snap create mode 100644 crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__fill_with__tests__multiple_mappings_are_deterministically_ordered@fill_with_multiple_mappings_mysql.snap create mode 100644 crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__fill_with__tests__multiple_mappings_are_deterministically_ordered@fill_with_multiple_mappings_postgres.snap create mode 100644 crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__fill_with__tests__multiple_mappings_are_deterministically_ordered@fill_with_multiple_mappings_sqlite.snap diff --git a/crates/vespertide-cli/src/commands/revision/prompts/fill_with.rs b/crates/vespertide-cli/src/commands/revision/prompts/fill_with.rs index c3bb907d..b678724e 100644 --- a/crates/vespertide-cli/src/commands/revision/prompts/fill_with.rs +++ b/crates/vespertide-cli/src/commands/revision/prompts/fill_with.rs @@ -136,6 +136,8 @@ pub(in crate::commands::revision) fn prompt_enum_value_bare( /// Strip SQL single-quotes from an enum value string. /// `BTreeMap` stores bare enum names; the SQL layer handles quoting via `Expr::val()`. +/// A quoted value gets escaped twice and lands in the column as `'active'`, which +/// `PostgreSQL` rejects with `invalid input value for enum`. pub(in crate::commands::revision) fn strip_enum_quotes(value: &str) -> String { value .trim_start_matches('\'') @@ -290,6 +292,9 @@ where /// The original ordering of `remaining_values` is preserved for every entry /// other than the suggestion (which is hoisted to the top), so non-suggested /// options remain in a predictable order. +/// +/// Every value passes through [`strip_enum_quotes`], so the returned mappings +/// hold bare labels no matter what `enum_prompt_fn` returns. pub(in crate::commands::revision) fn collect_enum_fill_with_values( missing: &[EnumFillWithRequired], enum_prompt_fn: E, @@ -333,7 +338,7 @@ where } let ordered = reorder_with_suggestion(&item.remaining_values, suggestion.as_deref()); let value = enum_prompt_fn(&prompt, &ordered)?; - mappings.insert(removed.clone(), value); + mappings.insert(removed.clone(), strip_enum_quotes(&value)); } results.push((item.action_index, mappings)); } diff --git a/crates/vespertide-cli/src/commands/revision/tests/prompts.rs b/crates/vespertide-cli/src/commands/revision/tests/prompts.rs index 1f264fbb..2483583b 100644 --- a/crates/vespertide-cli/src/commands/revision/tests/prompts.rs +++ b/crates/vespertide-cli/src/commands/revision/tests/prompts.rs @@ -556,6 +556,25 @@ fn test_collect_enum_fill_with_values_single_removal() { ); } +#[test] +fn test_collect_enum_fill_with_values_strips_quotes_from_prompt_result() { + use vespertide_planner::EnumFillWithRequired; + + let missing = vec![EnumFillWithRequired { + action_index: 0, + table: "plan".to_string(), + column: "sheet_policy".to_string(), + removed_values: vec!["OVER_500".to_string()], + remaining_values: vec!["FIXED".to_string(), "NEGOTIATION".to_string()], + }]; + + let quoting_enum = + |_prompt: &str, values: &[String]| -> Result { Ok(format!("'{}'", values[0])) }; + + let collected = collect_enum_fill_with_values(&missing, quoting_enum).unwrap(); + assert_eq!(collected[0].1.get("OVER_500"), Some(&"FIXED".to_string())); +} + #[test] fn test_collect_enum_fill_with_values_multiple_removals() { use vespertide_planner::EnumFillWithRequired; diff --git a/crates/vespertide-core/src/action/mod.rs b/crates/vespertide-core/src/action/mod.rs index 59459c23..18568945 100644 --- a/crates/vespertide-core/src/action/mod.rs +++ b/crates/vespertide-core/src/action/mod.rs @@ -84,7 +84,12 @@ pub enum MigrationAction { column: ColumnName, new_type: ColumnType, /// Mapping of removed enum values to replacement values for safe enum value removal. - /// e.g., `{"cancelled": "'pending'"}` generates an `UPDATE` before the type change. + /// Both sides are **bare** enum labels — write them exactly as they appear in the enum + /// `values` list, with no surrounding SQL quotes. The SQL generator binds them as data + /// values and adds the quoting itself. + /// e.g., `{"cancelled": "pending"}` generates an `UPDATE` before the type change. + /// A legacy pre-quoted replacement (`"'pending'"`) still works: one outer quote layer is + /// stripped with a warning. #[serde(default, skip_serializing_if = "Option::is_none")] fill_with: Option>, /// Strategy for transforming existing rows that would violate a *narrowed* new type diff --git a/crates/vespertide-query/src/sql/modify_column_type/fill_with.rs b/crates/vespertide-query/src/sql/modify_column_type/fill_with.rs new file mode 100644 index 00000000..bf182084 --- /dev/null +++ b/crates/vespertide-query/src/sql/modify_column_type/fill_with.rs @@ -0,0 +1,212 @@ +//! `fill_with` UPDATE emission for `ModifyColumnType`. +//! +//! `fill_with` maps a removed enum label to the surviving label that replaces +//! it. Both sides of the mapping are **bare** labels ??no SQL quoting ??because +//! [`Expr::val`] binds them as data values and the query builder adds exactly +//! one layer of quoting itself. + +use std::collections::BTreeMap; +use std::sync::Once; + +use sea_query::{Alias, Expr, ExprTrait, Query}; + +use crate::sql::types::BuiltQuery; + +/// Emitted at most once per process by [`strip_legacy_outer_quotes`]. +static LEGACY_QUOTE_WARNING: Once = Once::new(); + +#[expect( + clippy::print_stderr, + reason = "one-time deprecation notice for legacy pre-quoted fill_with values; stderr keeps the emitted SQL on stdout intact" +)] +fn warn_legacy_quoted_replacement(column: &str, removed: &str, replacement: &str, bare: &str) { + LEGACY_QUOTE_WARNING.call_once(|| { + eprintln!( + "vespertide: warning: modify_column_type.fill_with replacement \ + {replacement} for {column}.{removed} is wrapped in SQL single \ + quotes. fill_with values are bare enum labels; the quotes were \ + stripped for compatibility. Rewrite the migration to use {bare}." + ); + }); +} + +/// Backward compatibility for migration files written against the older schema +/// documentation, which showed the replacement already wrapped in SQL single +/// quotes (`{"cancelled": "'pending'"}`). +/// +/// Since [`Expr::val`] binds the replacement as a *data value*, a pre-quoted +/// label is escaped into `'''pending'''`, whose content is the 9-character +/// token `'pending'` rather than the 7-character label `pending`. `PostgreSQL` +/// then rejects the UPDATE with `invalid input value for enum`. +/// +/// When the value both starts and ends with a single quote, exactly one outer +/// layer is stripped and a one-time warning is emitted. Everything else is +/// passed through untouched. +fn strip_legacy_outer_quotes<'a>(column: &str, removed: &str, replacement: &'a str) -> &'a str { + let Some(bare) = replacement + .strip_prefix('\'') + .and_then(|inner| inner.strip_suffix('\'')) + else { + return replacement; + }; + + warn_legacy_quoted_replacement(column, removed, replacement, bare); + bare +} + +/// Build UPDATE statements for `fill_with` mappings (removed enum values ??replacement values). +/// Each entry generates: UPDATE "table" SET "column" = 'replacement' WHERE "column" = '`removed_value`' +/// +/// Iteration follows the `BTreeMap` key order, so the emitted statements are +/// deterministic across runs and platforms. +fn build_fill_with_updates( + table: &str, + column: &str, + fill_with: &BTreeMap, +) -> Vec { + fill_with + .iter() + .map(|(removed_value, replacement)| { + let replacement = strip_legacy_outer_quotes(column, removed_value, replacement); + let update_stmt = Query::update() + .table(Alias::new(table)) + .value(Alias::new(column), Expr::val(replacement)) + .and_where(Expr::col(Alias::new(column)).eq(removed_value.as_str())) + .to_owned(); + BuiltQuery::Update(Box::new(update_stmt)) + }) + .collect() +} + +/// Conditionally prepend `fill_with` UPDATEs to `queries`. +/// +/// Centralises the byte-identical +/// `if let Some(fw) = fill_with { queries.extend(build_fill_with_updates(...)); }` +/// dance that the three `modify_column_type` paths each previously +/// open-coded (`direct::build_postgres_enum_migration`, +/// `direct::build_standard_type_modification`, and +/// `sqlite_rebuild::build_modify_column_type_sqlite_temp_table`). Each +/// callsite now collapses to a single line whose name reads +/// "if a fill_with map exists, prepend its UPDATEs". +pub(super) fn extend_fill_with_updates( + queries: &mut Vec, + table: &str, + column: &str, + fill_with: Option<&BTreeMap>, +) { + if let Some(fw) = fill_with { + queries.extend(build_fill_with_updates(table, column, fw)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sql::DatabaseBackend; + use crate::test_support::{backend_tag, joined_sql_semicolon}; + use insta::{assert_snapshot, with_settings}; + use rstest::rstest; + + fn updates(fill_with: &BTreeMap, backend: DatabaseBackend) -> String { + let mut queries = Vec::new(); + extend_fill_with_updates(&mut queries, "plan", "sheet_policy", Some(fill_with)); + joined_sql_semicolon(backend, &queries) + } + + fn map(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect() + } + + /// A bare enum label gets exactly one layer of SQL quoting from the query + /// builder ??the enum label reaches the column intact. + #[rstest] + #[case::postgres(DatabaseBackend::Postgres)] + #[case::mysql(DatabaseBackend::MySql)] + #[case::sqlite(DatabaseBackend::Sqlite)] + fn bare_replacement_gets_exactly_one_quote_layer(#[case] backend: DatabaseBackend) { + let sql = updates(&map(&[("OVER_500", "FIXED")]), backend); + + assert!( + sql.contains("= 'FIXED'"), + "expected a single quote layer around FIXED, got: {sql}" + ); + assert!( + !sql.contains("'''FIXED'''"), + "replacement must not be double-quoted, got: {sql}" + ); + + with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => format!("fill_with_bare_replacement_{}", backend_tag(backend)) }, { + assert_snapshot!(sql); + }); + } + + /// Compatibility: a legacy pre-quoted replacement produces the SAME SQL as + /// the bare form, so migrations written against the old documentation keep + /// working. + #[rstest] + #[case::postgres(DatabaseBackend::Postgres)] + #[case::mysql(DatabaseBackend::MySql)] + #[case::sqlite(DatabaseBackend::Sqlite)] + fn quoted_replacement_matches_bare_replacement(#[case] backend: DatabaseBackend) { + let bare = updates(&map(&[("OVER_500", "FIXED")]), backend); + let quoted = updates(&map(&[("OVER_500", "'FIXED'")]), backend); + + assert_eq!(quoted, bare); + } + + /// Only the outer layer is stripped: a doubly-wrapped value keeps its inner + /// quotes, and a value with a stray quote on one side is left alone. + #[rstest] + #[case::double_wrapped("''FIXED''", "'FIXED'")] + #[case::leading_quote_only("'FIXED", "'FIXED")] + #[case::trailing_quote_only("FIXED'", "FIXED'")] + #[case::lone_quote("'", "'")] + #[case::empty_quotes("''", "")] + #[case::bare("FIXED", "FIXED")] + fn strip_legacy_outer_quotes_removes_at_most_one_layer( + #[case] input: &str, + #[case] expected: &str, + ) { + assert_eq!( + strip_legacy_outer_quotes("sheet_policy", "OVER_500", input), + expected + ); + } + + /// `fill_with` is a `BTreeMap`, so multiple mappings emit in sorted key + /// order regardless of insertion order. + #[rstest] + #[case::postgres(DatabaseBackend::Postgres)] + #[case::mysql(DatabaseBackend::MySql)] + #[case::sqlite(DatabaseBackend::Sqlite)] + fn multiple_mappings_are_deterministically_ordered(#[case] backend: DatabaseBackend) { + let ascending = map(&[ + ("OVER_500", "FIXED"), + ("PER_SHEET", "NEGOTIATION"), + ("UNDER_100", "FIXED"), + ]); + let descending = map(&[ + ("UNDER_100", "FIXED"), + ("PER_SHEET", "NEGOTIATION"), + ("OVER_500", "FIXED"), + ]); + let sql = updates(&ascending, backend); + + assert_eq!(updates(&descending, backend), sql); + + with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => format!("fill_with_multiple_mappings_{}", backend_tag(backend)) }, { + assert_snapshot!(sql); + }); + } + + /// `None` contributes no statements. + #[test] + fn absent_fill_with_emits_nothing() { + let mut queries = Vec::new(); + extend_fill_with_updates(&mut queries, "plan", "sheet_policy", None); + assert!(queries.is_empty()); + } +} diff --git a/crates/vespertide-query/src/sql/modify_column_type/mod.rs b/crates/vespertide-query/src/sql/modify_column_type/mod.rs index 193234c5..569e2084 100644 --- a/crates/vespertide-query/src/sql/modify_column_type/mod.rs +++ b/crates/vespertide-query/src/sql/modify_column_type/mod.rs @@ -1,9 +1,12 @@ mod direct; +mod fill_with; mod narrowing_preprocess; mod sqlite_rebuild; pub use narrowing_preprocess::build_narrowing_preprocess; +use fill_with::extend_fill_with_updates; + use vespertide_core::NarrowingStrategy; /// Combine narrowing pre-processing (when `narrowing_strategy` is set) with @@ -138,8 +141,6 @@ fn build_pg_alter_with_timezone( use std::collections::BTreeMap; -use sea_query::{Alias, Expr, ExprTrait, Query}; - use vespertide_core::{ColumnType, TableDef}; use self::direct::build_modify_column_type_direct; @@ -147,47 +148,6 @@ use self::sqlite_rebuild::build_modify_column_type_sqlite_temp_table; use super::types::{BuiltQuery, DatabaseBackend}; use crate::error::QueryError; -/// Build UPDATE statements for `fill_with` mappings (removed enum values → replacement values). -/// Each entry generates: UPDATE "table" SET "column" = 'replacement' WHERE "column" = '`removed_value`' -fn build_fill_with_updates( - table: &str, - column: &str, - fill_with: &BTreeMap, -) -> Vec { - fill_with - .iter() - .map(|(removed_value, replacement)| { - let update_stmt = Query::update() - .table(Alias::new(table)) - .value(Alias::new(column), Expr::val(replacement.as_str())) - .and_where(Expr::col(Alias::new(column)).eq(removed_value.as_str())) - .to_owned(); - BuiltQuery::Update(Box::new(update_stmt)) - }) - .collect() -} - -/// Conditionally prepend `fill_with` UPDATEs to `queries`. -/// -/// Centralises the byte-identical -/// `if let Some(fw) = fill_with { queries.extend(build_fill_with_updates(...)); }` -/// dance that the three `modify_column_type` paths each previously -/// open-coded (`direct::build_postgres_enum_migration`, -/// `direct::build_standard_type_modification`, and -/// `sqlite_rebuild::build_modify_column_type_sqlite_temp_table`). Each -/// callsite now collapses to a single line whose name reads -/// "if a fill_with map exists, prepend its UPDATEs". -pub(super) fn extend_fill_with_updates( - queries: &mut Vec, - table: &str, - column: &str, - fill_with: Option<&BTreeMap>, -) { - if let Some(fw) = fill_with { - queries.extend(build_fill_with_updates(table, column, fw)); - } -} - pub fn build_modify_column_type( backend: DatabaseBackend, table: &str, diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__fill_with__tests__bare_replacement_gets_exactly_one_quote_layer@fill_with_bare_replacement_mysql.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__fill_with__tests__bare_replacement_gets_exactly_one_quote_layer@fill_with_bare_replacement_mysql.snap new file mode 100644 index 00000000..f17c869e --- /dev/null +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__fill_with__tests__bare_replacement_gets_exactly_one_quote_layer@fill_with_bare_replacement_mysql.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespertide-query/src/sql/modify_column_type/fill_with.rs +expression: sql +--- +UPDATE `plan` SET `sheet_policy` = 'FIXED' WHERE `sheet_policy` = 'OVER_500' diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__fill_with__tests__bare_replacement_gets_exactly_one_quote_layer@fill_with_bare_replacement_postgres.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__fill_with__tests__bare_replacement_gets_exactly_one_quote_layer@fill_with_bare_replacement_postgres.snap new file mode 100644 index 00000000..daaa6aa5 --- /dev/null +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__fill_with__tests__bare_replacement_gets_exactly_one_quote_layer@fill_with_bare_replacement_postgres.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespertide-query/src/sql/modify_column_type/fill_with.rs +expression: sql +--- +UPDATE "plan" SET "sheet_policy" = 'FIXED' WHERE "sheet_policy" = 'OVER_500' diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__fill_with__tests__bare_replacement_gets_exactly_one_quote_layer@fill_with_bare_replacement_sqlite.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__fill_with__tests__bare_replacement_gets_exactly_one_quote_layer@fill_with_bare_replacement_sqlite.snap new file mode 100644 index 00000000..daaa6aa5 --- /dev/null +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__fill_with__tests__bare_replacement_gets_exactly_one_quote_layer@fill_with_bare_replacement_sqlite.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespertide-query/src/sql/modify_column_type/fill_with.rs +expression: sql +--- +UPDATE "plan" SET "sheet_policy" = 'FIXED' WHERE "sheet_policy" = 'OVER_500' diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__fill_with__tests__multiple_mappings_are_deterministically_ordered@fill_with_multiple_mappings_mysql.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__fill_with__tests__multiple_mappings_are_deterministically_ordered@fill_with_multiple_mappings_mysql.snap new file mode 100644 index 00000000..d8f1aa19 --- /dev/null +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__fill_with__tests__multiple_mappings_are_deterministically_ordered@fill_with_multiple_mappings_mysql.snap @@ -0,0 +1,7 @@ +--- +source: crates/vespertide-query/src/sql/modify_column_type/fill_with.rs +expression: sql +--- +UPDATE `plan` SET `sheet_policy` = 'FIXED' WHERE `sheet_policy` = 'OVER_500'; +UPDATE `plan` SET `sheet_policy` = 'NEGOTIATION' WHERE `sheet_policy` = 'PER_SHEET'; +UPDATE `plan` SET `sheet_policy` = 'FIXED' WHERE `sheet_policy` = 'UNDER_100' diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__fill_with__tests__multiple_mappings_are_deterministically_ordered@fill_with_multiple_mappings_postgres.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__fill_with__tests__multiple_mappings_are_deterministically_ordered@fill_with_multiple_mappings_postgres.snap new file mode 100644 index 00000000..dad03eab --- /dev/null +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__fill_with__tests__multiple_mappings_are_deterministically_ordered@fill_with_multiple_mappings_postgres.snap @@ -0,0 +1,7 @@ +--- +source: crates/vespertide-query/src/sql/modify_column_type/fill_with.rs +expression: sql +--- +UPDATE "plan" SET "sheet_policy" = 'FIXED' WHERE "sheet_policy" = 'OVER_500'; +UPDATE "plan" SET "sheet_policy" = 'NEGOTIATION' WHERE "sheet_policy" = 'PER_SHEET'; +UPDATE "plan" SET "sheet_policy" = 'FIXED' WHERE "sheet_policy" = 'UNDER_100' diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__fill_with__tests__multiple_mappings_are_deterministically_ordered@fill_with_multiple_mappings_sqlite.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__fill_with__tests__multiple_mappings_are_deterministically_ordered@fill_with_multiple_mappings_sqlite.snap new file mode 100644 index 00000000..dad03eab --- /dev/null +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__fill_with__tests__multiple_mappings_are_deterministically_ordered@fill_with_multiple_mappings_sqlite.snap @@ -0,0 +1,7 @@ +--- +source: crates/vespertide-query/src/sql/modify_column_type/fill_with.rs +expression: sql +--- +UPDATE "plan" SET "sheet_policy" = 'FIXED' WHERE "sheet_policy" = 'OVER_500'; +UPDATE "plan" SET "sheet_policy" = 'NEGOTIATION' WHERE "sheet_policy" = 'PER_SHEET'; +UPDATE "plan" SET "sheet_policy" = 'FIXED' WHERE "sheet_policy" = 'UNDER_100' diff --git a/schemas/migration.schema.json b/schemas/migration.schema.json index 387e4d00..9b640eb5 100644 --- a/schemas/migration.schema.json +++ b/schemas/migration.schema.json @@ -563,7 +563,7 @@ "$ref": "#/$defs/ColumnName" }, "fill_with": { - "description": "Mapping of removed enum values to replacement values for safe enum value removal.\ne.g., `{\"cancelled\": \"'pending'\"}` generates an `UPDATE` before the type change.", + "description": "Mapping of removed enum values to replacement values for safe enum value removal.\nBoth sides are **bare** enum labels — write them exactly as they appear in the enum\n`values` list, with no surrounding SQL quotes. The SQL generator binds them as data\nvalues and adds the quoting itself.\ne.g., `{\"cancelled\": \"pending\"}` generates an `UPDATE` before the type change.\nA legacy pre-quoted replacement (`\"'pending'\"`) still works: one outer quote layer is\nstripped with a warning.", "type": [ "object", "null" From a68f8a954b9ec1d99241954481754b655c8c5fa5 Mon Sep 17 00:00:00 2001 From: devfive Date: Thu, 20 Aug 2026 20:03:21 +0900 Subject: [PATCH 2/2] chore(changepacks): add Minor descriptor for the fill_with quoting fix --- .changepacks/changepack_log_A6HBSMdx7cre8I-RyNhPx.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changepacks/changepack_log_A6HBSMdx7cre8I-RyNhPx.json diff --git a/.changepacks/changepack_log_A6HBSMdx7cre8I-RyNhPx.json b/.changepacks/changepack_log_A6HBSMdx7cre8I-RyNhPx.json new file mode 100644 index 00000000..843e7a07 --- /dev/null +++ b/.changepacks/changepack_log_A6HBSMdx7cre8I-RyNhPx.json @@ -0,0 +1 @@ +{"changes":{"crates/vespertide-core/Cargo.toml":"Minor","crates/vespertide-query/Cargo.toml":"Minor","crates/vespertide-cli/Cargo.toml":"Minor"},"note":"modify_column_type.fill_with 를 bare enum 라벨로 확정 (스키마 문서와 구현 불일치로 enum 축소 마이그레이션이 invalid input value for enum 으로 실패하던 문제). 따옴표가 이미 붙은 기존 값은 한 겹 제거 + 1회 경고로 하위호환 유지","date":"2026-08-20T11:02:39.1090232Z"} \ No newline at end of file