feat: add data_migration action for schema-neutral DML - #183
Merged
Conversation
RawSql is skipped during baseline replay because its effect is unknown.
That is harmless for pure DML but fatal for DDL: replay silently loses the
schema change, so `vespertide diff` reports already-applied changes forever.
Nothing in the type system let an author say "this SQL is data-only", so the
distinction could only be made by reading every raw_sql body by hand.
Add MigrationAction::DataMigration, which is also skipped by replay - but
because changing no schema is its enforced contract, not because its effect
is unknown. A statement whose first token (after trimming comments and
whitespace) is CREATE / ALTER / DROP / TRUNCATE is rejected at load and plan
time with PlannerError::DataMigrationContainsDdl.
The action fills the gap left by the schema-coupled backfill facilities:
add_column.fill_with only fires for a newly added NOT NULL column with no
default, modify_column_default.backfill sets one column to one value for
every row, and modify_column_type.fill_with only remaps enum labels. None
can express a conditional backfill of existing columns, a correlated-subquery
backfill, or data reshaping during a format change.
- wire format: {"type":"data_migration","sql":...,"description":...} where
`sql` is a portable string or an object keyed by postgres/mysql/sqlite;
`description` is optional and shown by `vespertide diff`
- SQL is emitted byte-for-byte: no case folding, cast rewriting or
reformatting, bypassing every backend-normalising helper by design
- diff and status now warn when applied migrations contain raw_sql, naming
the affected versions, so the replay hazard is no longer invisible
- rustdoc on RawSql and its migration.schema.json description point at
data_migration for data-only changes
Changepacksvespertide@0.2.1 → 0.2.2 - crates/vespertide/Cargo.tomlPatch
vespertide-cli@0.2.1 → 0.3.0 - crates/vespertide-cli/Cargo.tomlMinor
vespertide-config@0.2.1 → 0.3.0 - crates/vespertide-config/Cargo.tomlMinor
vespertide-core@0.2.1 → 0.3.0 - crates/vespertide-core/Cargo.tomlMinor
vespertide-exporter@0.2.1 → 0.3.0 - crates/vespertide-exporter/Cargo.tomlMinor
vespertide-loader@0.2.1 → 0.2.2 - crates/vespertide-loader/Cargo.tomlPatch
vespertide-lsp@0.2.1 → 0.2.2 - crates/vespertide-lsp/Cargo.tomlPatch
vespertide-macro@0.2.1 → 0.2.2 - crates/vespertide-macro/Cargo.tomlPatch
vespertide-naming@0.2.1 → 0.3.0 - crates/vespertide-naming/Cargo.tomlMinor
vespertide-planner@0.2.1 → 0.3.0 - crates/vespertide-planner/Cargo.tomlMinor
vespertide-query@0.2.1 → 0.3.0 - crates/vespertide-query/Cargo.tomlMinor
|
Two CI failures on the PR, one mine and one surfaced by the missing changepack. cargo-mutants shard 7 flagged a surviving mutant: replacing `+` with `*` in `&after[idx + 1..]` (the `--` line-comment branch of strip_leading_trivia). The mutant survives because `rest.trim_start()` runs immediately afterwards and eats the newline either way, so no test could ever distinguish the two — the `+ 1` was simply redundant. Rewrite both comment branches with `split_once`, which drops the index arithmetic entirely and makes the two branches read identically. All 25 mutants in the file are now caught. cargo-semver-checks failed with "assume minor" because the job derives its release-type from the changepack THIS PR introduces, and there was none. The gate then evaluated a set of pre-existing breaking changes already on main (the `pub` -> `pub(crate)` narrowing of sql::helpers from bca0bf6, plus others) against minor rules. Add the changepack this PR owes. vespertide-core / planner / query / cli are Minor, which for 0.x crates is the breaking bump; vespertide-lsp is Patch since only an internal match arm changed. Declaring Minor is honest rather than merely convenient: PlannerError is NOT #[non_exhaustive], so the new DataMigrationContainsDdl variant is a genuine breaking change for that crate.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
MigrationAction::RawSqlis skipped during baseline replay because its effect is unknown. That is harmless when the SQL is pure DML ??it changes no schema, so there is nothing for replay to reflect. It is fatal when the SQL is DDL: replay silently loses the schema change, the reconstructed baseline is permanently wrong, andvespertide diffreports the same already-applied changes on every run.We hit exactly this in a production repository. Four migrations used
raw_sqlfor DDL, sovespertide diffpermanently reported 14 unrelated pending changes, andvespertide revisionthen generated a 15-action migration when only ONE new table was needed ??it would have tried to re-create existing tables and failed.Today nothing in the type system lets an author say "this SQL is data-only". A reviewer has to read every
raw_sqlbody and judge it by hand. That contract should be expressible as a type.What changed
1.
MigrationAction::DataMigration{ "type": "data_migration", "description": "set tier+kind on legacy rows only", "sql": "UPDATE \"user\" SET tier = 'PRO', kind = 'X' WHERE kind = 'legacy'" }sqlalso accepts a per-backend object for statements that cannot be portable:{ "type": "data_migration", "description": "text column -> JSON object keyed by locale", "sql": { "postgres": "UPDATE t SET j = jsonb_build_object('ko', c)", "mysql": "UPDATE t SET j = JSON_OBJECT('ko', c)", "sqlite": "UPDATE t SET j = json_object('ko', c)" } }All three backend keys are required in the per-backend form ??a missing key would silently emit nothing for that backend, which is the same class of silent loss this action exists to prevent.
descriptionis optional but encouraged; it is whatvespertide diffandvespertide logdisplay for the action.This fills a real gap. The existing backfill facilities are all coupled to a schema change and heavily constrained:
add_column.fill_withfill_withis set (add_column.rs:122). If the column has a default,fill_withis silently ignored. Only applies to a newly added column.modify_column_default.backfillWHERE??and coupled to changing the default.modify_column_type.fill_withSo a conditional backfill of existing columns, a correlated-subquery backfill, or data reshaping during a format change all required
raw_sqlbefore this PR.2. Verbatim emission
build_data_migrationpasses the statement through untouched ??no case folding, no cast rewriting, no reformatting. It deliberately bypasses every backend-normalising helper (convert_default_for_backend,normalize_fill_with, ??, and the module doc says so, so nobody "fixes the inconsistency" later. Snapshot evidence (identical on all three backends):Mixed-case keywords, quoted identifiers,
::casts, and multi-line formatting all survive.3. Replay contract
apply_actiongets its own explicitDataMigrationarm callingapply/data_migration.rs, kept separate fromapply/raw_sql.rsprecisely so the reason is documented and cannot be collapsed:4. DDL guard enforces the contract
A statement whose first token ??after trimming leading whitespace,
--line comments and/* */block comments ??isCREATE,ALTER,DROP, orTRUNCATE(case-insensitive) is rejected withPlannerError::DataMigrationContainsDdl. Matching requires a token boundary, soCREATED_AT_FIXUP()is fine andDROPLETis notDROP. Every branch of a per-backend form is checked, so DDL hidden in only thesqlitekey is still caught.The guard runs inside
validate_migration_plan, which the loader calls per file, so it fires at load time and therefore at plan time too. Real CLI output:(That input was
"-- just a tidy-up\n /* honest */ ALTER TABLE ..."??the comments are stripped before the check and before the quoted preview.)raw_sqlkeeps its DDL freedom; onlydata_migrationis constrained.5. The replay hazard is now visible
Previously
vespertide diffoutput was silently wrong with no hint at all ??which is how the 14 phantom changes went unnoticed.diffandstatusnow warn when the applied history containsraw_sql, naming the affected versions. Indiffit prints before the action list, since the list itself is what may be untrustworthy:That reproduction is the production bug in miniature:
nicknamewas already added by araw_sqlDDL action, so the reported change is a phantom. A history usingdata_migrationinstead produces no warning andNo differences found.6. Docs point users at the new action
The rustdoc on
MigrationAction::RawSql(and therefore itsdescriptioninschemas/migration.schema.json, which is generated from it) now explains the difference and points atdata_migrationfor data-only changes.Test evidence
cargo test --workspace --all-features??4433 passed, 0 failedcargo clippy --workspace --all-targets --all-features -- -D warnings??cleancargo fmt --all --check??cleansh scripts/check-line-budget.sh??cleanSchema drift (
git diff --no-index schemas _tmp_schemas) ??emptyCovering each stated requirement:
uniform_sql_is_emitted_byte_for_byteasserts byte equality against deliberately hostile SQL (mixed case,::cast, quoted identifier, newlines) across all 3 backends, plus snapshots.per_backend_sql_selects_the_matching_statementacross all 3 backends.data_migration_ddl.rs: 6 DML forms accepted (incl. correlated subquery and CTE), 7 DDL forms rejected with the offending keyword, DDL hidden in a single backend branch rejected,raw_sqlunaffected, and all offending actions reported rather than just the first. Plus a 22-caserstestonleading_ddl_keywordcovering comments, case, token boundaries and unterminated comments.apply_data_migration_leaves_schema_untouchedasserts the schema is unchanged for both wire forms.raw_sql_warning.rsunit tests on the rendered text pluscmd_diff_warns_when_history_contains_raw_sql,cmd_diff_stays_quiet_when_history_uses_data_migration, andcmd_status_warns_when_history_contains_raw_sql.descriptionusesskip_serializing_ifso omitting it round-trips unchanged.All of the CLI output quoted above was captured by running the built
vespertidebinary against a scratch project, not reasoned about.Note for the two sibling PRs
Two PRs are in flight on this repo touching
add_column.fill_withlowercasing andmodify_column_type.fill_withdouble-quoting. This PR touchesschemas/migration.schema.jsonand theMigrationActionenum, which they may also touch, so those edits were kept deliberately minimal:MigrationAction: the new variant is appended afterRawSql, at the end of the enum. No existing variant's fields were changed. The only edits to existing lines are the twoRawSqldoc-comment additions (requirement 5).schemas/migration.schema.json: regenerated, +59/??. The newDataMigrationSql$defand thedata_migrationentry are appended; the 2 changed lines are the twoRawSqldescriptions. Theadd_column.fill_withandmodify_column_type.fill_withschema regions are untouched, so conflicts should be trivial or absent.Also worth flagging for the
fill_withlowercasing PR: this action deliberately does not route through the normalising helpers, andbuild_data_migration's doc records that as intentional ??so it should not reintroduce that bug class.One unrelated line was required to compile:
vespertide-lsp'sErrorLocation::from_planner_errormatchesPlannerErrorexhaustively, so the new variant needed an arm. It returnsNone(adata_migrationDDL violation lives in a migration file, which the model-file locator cannot anchor to).Release / semver
This PR ships a changepack (
.changepacks/changepack_log_fIoUZOkWt-518L5MIjOim.json):vespertide-coreDataMigrationvariant (on a#[non_exhaustive]enum, so additive) + new publicDataMigrationSql/leading_ddl_keyword/sql_previewvespertide-plannerPlannerErroris not#[non_exhaustive], so the newDataMigrationContainsDdlvariant breaks exhaustive downstreammatches. Also addsfind_raw_sql_replay_hazardsvespertide-querysql::data_migrationmodule +build_data_migrationvespertide-cliraw_sqlreplay warning indiff/statusvespertide-lspErrorLocation::from_planner_errorFor 0.x crates
Minoris the breaking bump (0.2.1 -> 0.3.0), which is declared honestly here rather than merely conveniently: thePlannerErrorvariant addition genuinely is breaking.Worth a follow-up:
PlannerErroris the odd one out -MigrationAction,TableConstraint,QueryError,ColumnTypeand friends are all#[non_exhaustive]. MarkingPlannerError#[non_exhaustive]would make every future error variant additive, but it is itself a breaking change and touches a shared surface the two sibling PRs may also hit, so it is deliberately left out of this PR.Note the
cargo-semver-checksgate derives its release-type from the changepack this PR introduces (a descriptor already onmainmust not relax the gate). Without one it assumedminorand failed on breaking changes that were already merged tomain- notably thepub->pub(crate)narrowing ofsql::helpersinbca0bf6. Those are pre-existing and untouched here;crates/vespertide-query/src/sql/helpers.rsis byte-identical tomainon this branch.