From 9730c3ad5216d4c0220cc5bbf356dbc9f3c0c432 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 20 May 2026 16:02:06 +0900 Subject: [PATCH 01/32] Refactor --- .cargo/mutants.toml | 34 + .github/dependabot.yml | 60 + .github/workflows/CI.yml | 203 +- .github/workflows/bench.yml | 31 + .github/workflows/fuzz.yml | 64 + .github/workflows/mutants.yml | 49 + .gitignore | 77 +- AGENTS.md | 104 +- Cargo.lock | 1245 +++- Cargo.toml | 30 +- crates/vespertide-cli/AGENTS.md | 6 +- crates/vespertide-cli/Cargo.toml | 7 +- crates/vespertide-cli/src/commands/diff.rs | 67 +- crates/vespertide-cli/src/commands/export.rs | 14 +- crates/vespertide-cli/src/commands/new.rs | 24 +- .../vespertide-cli/src/commands/revision.rs | 3417 ----------- .../src/commands/revision/emit.rs | 255 + .../src/commands/revision/mod.rs | 161 + .../src/commands/revision/parse.rs | 27 + .../src/commands/revision/prompts.rs | 364 ++ .../src/commands/revision/tests.rs | 78 + .../revision/tests/delete_null_rows.rs | 394 ++ .../src/commands/revision/tests/fill_with.rs | 294 + .../commands/revision/tests/integration.rs | 322 + .../src/commands/revision/tests/prompts.rs | 762 +++ .../src/commands/revision/tests/recreate.rs | 720 +++ .../src/commands/revision/write.rs | 69 + crates/vespertide-cli/src/commands/sql.rs | 8 +- crates/vespertide-cli/src/commands/status.rs | 30 +- crates/vespertide-cli/src/utils.rs | 16 +- crates/vespertide-config/Cargo.toml | 6 + crates/vespertide-config/src/config.rs | 4 +- crates/vespertide-config/src/lib.rs | 7 +- crates/vespertide-config/src/name_case.rs | 2 +- crates/vespertide-core/AGENTS.md | 10 +- crates/vespertide-core/Cargo.toml | 15 + .../benches/normalize_benchmarks.rs | 81 + crates/vespertide-core/src/action.rs | 1270 ---- crates/vespertide-core/src/action/display.rs | 148 + crates/vespertide-core/src/action/mod.rs | 165 + crates/vespertide-core/src/action/prefix.rs | 150 + crates/vespertide-core/src/action/tests.rs | 897 +++ crates/vespertide-core/src/arbitrary/mod.rs | 430 ++ crates/vespertide-core/src/lib.rs | 14 +- crates/vespertide-core/src/migration.rs | 27 + crates/vespertide-core/src/schema/column.rs | 878 +-- .../src/schema/column_tests.rs | 697 +++ .../vespertide-core/src/schema/constraint.rs | 120 +- .../vespertide-core/src/schema/foreign_key.rs | 34 +- crates/vespertide-core/src/schema/index.rs | 14 + crates/vespertide-core/src/schema/mod.rs | 2 +- crates/vespertide-core/src/schema/names.rs | 15 + .../vespertide-core/src/schema/primary_key.rs | 17 + .../vespertide-core/src/schema/reference.rs | 17 +- .../vespertide-core/src/schema/str_or_bool.rs | 77 +- crates/vespertide-core/src/schema/table.rs | 1714 ------ .../vespertide-core/src/schema/table/mod.rs | 55 + .../src/schema/table/normalize.rs | 434 ++ .../src/schema/table/normalize_proptest.rs | 26 + .../vespertide-core/src/schema/table/tests.rs | 986 ++++ .../src/schema/table/tests/foreign_key.rs | 359 ++ .../src/schema/table/validation.rs | 54 + crates/vespertide-core/tests/utf8_safety.rs | 15 + crates/vespertide-exporter/AGENTS.md | 15 +- crates/vespertide-exporter/Cargo.toml | 13 +- .../benches/codegen_benchmarks.rs | 103 + crates/vespertide-exporter/src/jpa/mod.rs | 1223 +--- crates/vespertide-exporter/src/jpa/render.rs | 510 ++ ...edge_cases__snapshot_all_simple_types.snap | 75 + ...cases__snapshot_composite_primary_key.snap | 24 + ..._snapshot_composite_unique_constraint.snap | 25 + ...apshot_integer_enum_all_variant_types.snap | 37 + ...s__snapshot_reserved_word_identifiers.snap | 23 + ...e_cases__snapshot_self_referencing_fk.snap | 21 + crates/vespertide-exporter/src/jpa/tests.rs | 601 ++ .../src/jpa/tests_edge_cases.rs | 173 + crates/vespertide-exporter/src/jpa/types.rs | 107 + crates/vespertide-exporter/src/lib.rs | 3 +- .../vespertide-exporter/src/seaorm/enums.rs | 166 + .../src/seaorm/helper_tests.rs | 889 +++ .../helper_tests/reverse_relation_tests.rs | 248 + .../vespertide-exporter/src/seaorm/imports.rs | 160 + crates/vespertide-exporter/src/seaorm/mod.rs | 4414 +------------- .../src/seaorm/module_path_tests.rs | 214 + .../src/seaorm/relations.rs | 1009 ++++ .../vespertide-exporter/src/seaorm/render.rs | 334 ++ ...edge_cases__snapshot_all_simple_types.snap | 33 + ...cases__snapshot_composite_primary_key.snap | 19 + ..._snapshot_composite_unique_constraint.snap | 23 + ...apshot_integer_enum_all_variant_types.snap | 28 + ...s__snapshot_reserved_word_identifiers.snap | 18 + ...e_cases__snapshot_self_referencing_fk.snap | 19 + .../vespertide-exporter/src/seaorm/tests.rs | 990 ++++ .../src/seaorm/tests/misc_tests.rs | 502 ++ .../src/seaorm/tests_edge_cases.rs | 173 + .../vespertide-exporter/src/seaorm/types.rs | 88 + .../src/sqlalchemy/enums.rs | 22 + .../vespertide-exporter/src/sqlalchemy/mod.rs | 1517 +---- .../src/sqlalchemy/render.rs | 390 ++ ...sts__table_with_composite_foreign_key.snap | 22 + ...edge_cases__snapshot_all_simple_types.snap | 36 + ...cases__snapshot_composite_primary_key.snap | 17 + ..._snapshot_composite_unique_constraint.snap | 21 + ...apshot_integer_enum_all_variant_types.snap | 23 + ...s__snapshot_reserved_word_identifiers.snap | 17 + ...e_cases__snapshot_self_referencing_fk.snap | 17 + .../src/sqlalchemy/tests.rs | 988 ++++ .../src/sqlalchemy/tests_edge_cases.rs | 192 + .../src/sqlalchemy/types.rs | 202 + .../vespertide-exporter/src/sqlmodel/enums.rs | 54 + .../vespertide-exporter/src/sqlmodel/mod.rs | 1381 +---- .../src/sqlmodel/render.rs | 391 ++ ...sts__table_with_composite_foreign_key.snap | 22 + ...edge_cases__snapshot_all_simple_types.snap | 35 + ...cases__snapshot_composite_primary_key.snap | 16 + ..._snapshot_composite_unique_constraint.snap | 21 + ...apshot_integer_enum_all_variant_types.snap | 22 + ...s__snapshot_reserved_word_identifiers.snap | 16 + ...e_cases__snapshot_self_referencing_fk.snap | 16 + .../vespertide-exporter/src/sqlmodel/tests.rs | 908 +++ .../src/sqlmodel/tests_edge_cases.rs | 192 + .../vespertide-exporter/src/sqlmodel/types.rs | 101 + crates/vespertide-exporter/src/utils/mod.rs | 17 + .../vespertide-exporter/src/utils/python.rs | 28 + .../tests/codegen_determinism.rs | 112 + crates/vespertide-loader/Cargo.toml | 6 + crates/vespertide-loader/src/config.rs | 1 + crates/vespertide-loader/src/lib.rs | 10 + crates/vespertide-loader/src/migrations.rs | 58 +- crates/vespertide-loader/src/models.rs | 24 +- crates/vespertide-macro/Cargo.toml | 12 +- crates/vespertide-macro/src/lib.rs | 1023 +--- crates/vespertide-macro/src/tests.rs | 863 +++ crates/vespertide-macro/tests/ui.rs | 6 + .../tests/ui/unsupported_option_fail.rs | 6 + .../tests/ui/unsupported_option_fail.stderr | 5 + crates/vespertide-naming/Cargo.toml | 14 + .../benches/naming_benchmarks.rs | 52 + crates/vespertide-naming/src/lib.rs | 143 +- .../tests/naming_proptest.rs | 96 + crates/vespertide-planner/AGENTS.md | 13 +- crates/vespertide-planner/Cargo.toml | 13 + .../benches/diff_benchmarks.rs | 111 + crates/vespertide-planner/src/apply.rs | 1653 ------ .../src/apply/column_ops.rs | 193 + .../src/apply/constraint_ops.rs | 153 + crates/vespertide-planner/src/apply/mod.rs | 75 + .../vespertide-planner/src/apply/raw_sql.rs | 5 + .../vespertide-planner/src/apply/table_ops.rs | 67 + crates/vespertide-planner/src/apply/tests.rs | 879 +++ .../src/apply/tests/more.rs | 476 ++ crates/vespertide-planner/src/diff.rs | 5206 ----------------- crates/vespertide-planner/src/diff/columns.rs | 180 + .../src/diff/constraints.rs | 170 + crates/vespertide-planner/src/diff/mod.rs | 70 + .../vespertide-planner/src/diff/ordering.rs | 409 ++ crates/vespertide-planner/src/diff/tables.rs | 66 + .../src/diff/tests/basic.rs | 129 + .../src/diff/tests/column_changes.rs | 797 +++ .../src/diff/tests/constraint_performance.rs | 43 + .../src/diff/tests/constraint_removal.rs | 408 ++ .../src/diff/tests/coverage.rs | 211 + .../src/diff/tests/diff_tables.rs | 271 + .../src/diff/tests/enums.rs | 821 +++ .../src/diff/tests/fk_ordering.rs | 872 +++ .../src/diff/tests/inline_constraints.rs | 463 ++ .../vespertide-planner/src/diff/tests/mod.rs | 59 + .../src/diff/tests/ordering_sort.rs | 191 + .../src/diff/tests/primary_key_changes.rs | 250 + ...les__create_table_with_inline_index-2.snap | 15 + ...ables__create_table_with_inline_index.snap | 54 + ...s__diff_tables__diff_tables@add_index.snap | 15 + ...f_tables__diff_tables@add_named_index.snap | 17 + ...diff_tables__diff_tables@remove_index.snap | 15 + ...ables__diff_tables@remove_named_index.snap | 17 + crates/vespertide-planner/src/error.rs | 3 +- crates/vespertide-planner/src/lib.rs | 6 + crates/vespertide-planner/src/schema.rs | 2 +- crates/vespertide-planner/src/validate.rs | 2466 -------- .../vespertide-planner/src/validate/enums.rs | 149 + .../src/validate/foreign_keys.rs | 69 + crates/vespertide-planner/src/validate/mod.rs | 13 + .../vespertide-planner/src/validate/plan.rs | 274 + .../vespertide-planner/src/validate/schema.rs | 199 + .../vespertide-planner/src/validate/tests.rs | 89 + .../src/validate/tests/enum_fill_with.rs | 275 + .../src/validate/tests/fill_with.rs | 417 ++ .../src/validate/tests/plan_validation.rs | 954 +++ .../src/validate/tests/schema_cases.rs | 348 ++ .../vespertide-planner/tests/diff_proptest.rs | 180 + crates/vespertide-query/AGENTS.md | 24 +- crates/vespertide-query/Cargo.toml | 24 + .../benches/sql_benchmarks.rs | 129 + crates/vespertide-query/src/builder.rs | 164 +- crates/vespertide-query/src/lib.rs | 10 +- crates/vespertide-query/src/sql/add_column.rs | 57 +- .../src/sql/add_constraint.rs | 1428 ----- .../src/sql/add_constraint/check.rs | 40 + .../src/sql/add_constraint/foreign_key.rs | 49 + .../src/sql/add_constraint/index.rs | 15 + .../src/sql/add_constraint/mod.rs | 172 + .../src/sql/add_constraint/primary_key.rs | 35 + .../src/sql/add_constraint/tests.rs | 811 +++ .../sql/add_constraint/tests_foreign_key.rs | 209 + .../src/sql/add_constraint/unique.rs | 17 + .../vespertide-query/src/sql/create_table.rs | 334 +- .../vespertide-query/src/sql/delete_column.rs | 1201 ---- .../src/sql/delete_column/direct.rs | 35 + .../src/sql/delete_column/mod.rs | 132 + .../src/sql/delete_column/sqlite_rebuild.rs | 101 + .../src/sql/delete_column/tests.rs | 954 +++ .../vespertide-query/src/sql/delete_table.rs | 4 +- crates/vespertide-query/src/sql/helpers.rs | 857 +-- .../vespertide-query/src/sql/helpers_tests.rs | 503 ++ crates/vespertide-query/src/sql/mod.rs | 1483 +---- .../src/sql/modify_column_comment.rs | 50 +- .../src/sql/modify_column_default.rs | 70 +- .../src/sql/modify_column_nullable.rs | 100 +- .../src/sql/modify_column_type.rs | 104 +- crates/vespertide-query/src/sql/raw_sql.rs | 14 +- .../src/sql/remove_constraint.rs | 1572 ----- .../src/sql/remove_constraint/mod.rs | 66 + .../src/sql/remove_constraint/mysql.rs | 71 + .../src/sql/remove_constraint/postgres.rs | 66 + .../src/sql/remove_constraint/sqlite.rs | 164 + .../src/sql/remove_constraint/tests.rs | 438 ++ .../vespertide-query/src/sql/rename_column.rs | 4 +- .../vespertide-query/src/sql/rename_table.rs | 4 +- .../src/sql/replace_constraint.rs | 142 +- ...ble_with_timestamp_now_default_Sqlite.snap | 2 +- ..._to_current_timestamp@sqlite_fill_now.snap | 2 +- ...ault@create_table_func_default_Sqlite.snap | 4 +- crates/vespertide-query/src/sql/tests.rs | 24 + .../src/sql/tests/dispatch.rs | 767 +++ .../vespertide-query/src/sql/tests/naming.rs | 614 ++ crates/vespertide-query/src/sql/types.rs | 4 +- .../tests/composite_unique_test.rs | 8 +- .../tests/enum_migration_test.rs | 15 +- .../sql_dialect_parse.proptest-regressions | 10 + .../tests/sql_dialect_parse.rs | 159 + crates/vespertide-query/tests/sql_pg_query.rs | 84 + crates/vespertide-query/tests/sql_proptest.rs | 245 + .../sql_sqlite_exec.proptest-regressions | 11 + .../vespertide-query/tests/sql_sqlite_exec.rs | 134 + .../tests/table_prefixed_enum_test.rs | 14 +- .../tests/transactional_plan_test.rs | 93 + crates/vespertide-schema-gen/Cargo.toml | 4 +- crates/vespertide-schema-gen/src/main.rs | 20 +- crates/vespertide/Cargo.toml | 10 +- crates/vespertide/src/lib.rs | 9 + crates/vespertide/src/runtime.rs | 142 +- .../tests/runtime_database_error.rs | 37 + deny.toml | 40 + examples/app/Cargo.toml | 7 +- fuzz/.gitignore | 5 + fuzz/Cargo.toml | 41 + fuzz/corpus/fuzz_migration_apply/.gitkeep | 0 fuzz/corpus/fuzz_model_deser/.gitkeep | 0 fuzz/corpus/fuzz_sql_identifier/.gitkeep | 0 fuzz/fuzz_targets/fuzz_migration_apply.rs | 28 + fuzz/fuzz_targets/fuzz_model_deser.rs | 26 + fuzz/fuzz_targets/fuzz_sql_identifier.rs | 33 + rust-toolchain.toml | 8 + scripts/check-line-budget.sh | 31 + 264 files changed, 40144 insertions(+), 33038 deletions(-) create mode 100644 .cargo/mutants.toml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/bench.yml create mode 100644 .github/workflows/fuzz.yml create mode 100644 .github/workflows/mutants.yml delete mode 100644 crates/vespertide-cli/src/commands/revision.rs create mode 100644 crates/vespertide-cli/src/commands/revision/emit.rs create mode 100644 crates/vespertide-cli/src/commands/revision/mod.rs create mode 100644 crates/vespertide-cli/src/commands/revision/parse.rs create mode 100644 crates/vespertide-cli/src/commands/revision/prompts.rs create mode 100644 crates/vespertide-cli/src/commands/revision/tests.rs create mode 100644 crates/vespertide-cli/src/commands/revision/tests/delete_null_rows.rs create mode 100644 crates/vespertide-cli/src/commands/revision/tests/fill_with.rs create mode 100644 crates/vespertide-cli/src/commands/revision/tests/integration.rs create mode 100644 crates/vespertide-cli/src/commands/revision/tests/prompts.rs create mode 100644 crates/vespertide-cli/src/commands/revision/tests/recreate.rs create mode 100644 crates/vespertide-cli/src/commands/revision/write.rs create mode 100644 crates/vespertide-core/benches/normalize_benchmarks.rs delete mode 100644 crates/vespertide-core/src/action.rs create mode 100644 crates/vespertide-core/src/action/display.rs create mode 100644 crates/vespertide-core/src/action/mod.rs create mode 100644 crates/vespertide-core/src/action/prefix.rs create mode 100644 crates/vespertide-core/src/action/tests.rs create mode 100644 crates/vespertide-core/src/arbitrary/mod.rs create mode 100644 crates/vespertide-core/src/schema/column_tests.rs delete mode 100644 crates/vespertide-core/src/schema/table.rs create mode 100644 crates/vespertide-core/src/schema/table/mod.rs create mode 100644 crates/vespertide-core/src/schema/table/normalize.rs create mode 100644 crates/vespertide-core/src/schema/table/normalize_proptest.rs create mode 100644 crates/vespertide-core/src/schema/table/tests.rs create mode 100644 crates/vespertide-core/src/schema/table/tests/foreign_key.rs create mode 100644 crates/vespertide-core/src/schema/table/validation.rs create mode 100644 crates/vespertide-core/tests/utf8_safety.rs create mode 100644 crates/vespertide-exporter/benches/codegen_benchmarks.rs create mode 100644 crates/vespertide-exporter/src/jpa/render.rs create mode 100644 crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests_edge_cases__snapshot_all_simple_types.snap create mode 100644 crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests_edge_cases__snapshot_composite_primary_key.snap create mode 100644 crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests_edge_cases__snapshot_composite_unique_constraint.snap create mode 100644 crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests_edge_cases__snapshot_integer_enum_all_variant_types.snap create mode 100644 crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests_edge_cases__snapshot_reserved_word_identifiers.snap create mode 100644 crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests_edge_cases__snapshot_self_referencing_fk.snap create mode 100644 crates/vespertide-exporter/src/jpa/tests.rs create mode 100644 crates/vespertide-exporter/src/jpa/tests_edge_cases.rs create mode 100644 crates/vespertide-exporter/src/jpa/types.rs create mode 100644 crates/vespertide-exporter/src/seaorm/enums.rs create mode 100644 crates/vespertide-exporter/src/seaorm/helper_tests.rs create mode 100644 crates/vespertide-exporter/src/seaorm/helper_tests/reverse_relation_tests.rs create mode 100644 crates/vespertide-exporter/src/seaorm/imports.rs create mode 100644 crates/vespertide-exporter/src/seaorm/module_path_tests.rs create mode 100644 crates/vespertide-exporter/src/seaorm/relations.rs create mode 100644 crates/vespertide-exporter/src/seaorm/render.rs create mode 100644 crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests_edge_cases__snapshot_all_simple_types.snap create mode 100644 crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests_edge_cases__snapshot_composite_primary_key.snap create mode 100644 crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests_edge_cases__snapshot_composite_unique_constraint.snap create mode 100644 crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests_edge_cases__snapshot_integer_enum_all_variant_types.snap create mode 100644 crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests_edge_cases__snapshot_reserved_word_identifiers.snap create mode 100644 crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests_edge_cases__snapshot_self_referencing_fk.snap create mode 100644 crates/vespertide-exporter/src/seaorm/tests.rs create mode 100644 crates/vespertide-exporter/src/seaorm/tests/misc_tests.rs create mode 100644 crates/vespertide-exporter/src/seaorm/tests_edge_cases.rs create mode 100644 crates/vespertide-exporter/src/seaorm/types.rs create mode 100644 crates/vespertide-exporter/src/sqlalchemy/enums.rs create mode 100644 crates/vespertide-exporter/src/sqlalchemy/render.rs create mode 100644 crates/vespertide-exporter/src/sqlalchemy/snapshots/vespertide_exporter__sqlalchemy__tests__table_with_composite_foreign_key.snap create mode 100644 crates/vespertide-exporter/src/sqlalchemy/snapshots/vespertide_exporter__sqlalchemy__tests_edge_cases__snapshot_all_simple_types.snap create mode 100644 crates/vespertide-exporter/src/sqlalchemy/snapshots/vespertide_exporter__sqlalchemy__tests_edge_cases__snapshot_composite_primary_key.snap create mode 100644 crates/vespertide-exporter/src/sqlalchemy/snapshots/vespertide_exporter__sqlalchemy__tests_edge_cases__snapshot_composite_unique_constraint.snap create mode 100644 crates/vespertide-exporter/src/sqlalchemy/snapshots/vespertide_exporter__sqlalchemy__tests_edge_cases__snapshot_integer_enum_all_variant_types.snap create mode 100644 crates/vespertide-exporter/src/sqlalchemy/snapshots/vespertide_exporter__sqlalchemy__tests_edge_cases__snapshot_reserved_word_identifiers.snap create mode 100644 crates/vespertide-exporter/src/sqlalchemy/snapshots/vespertide_exporter__sqlalchemy__tests_edge_cases__snapshot_self_referencing_fk.snap create mode 100644 crates/vespertide-exporter/src/sqlalchemy/tests.rs create mode 100644 crates/vespertide-exporter/src/sqlalchemy/tests_edge_cases.rs create mode 100644 crates/vespertide-exporter/src/sqlalchemy/types.rs create mode 100644 crates/vespertide-exporter/src/sqlmodel/enums.rs create mode 100644 crates/vespertide-exporter/src/sqlmodel/render.rs create mode 100644 crates/vespertide-exporter/src/sqlmodel/snapshots/vespertide_exporter__sqlmodel__tests__table_with_composite_foreign_key.snap create mode 100644 crates/vespertide-exporter/src/sqlmodel/snapshots/vespertide_exporter__sqlmodel__tests_edge_cases__snapshot_all_simple_types.snap create mode 100644 crates/vespertide-exporter/src/sqlmodel/snapshots/vespertide_exporter__sqlmodel__tests_edge_cases__snapshot_composite_primary_key.snap create mode 100644 crates/vespertide-exporter/src/sqlmodel/snapshots/vespertide_exporter__sqlmodel__tests_edge_cases__snapshot_composite_unique_constraint.snap create mode 100644 crates/vespertide-exporter/src/sqlmodel/snapshots/vespertide_exporter__sqlmodel__tests_edge_cases__snapshot_integer_enum_all_variant_types.snap create mode 100644 crates/vespertide-exporter/src/sqlmodel/snapshots/vespertide_exporter__sqlmodel__tests_edge_cases__snapshot_reserved_word_identifiers.snap create mode 100644 crates/vespertide-exporter/src/sqlmodel/snapshots/vespertide_exporter__sqlmodel__tests_edge_cases__snapshot_self_referencing_fk.snap create mode 100644 crates/vespertide-exporter/src/sqlmodel/tests.rs create mode 100644 crates/vespertide-exporter/src/sqlmodel/tests_edge_cases.rs create mode 100644 crates/vespertide-exporter/src/sqlmodel/types.rs create mode 100644 crates/vespertide-exporter/src/utils/mod.rs create mode 100644 crates/vespertide-exporter/src/utils/python.rs create mode 100644 crates/vespertide-exporter/tests/codegen_determinism.rs create mode 100644 crates/vespertide-macro/src/tests.rs create mode 100644 crates/vespertide-macro/tests/ui.rs create mode 100644 crates/vespertide-macro/tests/ui/unsupported_option_fail.rs create mode 100644 crates/vespertide-macro/tests/ui/unsupported_option_fail.stderr create mode 100644 crates/vespertide-naming/benches/naming_benchmarks.rs create mode 100644 crates/vespertide-naming/tests/naming_proptest.rs create mode 100644 crates/vespertide-planner/benches/diff_benchmarks.rs delete mode 100644 crates/vespertide-planner/src/apply.rs create mode 100644 crates/vespertide-planner/src/apply/column_ops.rs create mode 100644 crates/vespertide-planner/src/apply/constraint_ops.rs create mode 100644 crates/vespertide-planner/src/apply/mod.rs create mode 100644 crates/vespertide-planner/src/apply/raw_sql.rs create mode 100644 crates/vespertide-planner/src/apply/table_ops.rs create mode 100644 crates/vespertide-planner/src/apply/tests.rs create mode 100644 crates/vespertide-planner/src/apply/tests/more.rs delete mode 100644 crates/vespertide-planner/src/diff.rs create mode 100644 crates/vespertide-planner/src/diff/columns.rs create mode 100644 crates/vespertide-planner/src/diff/constraints.rs create mode 100644 crates/vespertide-planner/src/diff/mod.rs create mode 100644 crates/vespertide-planner/src/diff/ordering.rs create mode 100644 crates/vespertide-planner/src/diff/tables.rs create mode 100644 crates/vespertide-planner/src/diff/tests/basic.rs create mode 100644 crates/vespertide-planner/src/diff/tests/column_changes.rs create mode 100644 crates/vespertide-planner/src/diff/tests/constraint_performance.rs create mode 100644 crates/vespertide-planner/src/diff/tests/constraint_removal.rs create mode 100644 crates/vespertide-planner/src/diff/tests/coverage.rs create mode 100644 crates/vespertide-planner/src/diff/tests/diff_tables.rs create mode 100644 crates/vespertide-planner/src/diff/tests/enums.rs create mode 100644 crates/vespertide-planner/src/diff/tests/fk_ordering.rs create mode 100644 crates/vespertide-planner/src/diff/tests/inline_constraints.rs create mode 100644 crates/vespertide-planner/src/diff/tests/mod.rs create mode 100644 crates/vespertide-planner/src/diff/tests/ordering_sort.rs create mode 100644 crates/vespertide-planner/src/diff/tests/primary_key_changes.rs create mode 100644 crates/vespertide-planner/src/diff/tests/snapshots/vespertide_planner__diff__tests__diff_tables__create_table_with_inline_index-2.snap create mode 100644 crates/vespertide-planner/src/diff/tests/snapshots/vespertide_planner__diff__tests__diff_tables__create_table_with_inline_index.snap create mode 100644 crates/vespertide-planner/src/diff/tests/snapshots/vespertide_planner__diff__tests__diff_tables__diff_tables@add_index.snap create mode 100644 crates/vespertide-planner/src/diff/tests/snapshots/vespertide_planner__diff__tests__diff_tables__diff_tables@add_named_index.snap create mode 100644 crates/vespertide-planner/src/diff/tests/snapshots/vespertide_planner__diff__tests__diff_tables__diff_tables@remove_index.snap create mode 100644 crates/vespertide-planner/src/diff/tests/snapshots/vespertide_planner__diff__tests__diff_tables__diff_tables@remove_named_index.snap delete mode 100644 crates/vespertide-planner/src/validate.rs create mode 100644 crates/vespertide-planner/src/validate/enums.rs create mode 100644 crates/vespertide-planner/src/validate/foreign_keys.rs create mode 100644 crates/vespertide-planner/src/validate/mod.rs create mode 100644 crates/vespertide-planner/src/validate/plan.rs create mode 100644 crates/vespertide-planner/src/validate/schema.rs create mode 100644 crates/vespertide-planner/src/validate/tests.rs create mode 100644 crates/vespertide-planner/src/validate/tests/enum_fill_with.rs create mode 100644 crates/vespertide-planner/src/validate/tests/fill_with.rs create mode 100644 crates/vespertide-planner/src/validate/tests/plan_validation.rs create mode 100644 crates/vespertide-planner/src/validate/tests/schema_cases.rs create mode 100644 crates/vespertide-planner/tests/diff_proptest.rs create mode 100644 crates/vespertide-query/benches/sql_benchmarks.rs delete mode 100644 crates/vespertide-query/src/sql/add_constraint.rs create mode 100644 crates/vespertide-query/src/sql/add_constraint/check.rs create mode 100644 crates/vespertide-query/src/sql/add_constraint/foreign_key.rs create mode 100644 crates/vespertide-query/src/sql/add_constraint/index.rs create mode 100644 crates/vespertide-query/src/sql/add_constraint/mod.rs create mode 100644 crates/vespertide-query/src/sql/add_constraint/primary_key.rs create mode 100644 crates/vespertide-query/src/sql/add_constraint/tests.rs create mode 100644 crates/vespertide-query/src/sql/add_constraint/tests_foreign_key.rs create mode 100644 crates/vespertide-query/src/sql/add_constraint/unique.rs delete mode 100644 crates/vespertide-query/src/sql/delete_column.rs create mode 100644 crates/vespertide-query/src/sql/delete_column/direct.rs create mode 100644 crates/vespertide-query/src/sql/delete_column/mod.rs create mode 100644 crates/vespertide-query/src/sql/delete_column/sqlite_rebuild.rs create mode 100644 crates/vespertide-query/src/sql/delete_column/tests.rs create mode 100644 crates/vespertide-query/src/sql/helpers_tests.rs delete mode 100644 crates/vespertide-query/src/sql/remove_constraint.rs create mode 100644 crates/vespertide-query/src/sql/remove_constraint/mod.rs create mode 100644 crates/vespertide-query/src/sql/remove_constraint/mysql.rs create mode 100644 crates/vespertide-query/src/sql/remove_constraint/postgres.rs create mode 100644 crates/vespertide-query/src/sql/remove_constraint/sqlite.rs create mode 100644 crates/vespertide-query/src/sql/remove_constraint/tests.rs create mode 100644 crates/vespertide-query/src/sql/tests.rs create mode 100644 crates/vespertide-query/src/sql/tests/dispatch.rs create mode 100644 crates/vespertide-query/src/sql/tests/naming.rs create mode 100644 crates/vespertide-query/tests/sql_dialect_parse.proptest-regressions create mode 100644 crates/vespertide-query/tests/sql_dialect_parse.rs create mode 100644 crates/vespertide-query/tests/sql_pg_query.rs create mode 100644 crates/vespertide-query/tests/sql_proptest.rs create mode 100644 crates/vespertide-query/tests/sql_sqlite_exec.proptest-regressions create mode 100644 crates/vespertide-query/tests/sql_sqlite_exec.rs create mode 100644 crates/vespertide-query/tests/transactional_plan_test.rs create mode 100644 crates/vespertide/tests/runtime_database_error.rs create mode 100644 deny.toml create mode 100644 fuzz/.gitignore create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/corpus/fuzz_migration_apply/.gitkeep create mode 100644 fuzz/corpus/fuzz_model_deser/.gitkeep create mode 100644 fuzz/corpus/fuzz_sql_identifier/.gitkeep create mode 100644 fuzz/fuzz_targets/fuzz_migration_apply.rs create mode 100644 fuzz/fuzz_targets/fuzz_model_deser.rs create mode 100644 fuzz/fuzz_targets/fuzz_sql_identifier.rs create mode 100644 rust-toolchain.toml create mode 100644 scripts/check-line-budget.sh diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml new file mode 100644 index 00000000..9a637ffb --- /dev/null +++ b/.cargo/mutants.toml @@ -0,0 +1,34 @@ +# cargo-mutants configuration for vespertide. +# Mutation testing complements unit/property tests by mutating source AST +# and verifying tests FAIL. Survived mutants = test gap. +# +# Run locally: +# cargo install --locked cargo-mutants +# cargo mutants --in-place --timeout-multiplier 3.0 -vV +# +# Run on changed lines only (used by PR CI): +# cargo mutants --in-diff git.diff --in-place + +# Skip patterns that produce noise (Debug/Display/From impls, generated boilerplate) +exclude_re = [ + "impl Debug", + "impl Display", + "impl From<", + "impl std::error::Error", + "fn fmt\\(", + # RawSql is an opaque escape hatch by design + "MigrationAction::RawSql", +] + +# Focus on logic-heavy crates. Exclude: +# - schema-gen: tool, not library +# - macro: proc-macro coverage handled via trybuild +# - exporter: snapshot tests catch most mutations trivially (output diff) +exclude_globs = [ + "crates/vespertide-schema-gen/**", + "crates/vespertide-macro/**", + "crates/vespertide-exporter/**", +] + +# 3× baseline test time per mutant (handles proptest variance) +timeout_multiplier = 3.0 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..a1f0ffe6 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,60 @@ +# Dependabot configuration for vespertide. +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +# +# Keeps GitHub Actions and Cargo dependencies up to date. +# `changepacks/action@main` is grouped under `ignore` per project policy +# (org-internal action intentionally tracks main). + +version: 2 +updates: + # GitHub Actions: actions are pinned to major version tags (e.g. `@v6`), + # so minor/patch updates within a major are picked up automatically by the + # runner. Dependabot only proposes MAJOR-version bumps for review. + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Asia/Seoul" + open-pull-requests-limit: 5 + groups: + github-actions-major: + applies-to: version-updates + update-types: + - "major" + patterns: + - "*" + ignore: + # Internal action intentionally pinned to main branch. + - dependency-name: "changepacks/action" + # Skip non-major updates: major tags auto-pick patches/minors. + - dependency-name: "*" + update-types: ["version-update:semver-minor", "version-update:semver-patch"] + + # Cargo dependencies: weekly minor/patch updates, group by ecosystem. + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Asia/Seoul" + open-pull-requests-limit: 10 + groups: + cargo-patches: + applies-to: version-updates + update-types: + - "patch" + patterns: + - "*" + cargo-minor: + applies-to: version-updates + update-types: + - "minor" + patterns: + - "*" + ignore: + # sea-orm is pinned to RC; manual updates only. + - dependency-name: "sea-orm" + update-types: ["version-update:semver-major"] diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 1bcdf3b8..e5a3cbae 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -1,3 +1,10 @@ +# AGENTS.md policy: Every `.rs` file must stay ≤ 1000 lines. +# +# GitHub Actions pinning policy: third-party actions use major version tags +# only (e.g. `@v6`, not `@v6.0.2`) so security patches and bug fixes within +# the same major are picked up automatically. Dependabot proposes only +# major bumps (see `.github/dependabot.yml`). The `changepacks/action@main` +# reference is intentionally kept on `main` per project policy. name: CI on: @@ -5,9 +12,9 @@ on: branches: - main paths-ignore: - - '**/*.md' + - "**/*.md" - LICENSE - - '**/*.gitignore' + - "**/*.gitignore" - .editorconfig pull_request: workflow_dispatch: @@ -17,52 +24,99 @@ concurrency: cancel-in-progress: true jobs: - test: - name: Test + fmt: + name: fmt runs-on: ubuntu-latest - container: - image: xd009642/tarpaulin:develop-nightly - options: --security-opt seccomp=unconfined steps: - - uses: actions/checkout@v5 - - uses: oven-sh/setup-bun@v2 + - uses: actions/checkout@v6 + - uses: actions-rust-lang/setup-rust-toolchain@v1 with: - bun-version: latest + components: rustfmt + - name: Check formatting + run: cargo fmt --all -- --check + + clippy: + name: clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 - uses: actions-rust-lang/setup-rust-toolchain@v1 - - name: Install - uses: dtolnay/rust-toolchain@stable with: - components: clippy, rustfmt - - name: Build - run: cargo check + components: clippy - name: Lint - run: cargo clippy --all-targets --all-features -- -D warnings && cargo fmt --check + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + + test: + name: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions-rust-lang/setup-rust-toolchain@v1 - name: Test - run: | - # rust coverage issue - echo 'max_width = 100000' > .rustfmt.toml - echo 'tab_spaces = 4' >> .rustfmt.toml - echo 'newline_style = "Unix"' >> .rustfmt.toml - echo 'fn_call_width = 100000' >> .rustfmt.toml - echo 'fn_params_layout = "Compressed"' >> .rustfmt.toml - echo 'chain_width = 100000' >> .rustfmt.toml - echo 'merge_derives = true' >> .rustfmt.toml - echo 'use_small_heuristics = "Default"' >> .rustfmt.toml - cargo fmt - cargo tarpaulin --engine llvm --out Lcov Stdout --workspace --exclude app - - name: Upload to codecov.io - uses: codecov/codecov-action@v5 + run: cargo test --workspace --all-features + + # SQL validity gates: daemon-free real-engine and parser-level validation. + # - SQLite: in-memory execution via rusqlite (bundled = static link) + # - PG/MySQL/SQLite syntax: sqlparser-rs pure Rust 3-dialect parser + # - PG strict: pg_query = PG's real C parser (FFI, Linux/macOS only) + # No Docker, no daemon, no service container — runs on plain ubuntu-latest. + sql-validity: + name: SQL validity (daemon-free) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Install build-essential for pg_query (PG C parser) + run: sudo apt-get update && sudo apt-get install -y build-essential libreadline-dev zlib1g-dev flex bison + - name: Run SQLite in-memory exec property test + run: cargo test -p vespertide-query --test sql_sqlite_exec --release + - name: Run sqlparser 3-dialect parse property test + run: cargo test -p vespertide-query --test sql_dialect_parse --release + - name: Run pg_query (real PG parser) property test + run: cargo test -p vespertide-query --test sql_pg_query --release + + # cargo-deny enforces license/advisory/multiple-version policy; cargo-semver-checks blocks accidental semver-major API changes. + deny: + name: cargo-deny + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - uses: EmbarkStudios/cargo-deny-action@v2 with: - token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: true - files: lcov.info - if: github.ref == 'refs/heads/main' + command: check all + + semver-checks: + name: cargo-semver-checks + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - uses: obi1kenobi/cargo-semver-checks-action@v2 + with: + # Only check published crates; skip cli (binary) and schema-gen (publish=false) + package: vespertide,vespertide-core,vespertide-config,vespertide-loader,vespertide-naming,vespertide-planner,vespertide-query,vespertide-exporter,vespertide-macro + feature-group: default-features # publish changepacks: name: changepacks runs-on: ubuntu-latest - needs: test + needs: + - fmt + - clippy + - test + - sql-validity + - doc + - schema-drift + - insta-pending + - line-budget + - coverage + - deny + - semver-checks permissions: # create pull request comments pull-requests: write @@ -71,10 +125,87 @@ jobs: # Create brench to create pull request contents: write steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 + # changepacks/action@main: project-internal action intentionally tracks main. - uses: changepacks/action@main id: changepacks with: publish: true env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + doc: + name: doc + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Check docs + run: RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --workspace + + schema-drift: + name: schema-drift + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Regenerate schemas + run: cargo run -p vespertide-schema-gen -- --out _tmp_schemas + - name: Check schema drift + run: git diff --no-index --exit-code -- schemas _tmp_schemas + + insta-pending: + name: insta-pending + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Run exporter snapshots + run: cargo test -p vespertide-exporter + - name: Fail on pending snapshots + run: | + pending=$(find . -name '*.snap.new' -type f -print) + if [ -n "$pending" ]; then + printf '%s\n' "Pending insta snapshots:" "$pending" + exit 1 + fi + + line-budget: + name: line-budget + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Check Rust line budget + run: sh scripts/check-line-budget.sh + + coverage: + name: coverage + runs-on: ubuntu-latest + container: + image: xd009642/tarpaulin:develop-nightly + options: --security-opt seccomp=unconfined + steps: + - uses: actions/checkout@v6 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Coverage + run: | + # rust coverage issue + echo 'max_width = 100000' > .rustfmt.toml + echo 'tab_spaces = 4' >> .rustfmt.toml + echo 'newline_style = "Unix"' >> .rustfmt.toml + echo 'fn_call_width = 100000' >> .rustfmt.toml + echo 'fn_params_layout = "Compressed"' >> .rustfmt.toml + echo 'chain_width = 100000' >> .rustfmt.toml + echo 'merge_derives = true' >> .rustfmt.toml + echo 'use_small_heuristics = "Default"' >> .rustfmt.toml + cargo fmt + cargo tarpaulin --engine llvm --out Lcov Stdout --workspace --exclude app + - name: Upload to codecov.io + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: true + files: lcov.info + if: github.ref == 'refs/heads/main' diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml new file mode 100644 index 00000000..6f40a0b4 --- /dev/null +++ b/.github/workflows/bench.yml @@ -0,0 +1,31 @@ +name: benchmarks + +on: + pull_request: + paths: + - "crates/**/*.rs" + - "crates/**/benches/**" + - "crates/**/Cargo.toml" + workflow_dispatch: + +jobs: + bench: + name: criterion (informational) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Compile benchmarks + run: cargo bench --workspace --no-run + + - name: Run benchmarks (informational) + run: cargo bench --workspace -- --output-format bencher | tee bench-results.txt + + - name: Upload results + uses: actions/upload-artifact@v7 + with: + name: bench-results + path: bench-results.txt diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 00000000..a06b23b5 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,64 @@ +name: fuzz + +on: + schedule: + - cron: '0 19 * * *' # nightly 04:00 KST (= 19:00 UTC prior day) + workflow_dispatch: + inputs: + target: + description: 'Single fuzz target name (default: all)' + default: 'all' + duration_seconds: + description: 'Per-target fuzz duration in seconds' + default: '300' + +jobs: + fuzz: + name: cargo-fuzz + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + target: + - fuzz_model_deser + - fuzz_sql_identifier + - fuzz_migration_apply + steps: + - uses: actions/checkout@v6 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: nightly + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-fuzz + + - name: Cache corpus + uses: actions/cache@v4 + with: + path: fuzz/corpus/${{ matrix.target }} + key: fuzz-corpus-${{ matrix.target }}-${{ github.sha }} + restore-keys: | + fuzz-corpus-${{ matrix.target }}- + + - name: Run cargo-fuzz (${{ matrix.target }}, 300s) + run: | + cd fuzz + cargo +nightly fuzz run ${{ matrix.target }} \ + -- -max_total_time=300 -max_len=4096 + + - name: Upload crash artifacts + if: failure() + uses: actions/upload-artifact@v7 + with: + name: fuzz-artifacts-${{ matrix.target }} + path: fuzz/artifacts/${{ matrix.target }}/ + + - name: Upload corpus + if: always() + uses: actions/upload-artifact@v7 + with: + name: fuzz-corpus-${{ matrix.target }} + path: fuzz/corpus/${{ matrix.target }}/ diff --git a/.github/workflows/mutants.yml b/.github/workflows/mutants.yml new file mode 100644 index 00000000..5f97692d --- /dev/null +++ b/.github/workflows/mutants.yml @@ -0,0 +1,49 @@ +# Action versions pinned per `.github/dependabot.yml` policy. +name: mutation-tests + +on: + pull_request: + paths: + - "crates/**/*.rs" + - ".cargo/mutants.toml" + +jobs: + incremental-mutants: + name: cargo-mutants (changed lines) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-mutants + + - name: Generate diff against PR base + run: git diff origin/${{ github.base_ref }}.. > git.diff + + - name: Skip if no Rust changes + run: | + if ! grep -q '\.rs$' git.diff; then + echo "No Rust file changes; skipping mutation tests." + exit 0 + fi + + - name: Run cargo-mutants on changed lines + run: | + cargo mutants \ + --no-shuffle \ + --in-diff git.diff \ + --in-place \ + --timeout-multiplier 3.0 \ + -vV + + - uses: actions/upload-artifact@v7 + if: always() + with: + name: mutants-output + path: mutants.out/ diff --git a/.gitignore b/.gitignore index b1ae8261..441ab1a6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,70 @@ -/target -local.db -settings.local.json -coverage -lcov.info -.sisyphus -.omc +# ─── Rust build artifacts ───────────────────────────────────────────── +/target +target/criterion/ # criterion benchmark HTML reports +*.profraw # cargo profile-guided / llvm coverage raw data +*.profdata # processed profile data +flamegraph.svg # profiling visualization output +perf.data # linux perf record output +perf.data.old + +# ─── Insta snapshot review queue ────────────────────────────────────── +# Pending snapshots awaiting `cargo insta accept` — CI gate fails on these. +*.snap.new +*.pending-snap + +# ─── cargo-mutants output (regenerated per run) ─────────────────────── +mutants-out*/ +mutants.out/ + +# ─── cargo-fuzz (corpus has gitkeep'd subdirs; artifacts are crash dumps) ── +fuzz/target/ +fuzz/artifacts/ +fuzz/corpus/* +!fuzz/corpus/*/ +!fuzz/corpus/*/.gitkeep + +# ─── CI temp / drift detection ──────────────────────────────────────── +_tmp_schemas/ # schema-drift job regenerated schemas for diff + +# ─── Coverage output ────────────────────────────────────────────────── +coverage/ +lcov.info + +# ─── Vespertide local / dev-only files ──────────────────────────────── +local.db # ad-hoc local SQLite for manual testing +settings.local.json # local overrides not committed + +# ─── OhMyOpenCode / agent infrastructure ────────────────────────────── +.sisyphus +.omc +.omo +.audit + +# ─── Local secrets / environment ────────────────────────────────────── +.env +.env.local +.env.*.local + +# ─── Patch / conflict artifacts ─────────────────────────────────────── +*.bak +*.orig +*.rej + +# ─── Logs ───────────────────────────────────────────────────────────── +*.log + +# ─── Editor / IDE ───────────────────────────────────────────────────── +.vscode/ +.idea/ +*.iml +.fleet/ + +# vim / emacs swap files +*.swp +*.swo +*~ + +# ─── OS junk ────────────────────────────────────────────────────────── +.DS_Store # macOS Finder metadata +Thumbs.db # Windows Explorer thumbnails +desktop.ini # Windows folder settings diff --git a/AGENTS.md b/AGENTS.md index 71b57cbe..2df29713 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,8 +35,8 @@ vespertide/ |------|----------|-------| | Core types (TableDef, ColumnDef) | `vespertide-core/src/schema/` | Start with `table.rs`, `column.rs` | | Column type system | `vespertide-core/src/schema/column.rs` | `ColumnType::Simple/Complex` variants | -| Migration actions | `vespertide-core/src/action.rs` | 12 action variants, `MigrationPlan` struct | -| Schema diffing | `vespertide-planner/src/diff.rs` | **3215 lines** - topological sort for FK deps | +| Migration actions | `vespertide-core/src/action.rs` | **14 action variants** (incl. `RawSql` escape hatch), `MigrationPlan` struct | +| Schema diffing | `vespertide-planner/src/diff.rs` | topological sort for FK deps | | SQL generation | `vespertide-query/src/sql/` | One file per action type | | CLI commands | `vespertide-cli/src/commands/` | `cmd_*` functions | | ORM export | `vespertide-exporter/src/{seaorm,sqlalchemy,sqlmodel}/` | Backend-specific generators | @@ -93,9 +93,9 @@ ColumnDef { |---------|---------| | `ColumnType::Integer` | Use `ColumnType::Simple(SimpleColumnType::Integer)` | | Forgetting inline fields in ColumnDef | Will cause compile errors - 4 Option fields required | -| Raw SQL in migrations | Use typed `MigrationAction` enums | +| Raw SQL in migrations | Prefer typed `MigrationAction` enums. `MigrationAction::RawSql` exists as a documented **emergency escape hatch** only — non-portable across backends, skipped by baseline replay, and not recommended for normal use | | Skipping `normalize()` on TableDef | Inline constraints won't convert to table-level | -| Assuming YAML works | YAML loading NOT implemented (templates only) | +| `.rs` file exceeding 1000 lines | Maintainability hard limit - split into focused submodules | ## COMMANDS @@ -122,15 +122,24 @@ cargo insta test -p vespertide-exporter cargo insta accept ``` -## COMPLEXITY HOTSPOTS +## COMPLEXITY HOTSPOTS (subject to 1000-line split) | File | Lines | What | |------|-------|------| -| `planner/src/diff.rs` | 3215 | Schema diffing with topological FK sort | -| `exporter/src/seaorm/mod.rs` | 2961 | SeaORM codegen with relation inference | -| `planner/src/validate.rs` | 1821 | Schema/migration validation | -| `core/src/schema/table.rs` | 1582 | Table normalization logic | -| `query/src/sql/remove_constraint.rs` | 1581 | SQLite temp table workarounds | +| `planner/src/diff.rs` | 4739 | Schema diffing with topological FK sort | +| `exporter/src/seaorm/mod.rs` | 4122 | SeaORM codegen with relation inference | +| `cli/src/commands/revision.rs` | 3064 | Revision generation, prompts, action emit | +| `planner/src/validate.rs` | 2299 | Schema/migration validation | +| `planner/src/apply.rs` | 1534 | Action replay onto baseline schema | +| `core/src/schema/table.rs` | 1526 | Table normalization logic | +| `query/src/sql/mod.rs` | 1507 | Dispatch and shared builder helpers | +| `query/src/sql/remove_constraint.rs` | 1465 | SQLite temp-table workarounds | +| `exporter/src/sqlalchemy/mod.rs` | 1383 | SQLAlchemy 2.x codegen | +| `query/src/sql/add_constraint.rs` | 1356 | PK/FK/Unique/CHECK emission | +| `exporter/src/sqlmodel/mod.rs` | 1274 | SQLModel/FastAPI codegen | +| `core/src/action.rs` | 1236 | 14 `MigrationAction` variants + helpers | +| `exporter/src/jpa/mod.rs` | 1122 | JPA codegen | +| `query/src/sql/delete_column.rs` | 1084 | DROP COLUMN with SQLite rebuild | ## TESTING @@ -146,11 +155,80 @@ cargo insta accept |---------|-------------------|-------| | PostgreSQL | `"identifier"` | Full feature support | | MySQL | `` `identifier` `` | Full feature support | -| SQLite | `"identifier"` | Temp table workarounds for ALTER | +| SQLite | `"identifier"` | Full feature support (ALTER limitations implemented via canonical temp-table-rebuild pattern in `query/src/sql/remove_constraint.rs` etc.) | + +## MODEL FORMATS + +Both JSON and YAML are supported for model and migration files. Loaders accept `.json`, `.yaml`, and `.yml` extensions. JSON is preferred (canonical schema URLs reference JSON) but YAML loading is a first-class, tested feature — see `vespertide-loader/src/models.rs` and `vespertide-config/src/file_format.rs`. ## NOTES - Edition 2024 (bleeding edge) - No LSP available - use grep/AST tools -- YAML loading not implemented -- Migration replay pattern: baseline always reconstructed from history +- Every `.rs` file must stay ≤ 1000 lines; CI enforces this +- Migration replay pattern: baseline always reconstructed from history (raw SQL actions are opaque to replay) + +## MUTATION TESTING + +`cargo-mutants` runs in CI on every PR for changed lines only. Locally: + +```bash +# Full pass on the planner crate (slow, ~30 min) +cargo install --locked cargo-mutants +cargo mutants -p vespertide-planner --in-place --timeout-multiplier 3.0 + +# Only mutations introduced by current changes +cargo mutants --in-diff <(git diff main..) --in-place +``` + +Survived mutants indicate test gaps. Fix by adding assertions, not by suppressing the mutant. + +## FUZZING + +`cargo-fuzz` runs in a nightly CI job (`.github/workflows/fuzz.yml`). +Three targets in `fuzz/fuzz_targets/`: + +- `fuzz_model_deser` — JSON deserialization of `TableDef` / `MigrationPlan` +- `fuzz_sql_identifier` — `quote_ident` safety invariants +- `fuzz_migration_apply` — `apply_action` never-panic property + +Local run (requires nightly): + +```bash +rustup install nightly +cargo install cargo-fuzz +cd fuzz +cargo +nightly fuzz run fuzz_model_deser -- -max_total_time=60 +``` + +Corpus and artifacts are gitignored except the `.gitkeep` markers. +Discovered crashes appear under `fuzz/artifacts//` and should be +committed to a regression test before fixing. + +## BENCHMARKS + +`criterion` benchmarks in `crates/*/benches/`. Run locally: + +```bash +# All benchmarks +cargo bench --workspace + +# Single crate +cargo bench -p vespertide-planner + +# Single benchmark with statistical comparison +cargo bench -p vespertide-planner --bench diff_benchmarks -- diff_identity/100 +``` + +HTML reports at `target/criterion//report/index.html`. + +Save baseline for comparison: + +```bash +cargo bench -- --save-baseline main +git checkout feature/foo +cargo bench -- --baseline main +``` + +CI workflow in `.github/workflows/bench.yml` runs on PR for informational +trend tracking (not currently blocking). diff --git a/Cargo.lock b/Cargo.lock index 1cdbe4c6..82b7c379 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -57,11 +57,17 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -74,15 +80,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -125,6 +131,15 @@ dependencies = [ "vespertide", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -133,9 +148,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "arrow" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4754a624e5ae42081f464514be454b39711daae0458906dacde5f4c632f33a8" +checksum = "3bd47f2a6ddc39244bd722a27ee5da66c03369d087b9e024eafdb03e98b98ea7" dependencies = [ "arrow-arith", "arrow-array", @@ -151,9 +166,9 @@ dependencies = [ [[package]] name = "arrow-arith" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7b3141e0ec5145a22d8694ea8b6d6f69305971c4fa1c1a13ef0195aef2d678b" +checksum = "7c7bbd679c5418b8639b92be01f361d60013c4906574b578b77b63c78356594c" dependencies = [ "arrow-array", "arrow-buffer", @@ -165,9 +180,9 @@ dependencies = [ [[package]] name = "arrow-array" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c8955af33b25f3b175ee10af580577280b4bd01f7e823d94c7cdef7cf8c9aef" +checksum = "c8a4ab47b3f3eac60f7fd31b81e9028fda018607bcc63451aca4f2b755269862" dependencies = [ "ahash 0.8.12", "arrow-buffer", @@ -183,9 +198,9 @@ dependencies = [ [[package]] name = "arrow-buffer" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c697ddca96183182f35b3a18e50b9110b11e916d7b7799cbfd4d34662f2c56c2" +checksum = "0d18b89b4c4f4811d0858175e79541fe98e33e18db3b011708bc287b1240593f" dependencies = [ "bytes", "half", @@ -195,9 +210,9 @@ dependencies = [ [[package]] name = "arrow-cast" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "646bbb821e86fd57189c10b4fcdaa941deaf4181924917b0daa92735baa6ada5" +checksum = "722b5c41dd1d14d0a879a1bce92c6fe33f546101bb2acce57a209825edd075b3" dependencies = [ "arrow-array", "arrow-buffer", @@ -216,9 +231,9 @@ dependencies = [ [[package]] name = "arrow-data" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fdd994a9d28e6365aa78e15da3f3950c0fdcea6b963a12fa1c391afb637b304" +checksum = "c1683705c63dcf0d18972759eda48489028cbbff67af7d6bef2c6b7b74ab778a" dependencies = [ "arrow-buffer", "arrow-schema", @@ -229,9 +244,9 @@ dependencies = [ [[package]] name = "arrow-ord" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7d8f1870e03d4cbed632959498bcc84083b5a24bded52905ae1695bd29da45b" +checksum = "082342947d4e5a2bcccf029a0a0397e21cb3bb8421edd9571d34fb5dd2670256" dependencies = [ "arrow-array", "arrow-buffer", @@ -242,9 +257,9 @@ dependencies = [ [[package]] name = "arrow-row" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18228633bad92bff92a95746bbeb16e5fc318e8382b75619dec26db79e4de4c0" +checksum = "e3a931b520a2a5e22033e01a6f2486b4cdc26f9106b759abeebc320f125e94d7" dependencies = [ "arrow-array", "arrow-buffer", @@ -255,15 +270,15 @@ dependencies = [ [[package]] name = "arrow-schema" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c872d36b7bf2a6a6a2b40de9156265f0242910791db366a2c17476ba8330d68" +checksum = "e4cf0d4a6609679e03002167a61074a21d7b1ad9ea65e462b2c0a97f8a3b2bc6" [[package]] name = "arrow-select" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68bf3e3efbd1278f770d67e5dc410257300b161b93baedb3aae836144edcaf4b" +checksum = "0b320d86a9806923663bb0fd9baa65ecaba81cb0cd77ff8c1768b9716b4ef891" dependencies = [ "ahash 0.8.12", "arrow-array", @@ -275,9 +290,9 @@ dependencies = [ [[package]] name = "arrow-string" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85e968097061b3c0e9fe3079cf2e703e487890700546b5b0647f60fca1b5a8d8" +checksum = "b493e99162e5764077e7823e50ba284858d365922631c7aaefe9487b1abd02c2" dependencies = [ "arrow-array", "arrow-buffer", @@ -292,9 +307,9 @@ dependencies = [ [[package]] name = "assert_cmd" -version = "2.1.1" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcbb6924530aa9e0432442af08bbcafdad182db80d2e560da42a6d442535bf85" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" dependencies = [ "anstyle", "bstr", @@ -372,9 +387,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", @@ -425,9 +440,9 @@ dependencies = [ [[package]] name = "axum-extra" -version = "0.12.5" +version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76" +checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970" dependencies = [ "axum", "axum-core", @@ -478,11 +493,49 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.66.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b84e06fc203107bfbad243f4aba2af864eb7db3b1cf46ea0a023b0b433d2a7" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.117", + "which", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ "serde_core", ] @@ -510,19 +563,20 @@ dependencies = [ [[package]] name = "borsh" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" dependencies = [ "borsh-derive", + "bytes", "cfg_aliases", ] [[package]] name = "borsh-derive" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" dependencies = [ "once_cell", "proc-macro-crate", @@ -582,16 +636,33 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" -version = "1.2.57" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -618,11 +689,49 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" -version = "4.5.53" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -630,9 +739,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.53" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -642,9 +751,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -654,23 +763,23 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.6" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "colored" -version = "3.0.0" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -767,9 +876,64 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] [[package]] name = "crossbeam-queue" @@ -794,9 +958,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", @@ -857,6 +1021,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -920,6 +1095,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dissimilar" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeda16ab4059c5fd2a83f2b9c9e9c981327b18aa8e3b313f7e6563799d4f093e" + [[package]] name = "dotenvy" version = "0.15.7" @@ -994,11 +1175,23 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "find-msvc-tools" @@ -1006,6 +1199,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "float-cmp" version = "0.10.0" @@ -1062,6 +1261,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "funty" version = "2.0.0" @@ -1070,9 +1275,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -1175,9 +1380,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.9" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", @@ -1246,6 +1451,15 @@ dependencies = [ "ahash 0.7.8", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1263,6 +1477,21 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "hashlink" version = "0.10.0" @@ -1308,6 +1537,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1388,9 +1623,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -1402,7 +1637,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", ] @@ -1448,12 +1682,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1461,9 +1696,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1474,9 +1709,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1488,15 +1723,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -1508,15 +1743,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1552,9 +1787,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1562,12 +1797,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -1596,12 +1831,32 @@ dependencies = [ "tempfile", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1617,12 +1872,24 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1636,6 +1903,12 @@ dependencies = [ "spin", ] +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1701,9 +1974,29 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.183" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] [[package]] name = "libm" @@ -1713,14 +2006,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ "bitflags", "libc", "plain", - "redox_syscall 0.7.3", + "redox_syscall 0.7.5", ] [[package]] @@ -1734,6 +2027,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1742,9 +2041,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -1809,11 +2108,17 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", @@ -1837,6 +2142,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "native-tls" version = "0.2.18" @@ -1867,6 +2178,16 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -1894,7 +2215,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -1910,9 +2231,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -1956,17 +2277,22 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "openssl" -version = "0.10.75" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ "bitflags", "cfg-if", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -1990,9 +2316,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.111" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -2062,6 +2388,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2077,6 +2409,34 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "pg_query" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ca6fdb8f9d32182abf17328789f87f305dd8c8ce5bf48c5aa2b5cffc94e1c04" +dependencies = [ + "bindgen", + "cc", + "fs_extra", + "glob", + "itertools 0.10.5", + "prost", + "prost-build", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "pgvector" version = "0.4.1" @@ -2092,12 +2452,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "pkcs1" version = "0.7.5" @@ -2121,9 +2475,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plain" @@ -2131,6 +2485,34 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "pluralizer" version = "0.5.0" @@ -2143,9 +2525,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -2167,9 +2549,9 @@ dependencies = [ [[package]] name = "predicates" -version = "3.1.3" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" dependencies = [ "anstyle", "difflib", @@ -2181,15 +2563,15 @@ dependencies = [ [[package]] name = "predicates-core" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" [[package]] name = "predicates-tree" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" dependencies = [ "predicates-core", "termtree", @@ -2258,6 +2640,77 @@ dependencies = [ "yansi", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck 0.5.0", + "itertools 0.14.0", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.117", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + [[package]] name = "ptr_meta" version = "0.1.4" @@ -2278,6 +2731,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.45" @@ -2307,13 +2766,23 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -2323,7 +2792,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -2335,6 +2814,44 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2346,9 +2863,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.3" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ "bitflags", ] @@ -2459,7 +2976,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -2506,22 +3023,43 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink 0.9.1", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust_decimal" -version = "1.40.0" +version = "1.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" +checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" dependencies = [ "arrayvec", "borsh", "bytes", "num-traits", - "rand", + "rand 0.8.6", "rkyv", "serde", "serde_json", + "wasm-bindgen", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2531,6 +3069,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -2540,7 +3091,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -2550,12 +3101,33 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scc" version = "2.4.0" @@ -2567,18 +3139,18 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "schemars" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", @@ -2589,9 +3161,9 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4908ad288c5035a8eb12cfdf0d49270def0a268ee162b75eeee0f85d155a7c45" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" dependencies = [ "proc-macro2", "quote", @@ -2626,9 +3198,9 @@ dependencies = [ [[package]] name = "sea-orm" -version = "2.0.0-rc.37" +version = "2.0.0-rc.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b846dc1c7fefbea372c03765ff08307d68894bbad8c73b66176dcd53a3ee131" +checksum = "9b5428ce6a0c8f6b9858df21ad1aa00c2fb94e1c9f344a0436bc855391e5a225" dependencies = [ "async-stream", "async-trait", @@ -2636,7 +3208,7 @@ dependencies = [ "chrono", "derive_more", "futures-util", - "itertools", + "itertools 0.14.0", "log", "mac_address", "ouroboros", @@ -2644,14 +3216,14 @@ dependencies = [ "rust_decimal", "sea-orm-arrow", "sea-orm-macros", - "sea-query 1.0.0-rc.31", + "sea-query 1.0.0-rc.33", "sea-query-sqlx", "sea-schema", "serde", "serde_json", "sqlx", "strum", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "url", @@ -2665,18 +3237,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c2eee8405f16c1f337fe3a83389361caea83c928d14dbd666a480407072c365" dependencies = [ "arrow", - "sea-query 1.0.0-rc.31", - "thiserror", + "sea-query 1.0.0-rc.33", + "thiserror 2.0.18", ] [[package]] name = "sea-orm-macros" -version = "2.0.0-rc.37" +version = "2.0.0-rc.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b449fe660e4d365f335222025df97ae01e670ef7ad788b3c67db9183b6cb0474" +checksum = "ae1374d83dd5b43f14dcc90fc726486c556f4db774b680b12b8c680af76e8233" dependencies = [ "heck 0.5.0", - "itertools", + "itertools 0.14.0", "pluralizer", "proc-macro2", "quote", @@ -2697,9 +3269,9 @@ dependencies = [ [[package]] name = "sea-query" -version = "1.0.0-rc.31" +version = "1.0.0-rc.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58decdaaaf2a698170af2fa1b2e8f7b43a970e7768bf18aebaab113bada46354" +checksum = "b04cdb0135c16e829504e93fbe7880513578d56f07aaea152283526590111828" dependencies = [ "chrono", "inherent", @@ -2722,7 +3294,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -2736,16 +3308,16 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "thiserror", + "thiserror 2.0.18", ] [[package]] name = "sea-query-sqlx" -version = "0.8.0-rc.14" +version = "0.8.0-rc.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4377164b09a11bb692dec6966eb0e6908d63d768defef0be689b39e02cf8544" +checksum = "a04aeecfe00614fece56336fd35dc385bb9ffed0c75660695ba925e42a3991ef" dependencies = [ - "sea-query 1.0.0-rc.31", + "sea-query 1.0.0-rc.33", "sqlx", ] @@ -2756,7 +3328,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b363dd21c20fe4d1488819cb2bc7f8d4696c62dd9f39554f97639f54d57dd0ab" dependencies = [ "async-trait", - "sea-query 1.0.0-rc.31", + "sea-query 1.0.0-rc.33", "sea-query-sqlx", "sea-schema-derive", "sqlx", @@ -2805,9 +3377,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -2887,6 +3459,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2989,7 +3570,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -3048,6 +3629,15 @@ dependencies = [ "der", ] +[[package]] +name = "sqlparser" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a875d8cd437cc8a97e9aeaeea352ec9a19aea99c23e9effb17757291de80b08" +dependencies = [ + "log", +] + [[package]] name = "sqlx" version = "0.8.6" @@ -3079,7 +3669,7 @@ dependencies = [ "futures-io", "futures-util", "hashbrown 0.15.5", - "hashlink", + "hashlink 0.10.0", "indexmap", "log", "memchr", @@ -3091,7 +3681,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror", + "thiserror 2.0.18", "time", "tokio", "tokio-stream", @@ -3168,7 +3758,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.6", "rsa", "rust_decimal", "serde", @@ -3177,7 +3767,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "uuid", @@ -3210,7 +3800,7 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.6", "rust_decimal", "serde", "serde_json", @@ -3218,7 +3808,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "uuid", @@ -3245,7 +3835,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "url", @@ -3283,9 +3873,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" [[package]] name = "subtle" @@ -3338,6 +3928,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "target-triple" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" + [[package]] name = "tempfile" version = "3.27.0" @@ -3347,23 +3943,52 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "termtree" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -3419,19 +4044,29 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -3444,9 +4079,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -3461,9 +4096,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -3481,20 +4116,35 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", "toml_datetime", @@ -3504,13 +4154,19 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tower" version = "0.5.3" @@ -3571,11 +4227,33 @@ dependencies = [ "once_cell", ] +[[package]] +name = "trybuild" +version = "1.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c635f0191bd3a2941013e5062667100969f8c4e9cd787c14f977265d73616e" +dependencies = [ + "dissimilar", + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml", +] + [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unarray" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unicode-bidi" @@ -3648,9 +4326,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -3672,9 +4350,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespera" -version = "0.1.50" +version = "0.1.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e53fae5b7dfbc9c6358a7ed2ab03309bafda94edff6f0ed137aacbbf4673290" +checksum = "f4f217d51b0403454c5ee6b798d853acf93cbe2bc5b318beba6d40035850262b" dependencies = [ "axum", "axum-extra", @@ -3689,9 +4367,9 @@ dependencies = [ [[package]] name = "vespera_core" -version = "0.1.50" +version = "0.1.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7907610cd10b5404764392d01a9ed1cbc9f77e65fbdab3d29d7e725a69fef2a" +checksum = "73fc3c38f267572f59d54aa92c2ed96bacf746abf8e0a67a8d50819cea3ad4fc" dependencies = [ "serde", "serde_json", @@ -3699,9 +4377,9 @@ dependencies = [ [[package]] name = "vespera_macro" -version = "0.1.50" +version = "0.1.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8516ec0107927abe8dfa91ea4c7db0c03a63c8b1e5273a6013e3de665b5ff029" +checksum = "7452a3ffa22362fecbb61da1d85dad8c18e09cb8187a37c3272f2693a829a5a6" dependencies = [ "proc-macro2", "quote", @@ -3713,7 +4391,7 @@ dependencies = [ [[package]] name = "vespertide" -version = "0.1.60" +version = "0.1.61" dependencies = [ "sea-orm", "tokio", @@ -3723,7 +4401,7 @@ dependencies = [ [[package]] name = "vespertide-cli" -version = "0.1.60" +version = "0.1.61" dependencies = [ "anyhow", "assert_cmd", @@ -3752,7 +4430,7 @@ dependencies = [ [[package]] name = "vespertide-config" -version = "0.1.60" +version = "0.1.61" dependencies = [ "clap", "schemars", @@ -3762,31 +4440,48 @@ dependencies = [ [[package]] name = "vespertide-core" -version = "0.1.60" +version = "0.1.61" dependencies = [ + "criterion", + "proptest", "rstest", "schemars", + "sea-orm", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "vespertide-naming", ] [[package]] name = "vespertide-exporter" -version = "0.1.60" +version = "0.1.61" dependencies = [ + "criterion", "insta", + "proptest", "rstest", - "thiserror", + "thiserror 2.0.18", "vespertide-config", "vespertide-core", "vespertide-naming", ] +[[package]] +name = "vespertide-fuzz" +version = "0.0.0" +dependencies = [ + "arbitrary", + "libfuzzer-sys", + "serde_json", + "vespertide-core", + "vespertide-planner", + "vespertide-query", +] + [[package]] name = "vespertide-loader" -version = "0.1.60" +version = "0.1.61" dependencies = [ "anyhow", "rstest", @@ -3801,12 +4496,15 @@ dependencies = [ [[package]] name = "vespertide-macro" -version = "0.1.60" +version = "0.1.61" dependencies = [ + "proc-macro-crate", "proc-macro2", + "quote", "runtime-macros", "syn 2.0.117", "tempfile", + "trybuild", "vespertide-config", "vespertide-core", "vespertide-loader", @@ -3816,27 +4514,38 @@ dependencies = [ [[package]] name = "vespertide-naming" -version = "0.1.60" +version = "0.1.61" +dependencies = [ + "criterion", + "proptest", +] [[package]] name = "vespertide-planner" -version = "0.1.60" +version = "0.1.61" dependencies = [ + "criterion", "insta", + "proptest", "rstest", - "thiserror", + "thiserror 2.0.18", "vespertide-core", "vespertide-naming", ] [[package]] name = "vespertide-query" -version = "0.1.60" +version = "0.1.61" dependencies = [ + "criterion", "insta", + "pg_query", + "proptest", "rstest", + "rusqlite", "sea-query 0.32.7", - "thiserror", + "sqlparser", + "thiserror 2.0.18", "vespertide-core", "vespertide-naming", "vespertide-planner", @@ -3866,6 +4575,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -3874,11 +4593,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -3887,7 +4606,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] @@ -3898,22 +4617,23 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", "rustversion", + "serde", "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3921,9 +4641,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -3934,9 +4654,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -3975,6 +4695,28 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "whoami" version = "1.6.1" @@ -4001,6 +4743,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -4216,9 +4967,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.7.15" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -4232,6 +4983,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -4313,9 +5070,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wyz" @@ -4334,9 +5091,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -4345,9 +5102,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -4357,18 +5114,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.41" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96e13bc581734df6250836c59a5f44f3c57db9f9acb9dc8e3eaabdaf6170254d" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.41" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3545ea9e86d12ab9bba9fcd99b54c1556fd3199007def5a03c375623d05fac1c" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -4377,18 +5134,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -4404,9 +5161,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -4415,9 +5172,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -4426,9 +5183,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 7eb342bf..58c90e5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,19 @@ [workspace] -members = ["crates/*", "examples/app"] +members = ["crates/*", "examples/app", "fuzz"] +default-members = [ + "crates/vespertide", + "crates/vespertide-cli", + "crates/vespertide-config", + "crates/vespertide-core", + "crates/vespertide-exporter", + "crates/vespertide-loader", + "crates/vespertide-macro", + "crates/vespertide-naming", + "crates/vespertide-planner", + "crates/vespertide-query", + "crates/vespertide-schema-gen", + "examples/app", +] resolver = "2" [workspace.package] @@ -10,8 +24,22 @@ homepage = "https://github.com/dev-five-git/vespertide" documentation = "https://docs.rs/vespertide" [workspace.lints.rust] +unsafe_code = "warn" unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } +[workspace.lints.clippy] +# AGENTS policy: clippy::all + pedantic enforced workspace-wide. +# Pre-1.0 quality bar: every warning must be fixed or have a justified allow. +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +# Pedantic lints that are too noisy for this codebase and accepted as project style: +module_name_repetitions = "allow" # crate-prefixed type names are intentional (vespertide-* convention) +similar_names = "allow" # `from`/`to`, `table`/`tables` etc. are domain-natural +must_use_candidate = "allow" # blanket-applying #[must_use] obscures real intent; case-by-case via audit +missing_errors_doc = "allow" # adding `# Errors` to every Result fn is performative; deferred +missing_panics_doc = "allow" # same as above; we don't panic in production code paths +return_self_not_must_use = "allow" # builder patterns are obvious from context + [workspace.dependencies] vespertide-core = { path = "crates/vespertide-core", version = "=0.1.61", default-features = false } vespertide-config = { path = "crates/vespertide-config", version = "=0.1.61", default-features = false } diff --git a/crates/vespertide-cli/AGENTS.md b/crates/vespertide-cli/AGENTS.md index b46b6b4d..778a065b 100644 --- a/crates/vespertide-cli/AGENTS.md +++ b/crates/vespertide-cli/AGENTS.md @@ -45,8 +45,12 @@ src/ ## NOTES -- **revision.rs** (1100 lines): Most complex - handles interactive `--fill-with` prompts for NOT NULL columns without defaults +- **revision.rs** (3064 lines, scheduled for split per 1000-line rule): Most complex - handles interactive `--fill-with` prompts for NOT NULL columns without defaults - **export.rs**: Generates `mod.rs` chain for SeaORM exports; Python ORMs skip this - All commands use `load_config()`, `load_models()`, `load_migrations()` from `vespertide_loader` +- YAML and JSON are both fully supported for models and migrations; `new -f yaml` creates YAML templates. +- Prefer typed `MigrationAction` enums; `RawSql` exists as a documented emergency escape hatch, but is not recommended for normal use. - Tests use `serial_test::serial` with `CwdGuard` for directory isolation - Schema URLs default to GitHub raw; override via `VESP_SCHEMA_BASE_URL` env var +- Every `.rs` file must stay ≤ 1000 lines (CI enforced). +- Workspace lints warn on unsafe code and Clippy all: `unsafe_code = "warn"`, `clippy::all = { level = "warn", priority = -1 }`. diff --git a/crates/vespertide-cli/Cargo.toml b/crates/vespertide-cli/Cargo.toml index d7383fdc..cf580f34 100644 --- a/crates/vespertide-cli/Cargo.toml +++ b/crates/vespertide-cli/Cargo.toml @@ -7,6 +7,9 @@ repository.workspace = true homepage.workspace = true documentation.workspace = true description = "CLI command for vespertide (model template, diff, SQL, revision, status, log)" +keywords = ["database", "migration", "schema", "orm", "sql"] +categories = ["database"] +readme = "../../README.md" publish = true [dependencies] @@ -40,5 +43,5 @@ predicates = "3" name = "vespertide" path = "src/main.rs" -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } +[lints] +workspace = true diff --git a/crates/vespertide-cli/src/commands/diff.rs b/crates/vespertide-cli/src/commands/diff.rs index dc2ca19e..6ae741fe 100644 --- a/crates/vespertide-cli/src/commands/diff.rs +++ b/crates/vespertide-cli/src/commands/diff.rs @@ -11,7 +11,7 @@ pub async fn cmd_diff() -> Result<()> { let applied_plans = load_migrations(&config)?; let plan = plan_next_migration(¤t_models, &applied_plans) - .map_err(|e| anyhow::anyhow!("planning error: {}", e))?; + .map_err(|e| anyhow::anyhow!("planning error: {e}"))?; if plan.actions.is_empty() { println!( @@ -39,98 +39,97 @@ pub async fn cmd_diff() -> Result<()> { Ok(()) } +#[expect( + clippy::too_many_lines, + reason = "one display arm per migration action keeps output obvious" +)] fn format_action(action: &MigrationAction) -> String { + let table = action.table_name().map(Colorize::bright_cyan); match action { - MigrationAction::CreateTable { table, .. } => { + MigrationAction::CreateTable { .. } => { format!( "{} {}", "Create table:".bright_green(), - table.bright_cyan().bold() + table.expect("CreateTable has a table").bold() ) } - MigrationAction::DeleteTable { table } => { + MigrationAction::DeleteTable { .. } => { format!( "{} {}", "Delete table:".bright_red(), - table.bright_cyan().bold() + table.expect("DeleteTable has a table").bold() ) } - MigrationAction::AddColumn { table, column, .. } => { + MigrationAction::AddColumn { column, .. } => { format!( "{} {}.{}", "Add column:".bright_green(), - table.bright_cyan(), + table.expect("AddColumn has a table"), column.name.bright_cyan().bold() ) } - MigrationAction::RenameColumn { table, from, to } => { + MigrationAction::RenameColumn { from, to, .. } => { format!( "{} {}.{} {} {}", "Rename column:".bright_yellow(), - table.bright_cyan(), + table.expect("RenameColumn has a table"), from.bright_white(), "->".bright_white(), to.bright_cyan().bold() ) } - MigrationAction::DeleteColumn { table, column } => { + MigrationAction::DeleteColumn { column, .. } => { format!( "{} {}.{}", "Delete column:".bright_red(), - table.bright_cyan(), + table.expect("DeleteColumn has a table"), column.bright_cyan().bold() ) } MigrationAction::ModifyColumnType { - table, - column, - new_type, - .. + column, new_type, .. } => { format!( "{} {}.{} {} {}", "Modify column type:".bright_yellow(), - table.bright_cyan(), + table.expect("ModifyColumnType has a table"), column.bright_cyan().bold(), "->".bright_white(), new_type.to_display_string().bright_cyan().bold() ) } MigrationAction::ModifyColumnNullable { - table, - column, - nullable, - .. + column, nullable, .. } => { let nullability = if *nullable { "NULL" } else { "NOT NULL" }; format!( "{} {}.{} {} {}", "Modify column nullability:".bright_yellow(), - table.bright_cyan(), + table.expect("ModifyColumnNullable has a table"), column.bright_cyan().bold(), "->".bright_white(), nullability.bright_cyan().bold() ) } MigrationAction::ModifyColumnDefault { - table, column, new_default, + .. } => { let default_display = new_default.as_deref().unwrap_or("(none)"); format!( "{} {}.{} {} {}", "Modify column default:".bright_yellow(), - table.bright_cyan(), + table.expect("ModifyColumnDefault has a table"), column.bright_cyan().bold(), "->".bright_white(), default_display.bright_cyan().bold() ) } MigrationAction::ModifyColumnComment { - table, column, new_comment, + .. } => { let comment_display = new_comment.as_deref().unwrap_or("(none)"); let truncated = if comment_display.chars().count() > 30 { @@ -144,7 +143,7 @@ fn format_action(action: &MigrationAction) -> String { format!( "{} {}.{} {} '{}'", "Modify column comment:".bright_yellow(), - table.bright_cyan(), + table.expect("ModifyColumnComment has a table"), column.bright_cyan().bold(), "->".bright_white(), truncated.bright_cyan().bold() @@ -166,27 +165,25 @@ fn format_action(action: &MigrationAction) -> String { sql.bright_cyan() ) } - MigrationAction::AddConstraint { table, constraint } => { + MigrationAction::AddConstraint { constraint, .. } => { format!( "{} {} {} {}", "Add constraint:".bright_green(), format_constraint_type(constraint).bright_cyan().bold(), "on".bright_white(), - table.bright_cyan() + table.expect("AddConstraint has a table") ) } - MigrationAction::RemoveConstraint { table, constraint } => { + MigrationAction::RemoveConstraint { constraint, .. } => { format!( "{} {} {} {}", "Remove constraint:".bright_red(), format_constraint_type(constraint).bright_cyan().bold(), "from".bright_white(), - table.bright_cyan() + table.expect("RemoveConstraint has a table") ) } - MigrationAction::ReplaceConstraint { - table, from, to, .. - } => { + MigrationAction::ReplaceConstraint { from, to, .. } => { format!( "{} {} {} {} {} {}", "Replace constraint:".bright_yellow(), @@ -194,9 +191,10 @@ fn format_action(action: &MigrationAction) -> String { "->".bright_white(), format_constraint_type(to).bright_cyan().bold(), "on".bright_white(), - table.bright_cyan() + table.expect("ReplaceConstraint has a table") ) } + _ => unreachable!("MigrationAction is #[non_exhaustive]; all variants are matched above"), } } @@ -225,7 +223,7 @@ fn format_constraint_type(constraint: &vespertide_core::TableConstraint) -> Stri } } vespertide_core::TableConstraint::Check { name, expr } => { - format!("{} CHECK ({})", name, expr) + format!("{name} CHECK ({expr})") } vespertide_core::TableConstraint::Index { name, columns } => { if let Some(n) = name { @@ -234,6 +232,7 @@ fn format_constraint_type(constraint: &vespertide_core::TableConstraint) -> Stri format!("INDEX ({})", columns.join(", ")) } } + _ => unreachable!("TableConstraint is #[non_exhaustive]; all variants are matched above"), } } diff --git a/crates/vespertide-cli/src/commands/export.rs b/crates/vespertide-cli/src/commands/export.rs index da274037..33ecf21f 100644 --- a/crates/vespertide-cli/src/commands/export.rs +++ b/crates/vespertide-cli/src/commands/export.rs @@ -165,7 +165,7 @@ fn rel_path_to_module_segments(rel_path: &Path) -> Vec { if let std::path::Component::Normal(name) = component && let Some(s) = name.to_str() { - segments.push(sanitize_filename(s).to_string()); + segments.push(sanitize_filename(s).clone()); } } } @@ -178,7 +178,7 @@ fn rel_path_to_module_segments(rel_path: &Path) -> Vec { (file_name, "") }; let stem = stem.strip_suffix(".vespertide").unwrap_or(stem); - segments.push(sanitize_filename(stem).to_string()); + segments.push(sanitize_filename(stem).clone()); } segments @@ -302,7 +302,7 @@ fn build_output_path(root: &Path, rel_path: &Path, orm: Orm) -> PathBuf { } else { sanitized }; - out.set_file_name(format!("{}.{}", file_stem, ext)); + out.set_file_name(format!("{file_stem}.{ext}")); } out @@ -356,11 +356,7 @@ async fn ensure_mod_chain(root: &Path, rel_path: &Path) -> Result<()> { }; let mut comps: Vec = path_stripped .components() - .filter_map(|c| { - c.as_os_str() - .to_str() - .map(|s| sanitize_filename(s).to_string()) - }) + .filter_map(|c| c.as_os_str().to_str().map(|s| sanitize_filename(s).clone())) .collect(); if comps.is_empty() { return Ok(()); @@ -412,7 +408,7 @@ async fn walk_models( continue; } let ext = path.extension().and_then(|s| s.to_str()); - if !matches!(ext, Some("json") | Some("yaml") | Some("yml")) { + if !matches!(ext, Some("json" | "yaml" | "yml")) { continue; } let content = fs::read_to_string(&path) diff --git a/crates/vespertide-cli/src/commands/new.rs b/crates/vespertide-cli/src/commands/new.rs index 4dc5899b..b793b84f 100644 --- a/crates/vespertide-cli/src/commands/new.rs +++ b/crates/vespertide-cli/src/commands/new.rs @@ -6,7 +6,7 @@ use serde_json::Value; use tokio::fs; use vespertide_core::TableDef; -use crate::utils::load_config; +use crate::utils::{load_config, schema_url}; use vespertide_config::FileFormat; pub async fn cmd_new(name: String, format: Option) -> Result<()> { @@ -25,7 +25,7 @@ pub async fn cmd_new(name: String, format: Option) -> Result<()> { FileFormat::Yml => "yml", }; - let schema_url = schema_url_for(format); + let schema_url = schema_url("model.schema.json"); let path = dir.join(format!("{name}.vespertide.{ext}")); if path.exists() { bail!("model file already exists: {}", path.display()); @@ -51,20 +51,6 @@ pub async fn cmd_new(name: String, format: Option) -> Result<()> { Ok(()) } -fn schema_url_for(format: FileFormat) -> String { - // If not set, default to public raw GitHub schema location. - // Users can override via VESP_SCHEMA_BASE_URL. - let base = std::env::var("VESP_SCHEMA_BASE_URL").ok(); - let base = base.as_deref().unwrap_or( - "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas", - ); - let base = base.trim_end_matches('/'); - match format { - FileFormat::Json => format!("{}/model.schema.json", base), - FileFormat::Yaml | FileFormat::Yml => format!("{}/model.schema.json", base), - } -} - async fn write_json_with_schema(path: &Path, table: &TableDef, schema_url: &str) -> Result<()> { let mut value = serde_json::to_value(table).context("serialize table to json")?; if let Value::Object(ref mut map) = value { @@ -133,7 +119,7 @@ mod tests { async fn cmd_new_creates_json_with_schema() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(tmp.path()); - let expected_schema = schema_url_for(FileFormat::Json); + let expected_schema = schema_url("model.schema.json"); write_config(FileFormat::Json); cmd_new("users".into(), None).await.unwrap(); @@ -155,7 +141,7 @@ mod tests { async fn cmd_new_creates_yaml_with_schema() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(tmp.path()); - let expected_schema = schema_url_for(FileFormat::Yaml); + let expected_schema = schema_url("model.schema.json"); write_config(FileFormat::Yaml); cmd_new("orders".into(), None).await.unwrap(); @@ -181,7 +167,7 @@ mod tests { async fn cmd_new_creates_yml_with_schema() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(tmp.path()); - let expected_schema = schema_url_for(FileFormat::Yml); + let expected_schema = schema_url("model.schema.json"); write_config(FileFormat::Yml); cmd_new("products".into(), None).await.unwrap(); diff --git a/crates/vespertide-cli/src/commands/revision.rs b/crates/vespertide-cli/src/commands/revision.rs deleted file mode 100644 index 1aa21a6e..00000000 --- a/crates/vespertide-cli/src/commands/revision.rs +++ /dev/null @@ -1,3417 +0,0 @@ -use std::collections::{BTreeMap, HashMap, HashSet}; -use std::path::Path; - -use anyhow::{Context, Result}; -use chrono::Utc; -use colored::Colorize; -use dialoguer::{Confirm, Input, Select}; -use serde_json::Value; -use tokio::fs; -use vespertide_config::FileFormat; -use vespertide_core::{MigrationAction, MigrationPlan, TableConstraint, TableDef}; -use vespertide_planner::{ - EnumFillWithRequired, FillWithRequired, find_missing_enum_fill_with, find_missing_fill_with, - plan_next_migration, schema_from_plans, -}; - -use crate::utils::{ - load_config, load_migrations, load_models, migration_filename_with_format_and_pattern, -}; - -/// Parse fill_with arguments from CLI. -/// Format: table.column=value -fn parse_fill_with_args(args: &[String]) -> HashMap<(String, String), String> { - let mut map = HashMap::new(); - for arg in args { - if let Some((key, value)) = arg.split_once('=') - && let Some((table, column)) = key.split_once('.') - { - map.insert((table.to_string(), column.to_string()), value.to_string()); - } - } - map -} - -/// Parse delete_null_rows arguments from CLI. -/// Format: table.column -fn parse_delete_null_rows_args(args: &[String]) -> HashSet<(String, String)> { - let mut set = HashSet::new(); - for arg in args { - if let Some((table, column)) = arg.split_once('.') { - set.insert((table.to_string(), column.to_string())); - } - } - set -} - -/// Format the type info string for display. -/// Includes column type and default value hint if available. -fn format_type_info(column_type: &str, default_value: &str) -> String { - format!(" ({}, default: {})", column_type, default_value) -} - -/// Format a single fill_with item for display. -fn format_fill_with_item(table: &str, column: &str, type_info: &str, action_type: &str) -> String { - format!( - " {} {}.{}{}\n {} {}", - "•".bright_cyan(), - table.bright_white(), - column.bright_green(), - type_info.bright_black(), - "Action:".bright_black(), - action_type.bright_magenta() - ) -} - -/// Format the prompt string for interactive input. -fn format_fill_with_prompt(table: &str, column: &str) -> String { - format!( - " Enter fill value for {}.{}", - table.bright_white(), - column.bright_green() - ) -} - -/// Print the header for fill_with prompts. -fn print_fill_with_header() { - println!( - "\n{} {}", - "⚠".bright_yellow(), - "The following columns require fill_with values:".bright_yellow() - ); - println!("{}", "─".repeat(60).bright_black()); -} - -/// Print the footer for fill_with prompts. -fn print_fill_with_footer() { - println!("{}", "─".repeat(60).bright_black()); -} - -/// Print a fill_with item and return the formatted prompt. -fn print_fill_with_item_and_get_prompt( - table: &str, - column: &str, - column_type: &str, - default_value: &str, - action_type: &str, -) -> String { - let type_info = format_type_info(column_type, default_value); - let item_display = format_fill_with_item(table, column, &type_info, action_type); - println!("{}", item_display); - format_fill_with_prompt(table, column) -} - -/// Wrap a value with single quotes if it contains spaces and isn't already quoted. -fn wrap_if_spaces(value: String) -> String { - if value.is_empty() { - return value; - } - // Already wrapped with single quotes - if value.starts_with('\'') && value.ends_with('\'') { - return value; - } - // Contains spaces: wrap with single quotes - if value.contains(' ') { - return format!("'{}'", value); - } - value -} - -/// Prompt the user for a fill_with value using dialoguer. -/// This function wraps terminal I/O and cannot be unit tested without a real terminal. -#[cfg(not(tarpaulin_include))] -fn prompt_fill_with_value(prompt: &str, default: &str) -> Result { - let value: String = Input::new() - .with_prompt(prompt) - .default(default.to_string()) - .interact_text() - .context("failed to read input")?; - Ok(wrap_if_spaces(value)) -} - -/// Prompt the user to select an enum value using dialoguer Select. -/// Returns the selected value wrapped in single quotes for SQL. -#[cfg(not(tarpaulin_include))] -fn prompt_enum_value(prompt: &str, enum_values: &[String]) -> Result { - let selection = Select::new() - .with_prompt(prompt) - .items(enum_values) - .default(0) - .interact() - .context("failed to read selection")?; - // Return the selected value with single quotes for SQL enum literal - Ok(format!("'{}'", enum_values[selection])) -} - -/// Prompt for enum value selection and return bare (unquoted) value. -/// Used by `cmd_revision` for enum fill_with collection where BTreeMap stores bare names. -#[cfg(not(tarpaulin_include))] -fn prompt_enum_value_bare(prompt: &str, values: &[String]) -> Result { - let selected = prompt_enum_value(prompt, values)?; - Ok(strip_enum_quotes(selected)) -} - -/// Strip SQL single-quotes from an enum value string. -/// BTreeMap stores bare enum names; the SQL layer handles quoting via `Expr::val()`. -fn strip_enum_quotes(value: String) -> String { - value - .trim_start_matches('\'') - .trim_end_matches('\'') - .to_string() -} - -/// Collect fill_with values interactively for missing columns. -/// The `prompt_fn` parameter allows injecting a mock for testing. -/// The `enum_prompt_fn` parameter handles enum type columns with selection UI. -fn collect_fill_with_values( - missing: &[vespertide_planner::FillWithRequired], - fill_values: &mut HashMap<(String, String), String>, - prompt_fn: F, - enum_prompt_fn: E, -) -> Result<()> -where - F: Fn(&str, &str) -> Result, - E: Fn(&str, &[String]) -> Result, -{ - print_fill_with_header(); - - for item in missing { - let prompt = print_fill_with_item_and_get_prompt( - &item.table, - &item.column, - &item.column_type, - &item.default_value, - item.action_type, - ); - - let value = if let Some(enum_values) = &item.enum_values { - // Use selection UI for enum types - enum_prompt_fn(&prompt, enum_values)? - } else { - // Use text input with default pre-filled - prompt_fn(&prompt, &item.default_value)? - }; - fill_values.insert((item.table.clone(), item.column.clone()), value); - } - - print_fill_with_footer(); - Ok(()) -} - -/// Apply fill_with values to a migration plan. -fn apply_fill_with_to_plan( - plan: &mut MigrationPlan, - fill_values: &HashMap<(String, String), String>, -) { - for action in &mut plan.actions { - match action { - MigrationAction::AddColumn { - table, - column, - fill_with, - } => { - if fill_with.is_none() - && let Some(value) = fill_values.get(&(table.clone(), column.name.clone())) - { - *fill_with = Some(value.clone()); - } - } - MigrationAction::ModifyColumnNullable { - table, - column, - fill_with, - .. - } => { - if fill_with.is_none() - && let Some(value) = fill_values.get(&(table.clone(), column.clone())) - { - *fill_with = Some(value.clone()); - } - } - _ => {} - } - } -} - -/// Apply delete_null_rows flags to matching ModifyColumnNullable actions. -fn apply_delete_null_rows_to_plan( - plan: &mut MigrationPlan, - delete_set: &HashSet<(String, String)>, -) { - for action in &mut plan.actions { - if let MigrationAction::ModifyColumnNullable { - table, - column, - nullable, - delete_null_rows, - .. - } = action - && !*nullable - && delete_null_rows.is_none() - && delete_set.contains(&(table.clone(), column.clone())) - { - *delete_null_rows = Some(true); - } - } -} - -/// Handle interactive fill_with collection if there are missing values. -/// Returns the updated fill_values map after collecting from user. -#[cfg(test)] -fn handle_missing_fill_with( - plan: &mut MigrationPlan, - fill_values: &mut HashMap<(String, String), String>, - current_schema: &[TableDef], - prompt_fn: F, - enum_prompt_fn: E, -) -> Result<()> -where - F: Fn(&str, &str) -> Result, - E: Fn(&str, &[String]) -> Result, -{ - let missing = find_missing_fill_with(plan, current_schema); - - if !missing.is_empty() { - collect_fill_with_values(&missing, fill_values, prompt_fn, enum_prompt_fn)?; - - // Apply the collected fill_with values - apply_fill_with_to_plan(plan, fill_values); - } - - Ok(()) -} - -#[cfg(not(tarpaulin_include))] -fn prompt_delete_null_rows(table: &str, column: &str) -> Result { - let confirmed = Confirm::new() - .with_prompt(format!(" Delete rows where {}.{} IS NULL?", table, column)) - .default(false) - .interact() - .context("failed to read confirmation")?; - Ok(confirmed) -} - -fn handle_delete_null_rows( - plan: &mut MigrationPlan, - missing: &mut Vec, - delete_set: &HashSet<(String, String)>, - prompt_fn: F, -) -> Result<()> -where - F: Fn(&str, &str) -> Result, -{ - let mut to_delete = Vec::new(); - let mut remaining = Vec::new(); - - for item in missing.drain(..) { - if item.has_foreign_key && !delete_set.contains(&(item.table.clone(), item.column.clone())) - { - // FK column without CLI arg — prompt user - println!( - " {} {}.{} has a foreign key constraint — fill_with may not work.", - "\u{2022}".bright_cyan(), - item.table.bright_white(), - item.column.bright_green() - ); - if prompt_fn(&item.table, &item.column)? { - to_delete.push((item.table.clone(), item.column.clone())); - } else { - remaining.push(item); - } - } else if delete_set.contains(&(item.table.clone(), item.column.clone())) { - to_delete.push((item.table.clone(), item.column.clone())); - } else { - remaining.push(item); - } - } - - // Apply delete_null_rows to plan - for (table, column) in &to_delete { - for action in &mut plan.actions { - if let MigrationAction::ModifyColumnNullable { - table: t, - column: c, - delete_null_rows, - .. - } = action - && t == table - && c == column - { - *delete_null_rows = Some(true); - } - } - } - - *missing = remaining; - Ok(()) -} - -/// Collect enum fill_with values interactively for removed enum values. -/// The `enum_prompt_fn` parameter handles enum type columns with selection UI. -fn collect_enum_fill_with_values( - missing: &[EnumFillWithRequired], - enum_prompt_fn: E, -) -> Result)>> -where - E: Fn(&str, &[String]) -> Result, -{ - let mut results = Vec::new(); - - println!( - "\n{} {}", - "\u{26a0}".bright_yellow(), - "The following enum value removals require replacement mappings:".bright_yellow() - ); - println!("{}", "\u{2500}".repeat(60).bright_black()); - - for item in missing { - println!( - " {} {}.{}: removing enum values", - "\u{2022}".bright_cyan(), - item.table.bright_white(), - item.column.bright_green() - ); - - let mut mappings = BTreeMap::new(); - for removed in &item.removed_values { - let prompt = format!( - " Replace '{}' in {}.{} with", - removed.bright_red(), - item.table.bright_white(), - item.column.bright_green() - ); - let value = enum_prompt_fn(&prompt, &item.remaining_values)?; - mappings.insert(removed.clone(), value); - } - results.push((item.action_index, mappings)); - } - - println!("{}", "\u{2500}".repeat(60).bright_black()); - Ok(results) -} - -/// Apply collected enum fill_with mappings to the migration plan. -fn apply_enum_fill_with_to_plan( - plan: &mut MigrationPlan, - collected: &[(usize, BTreeMap)], -) { - for (action_index, mappings) in collected { - if let Some(MigrationAction::ModifyColumnType { fill_with, .. }) = - plan.actions.get_mut(*action_index) - { - match fill_with { - Some(existing) => { - existing.extend(mappings.clone()); - } - None => { - *fill_with = Some(mappings.clone()); - } - } - } - } -} - -/// Handle interactive enum fill_with collection if there are missing values. -fn handle_missing_enum_fill_with( - plan: &mut MigrationPlan, - current_schema: &[TableDef], - enum_prompt_fn: E, -) -> Result<()> -where - E: Fn(&str, &[String]) -> Result, -{ - let missing = find_missing_enum_fill_with(plan, current_schema); - - if !missing.is_empty() { - let collected = collect_enum_fill_with_values(&missing, enum_prompt_fn)?; - apply_enum_fill_with_to_plan(plan, &collected); - } - - Ok(()) -} - -/// Reason why a table needs to be recreated. -#[derive(Debug, Clone, PartialEq, Eq)] -enum RecreateReason { - /// A new non-nullable FK column is being added. - AddColumnWithFk, - /// A FK constraint is being added to an existing non-nullable column. - AddFkToExistingColumn, -} - -/// A table that needs to be recreated because of a non-nullable FK constraint issue. -#[derive(Debug, Clone, PartialEq, Eq)] -struct RecreateTableRequired { - table: String, - column: String, - reason: RecreateReason, -} - -/// Find actions that require table recreation due to non-nullable FK constraints. -/// -/// Two cases are detected: -/// 1. **AddColumn with FK**: A new non-nullable FK column is being added (no default). -/// 2. **AddConstraint(FK) on existing column**: A FK constraint is being added to an -/// existing non-nullable column without a default. -/// -/// In both cases, existing rows cannot satisfy the foreign key constraint, -/// so the table must be recreated (DeleteTable + CreateTable). -fn find_non_nullable_fk_add_columns( - plan: &MigrationPlan, - current_models: &[TableDef], -) -> Vec { - use std::collections::HashSet; - - // Collect FK columns from AddConstraint actions - let mut fk_columns: HashSet<(String, String)> = HashSet::new(); - for action in &plan.actions { - if let MigrationAction::AddConstraint { - table, - constraint: TableConstraint::ForeignKey { columns, .. }, - } = action - { - for col in columns { - fk_columns.insert((table.clone(), col.to_string())); - } - } - } - - // Collect columns being added in this migration (to distinguish new vs existing) - let mut added_columns: HashSet<(String, String)> = HashSet::new(); - for action in &plan.actions { - if let MigrationAction::AddColumn { table, column, .. } = action { - added_columns.insert((table.clone(), column.name.clone())); - } - } - - let mut result = Vec::new(); - - // Case 1: AddColumn with FK (new non-nullable FK column) - for action in &plan.actions { - if let MigrationAction::AddColumn { table, column, .. } = action { - let has_fk = column.foreign_key.is_some() - || fk_columns.contains(&(table.clone(), column.name.to_string())); - if has_fk && !column.nullable && column.default.is_none() { - result.push(RecreateTableRequired { - table: table.clone(), - column: column.name.clone(), - reason: RecreateReason::AddColumnWithFk, - }); - } - } - } - - // Case 2: AddConstraint(FK) on existing non-nullable column - for action in &plan.actions { - if let MigrationAction::AddConstraint { - table, - constraint: TableConstraint::ForeignKey { columns, .. }, - } = action - { - for col_name in columns { - // Skip if this column is being added in this migration (handled by Case 1) - if added_columns.contains(&(table.clone(), col_name.to_string())) { - continue; - } - // Look up column in current models to check nullability - if let Some(model) = current_models - .iter() - .find(|m| m.name.as_str() == table.as_str()) - && let Some(col_def) = model - .columns - .iter() - .find(|c| c.name.as_str() == col_name.as_str()) - && !col_def.nullable - && col_def.default.is_none() - { - result.push(RecreateTableRequired { - table: table.clone(), - column: col_name.clone(), - reason: RecreateReason::AddFkToExistingColumn, - }); - } - } - } - } - - result -} - -/// Prompt the user to confirm table recreation. -/// Returns true if the user confirms, false otherwise. -#[cfg(not(tarpaulin_include))] -fn prompt_recreate_tables(tables: &[RecreateTableRequired]) -> Result { - println!( - "\n{} {}", - "\u{26a0}".bright_yellow(), - "The following tables need to be RECREATED:".bright_yellow() - ); - println!("{}", "\u{2500}".repeat(60).bright_black()); - - for item in tables { - let reason_msg = match item.reason { - RecreateReason::AddColumnWithFk => "adding required FK column", - RecreateReason::AddFkToExistingColumn => "adding FK to existing required column", - }; - println!( - " {} Table {} \u{2014} {} {}", - "\u{2022}".bright_cyan(), - item.table.bright_white(), - reason_msg, - item.column.bright_green() - ); - } - - println!("{}", "\u{2500}".repeat(60).bright_black()); - println!( - " {} {}", - "\u{26a0}".bright_red(), - "ALL DATA in these tables will be DELETED.".bright_red() - ); - - let confirmed = Confirm::new() - .with_prompt(" Proceed with table recreation?") - .default(false) - .interact() - .context("failed to read confirmation")?; - - Ok(confirmed) -} - -/// Rewrite the migration plan to recreate tables instead of adding columns. -/// Removes all column/constraint actions targeting the recreated tables and replaces -/// them with DeleteTable + CreateTable using the full target model. -fn rewrite_plan_for_recreation( - plan: &mut MigrationPlan, - recreate_tables: &[RecreateTableRequired], - current_models: &[TableDef], -) { - use std::collections::HashSet; - - let tables_to_recreate: HashSet<&str> = - recreate_tables.iter().map(|r| r.table.as_str()).collect(); - - // Remove all column/constraint actions targeting recreated tables - plan.actions.retain(|action| { - let table = match action { - MigrationAction::AddColumn { table, .. } - | MigrationAction::DeleteColumn { table, .. } - | MigrationAction::RenameColumn { table, .. } - | MigrationAction::ModifyColumnType { table, .. } - | MigrationAction::ModifyColumnNullable { table, .. } - | MigrationAction::ModifyColumnDefault { table, .. } - | MigrationAction::ModifyColumnComment { table, .. } - | MigrationAction::AddConstraint { table, .. } - | MigrationAction::RemoveConstraint { table, .. } - | MigrationAction::ReplaceConstraint { table, .. } => Some(table.as_str()), - _ => None, - }; - table.is_none_or(|t| !tables_to_recreate.contains(t)) - }); - - // Add DeleteTable + CreateTable for each recreated table - for table_name in &tables_to_recreate { - if let Some(model) = current_models - .iter() - .find(|m| m.name.as_str() == *table_name) - { - plan.actions.push(MigrationAction::DeleteTable { - table: table_name.to_string(), - }); - plan.actions.push(MigrationAction::CreateTable { - table: model.name.clone(), - columns: model.columns.clone(), - constraints: model.constraints.clone(), - }); - } - } -} - -fn handle_recreate_requirements( - plan: &mut MigrationPlan, - current_models: &[TableDef], - prompt_fn: F, -) -> Result<()> -where - F: Fn(&[RecreateTableRequired]) -> Result, -{ - let recreate_tables = find_non_nullable_fk_add_columns(plan, current_models); - if recreate_tables.is_empty() { - return Ok(()); - } - - if !prompt_fn(&recreate_tables)? { - anyhow::bail!( - "Migration cancelled. To proceed without recreation, make the column nullable or add it with a default value that references an existing row." - ); - } - - rewrite_plan_for_recreation(plan, &recreate_tables, current_models); - Ok(()) -} - -pub async fn cmd_revision( - message: String, - fill_with_args: Vec, - delete_null_rows_args: Vec, -) -> Result<()> { - cmd_revision_core( - message, - fill_with_args, - delete_null_rows_args, - RevisionPromptFns { - recreate_prompt_fn: prompt_recreate_tables, - delete_null_rows_prompt_fn: prompt_delete_null_rows, - fill_with_prompt_fn: prompt_fill_with_value, - enum_prompt_fn: prompt_enum_value, - enum_bare_prompt_fn: prompt_enum_value_bare, - }, - ) - .await -} - -struct RevisionPromptFns { - recreate_prompt_fn: R, - delete_null_rows_prompt_fn: D, - fill_with_prompt_fn: F, - enum_prompt_fn: E, - enum_bare_prompt_fn: EB, -} - -async fn cmd_revision_core( - message: String, - fill_with_args: Vec, - delete_null_rows_args: Vec, - prompt_fns: RevisionPromptFns, -) -> Result<()> -where - R: Fn(&[RecreateTableRequired]) -> Result, - D: Fn(&str, &str) -> Result, - F: Fn(&str, &str) -> Result, - E: Fn(&str, &[String]) -> Result, - EB: Fn(&str, &[String]) -> Result, -{ - let RevisionPromptFns { - recreate_prompt_fn, - delete_null_rows_prompt_fn, - fill_with_prompt_fn, - enum_prompt_fn, - enum_bare_prompt_fn, - } = prompt_fns; - - let config = load_config()?; - let current_models = load_models(&config)?; - let applied_plans = load_migrations(&config)?; - - let mut plan = plan_next_migration(¤t_models, &applied_plans) - .map_err(|e| anyhow::anyhow!("planning error: {}", e))?; - - // Check for non-nullable FK changes that require table recreation. - handle_recreate_requirements(&mut plan, ¤t_models, recreate_prompt_fn)?; - - if plan.actions.is_empty() { - println!( - "{} {}", - "No changes detected.".bright_yellow(), - "Nothing to migrate.".bright_white() - ); - return Ok(()); - } - - // Reconstruct baseline schema for column type lookups - let baseline_schema = schema_from_plans(&applied_plans) - .map_err(|e| anyhow::anyhow!("schema reconstruction error: {}", e))?; - - // Parse CLI fill_with arguments - let mut fill_values = parse_fill_with_args(&fill_with_args); - let delete_set = parse_delete_null_rows_args(&delete_null_rows_args); - - // Apply any CLI-provided fill_with values first - apply_fill_with_to_plan(&mut plan, &fill_values); - apply_delete_null_rows_to_plan(&mut plan, &delete_set); - - // Find all missing fill_with values - let mut missing = find_missing_fill_with(&plan, &baseline_schema); - - // Handle FK columns with delete_null_rows option first - if !missing.is_empty() { - handle_delete_null_rows( - &mut plan, - &mut missing, - &delete_set, - delete_null_rows_prompt_fn, - )?; - } - - // Handle remaining missing fill_with values interactively - if !missing.is_empty() { - collect_fill_with_values( - &missing, - &mut fill_values, - fill_with_prompt_fn, - enum_prompt_fn, - )?; - apply_fill_with_to_plan(&mut plan, &fill_values); - } - - // Handle any missing enum fill_with values (for removed enum values) interactively - handle_missing_enum_fill_with(&mut plan, &baseline_schema, enum_bare_prompt_fn)?; - - plan.id = uuid::Uuid::new_v4().to_string(); - plan.comment = Some(message); - if plan.created_at.is_none() { - // Record creation time in RFC3339 (UTC). - plan.created_at = Some(Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)); - } - - let migrations_dir = config.migrations_dir(); - if !migrations_dir.exists() { - fs::create_dir_all(&migrations_dir) - .await - .context("create migrations directory")?; - } - - let format = config.migration_format(); - let filename = migration_filename_with_format_and_pattern( - plan.version, - plan.comment.as_deref(), - format, - config.migration_filename_pattern(), - ); - let path = migrations_dir.join(&filename); - - let schema_url = schema_url_for(format); - match format { - FileFormat::Json => write_json_with_schema(&path, &plan, &schema_url).await?, - FileFormat::Yaml | FileFormat::Yml => write_yaml(&path, &plan, &schema_url).await?, - } - - println!( - "{} {}", - "Created migration:".bright_green().bold(), - format!("{}", path.display()).bright_white() - ); - println!( - " {} {}", - "Version:".bright_cyan(), - plan.version.to_string().bright_magenta().bold() - ); - println!( - " {} {}", - "Actions:".bright_cyan(), - plan.actions.len().to_string().bright_yellow() - ); - if let Some(comment) = &plan.comment { - println!(" {} {}", "Comment:".bright_cyan(), comment.bright_white()); - } - - Ok(()) -} - -fn schema_url_for(format: FileFormat) -> String { - // If not set, default to public raw GitHub schema location. - // Users can override via VESP_SCHEMA_BASE_URL. - let base = std::env::var("VESP_SCHEMA_BASE_URL").ok(); - let base = base.as_deref().unwrap_or( - "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas", - ); - let base = base.trim_end_matches('/'); - match format { - FileFormat::Json => format!("{}/migration.schema.json", base), - FileFormat::Yaml | FileFormat::Yml => format!("{}/migration.schema.json", base), - } -} - -async fn write_json_with_schema(path: &Path, plan: &MigrationPlan, schema_url: &str) -> Result<()> { - let mut value = serde_json::to_value(plan).context("serialize migration plan to json")?; - if let Value::Object(ref mut map) = value { - map.insert("$schema".to_string(), Value::String(schema_url.to_string())); - } - let text = serde_json::to_string_pretty(&value).context("stringify json with schema")?; - fs::write(path, text) - .await - .with_context(|| format!("write file: {}", path.display()))?; - Ok(()) -} - -async fn write_yaml(path: &Path, plan: &MigrationPlan, schema_url: &str) -> Result<()> { - let mut value = serde_yaml::to_value(plan).context("serialize migration plan to yaml value")?; - if let serde_yaml::Value::Mapping(ref mut map) = value { - map.insert( - serde_yaml::Value::String("$schema".to_string()), - serde_yaml::Value::String(schema_url.to_string()), - ); - } - let text = serde_yaml::to_string(&value).context("serialize yaml with schema")?; - fs::write(path, text) - .await - .with_context(|| format!("write file: {}", path.display()))?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::{env, fs as std_fs, path::PathBuf}; - use tempfile::tempdir; - use vespertide_config::{FileFormat, VespertideConfig}; - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType, TableConstraint, TableDef}; - - struct CwdGuard { - original: PathBuf, - } - - impl CwdGuard { - fn new(dir: &PathBuf) -> Self { - let original = env::current_dir().unwrap(); - env::set_current_dir(dir).unwrap(); - Self { original } - } - } - - impl Drop for CwdGuard { - fn drop(&mut self) { - let _ = env::set_current_dir(&self.original); - } - } - - fn write_config() -> VespertideConfig { - write_config_with_format(None) - } - - fn write_config_with_format(fmt: Option) -> VespertideConfig { - let mut cfg = VespertideConfig::default(); - if let Some(f) = fmt { - cfg.migration_format = f; - } - let text = serde_json::to_string_pretty(&cfg).unwrap(); - std_fs::write("vespertide.json", text).unwrap(); - cfg - } - - fn write_model(name: &str) { - let models_dir = PathBuf::from("models"); - std_fs::create_dir_all(&models_dir).unwrap(); - let table = TableDef { - name: name.to_string(), - description: None, - columns: vec![ColumnDef { - name: "id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }], - constraints: vec![TableConstraint::PrimaryKey { - auto_increment: false, - columns: vec!["id".into()], - }], - }; - let path = models_dir.join(format!("{name}.json")); - std_fs::write(path, serde_json::to_string_pretty(&table).unwrap()).unwrap(); - } - - #[tokio::test] - #[serial_test::serial] - async fn cmd_revision_writes_migration() { - let tmp = tempdir().unwrap(); - let _guard = CwdGuard::new(&tmp.path().to_path_buf()); - - let cfg = write_config(); - write_model("users"); - std_fs::create_dir_all(cfg.migrations_dir()).unwrap(); - - cmd_revision("init".into(), vec![], vec![]).await.unwrap(); - - let entries: Vec<_> = std_fs::read_dir(cfg.migrations_dir()).unwrap().collect(); - assert!(!entries.is_empty()); - } - - #[tokio::test] - #[serial_test::serial] - async fn cmd_revision_no_changes_short_circuits() { - let tmp = tempdir().unwrap(); - let _guard = CwdGuard::new(&tmp.path().to_path_buf()); - - let cfg = write_config(); - // no models, no migrations -> plan with no actions -> early return - assert!(cmd_revision("noop".into(), vec![], vec![]).await.is_ok()); - // migrations dir should not be created - assert!(!cfg.migrations_dir().exists()); - } - - #[tokio::test] - #[serial_test::serial] - async fn cmd_revision_writes_yaml_when_configured() { - let tmp = tempdir().unwrap(); - let _guard = CwdGuard::new(&tmp.path().to_path_buf()); - - let cfg = write_config_with_format(Some(FileFormat::Yaml)); - write_model("users"); - // ensure migrations dir absent to exercise create_dir_all branch - if cfg.migrations_dir().exists() { - std_fs::remove_dir_all(cfg.migrations_dir()).unwrap(); - } - - cmd_revision("yaml".into(), vec![], vec![]).await.unwrap(); - - let entries: Vec<_> = std_fs::read_dir(cfg.migrations_dir()).unwrap().collect(); - assert!(!entries.is_empty()); - let has_yaml = entries.iter().any(|e| { - e.as_ref() - .unwrap() - .path() - .extension() - .map(|s| s == "yaml") - .unwrap_or(false) - }); - assert!(has_yaml); - } - - #[test] - fn find_non_nullable_fk_add_column_detects_recreate() { - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; - let plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![ - MigrationAction::AddColumn { - table: "post".into(), - column: Box::new(ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: Some("1".into()), - }, - MigrationAction::AddConstraint { - table: "post".into(), - constraint: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "user".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }, - ], - }; - let result = find_non_nullable_fk_add_columns(&plan, &[]); - assert_eq!(result.len(), 1); - assert_eq!(result[0].table, "post"); - assert_eq!(result[0].column, "user_id"); - assert_eq!(result[0].reason, RecreateReason::AddColumnWithFk); - } - - #[test] - fn find_non_nullable_inline_fk_add_column_detects_recreate() { - use vespertide_core::schema::foreign_key::{ForeignKeyDef, ForeignKeySyntax}; - use vespertide_core::{ColumnDef, ColumnType, ReferenceAction, SimpleColumnType}; - - let plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![MigrationAction::AddColumn { - table: "post".into(), - column: Box::new(ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: Some(ForeignKeySyntax::Object(ForeignKeyDef { - ref_table: "user".into(), - ref_columns: vec!["id".into()], - on_delete: Some(ReferenceAction::Cascade), - on_update: None, - })), - }), - fill_with: None, - }], - }; - - let result = find_non_nullable_fk_add_columns(&plan, &[]); - assert_eq!(result.len(), 1); - assert_eq!(result[0].table, "post"); - assert_eq!(result[0].column, "user_id"); - assert_eq!(result[0].reason, RecreateReason::AddColumnWithFk); - } - - #[test] - fn find_nullable_fk_add_column_returns_empty() { - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; - let plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![ - MigrationAction::AddColumn { - table: "post".into(), - column: Box::new(ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: true, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }, - MigrationAction::AddConstraint { - table: "post".into(), - constraint: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "user".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }, - ], - }; - assert!(find_non_nullable_fk_add_columns(&plan, &[]).is_empty()); - } - - #[test] - fn find_non_nullable_no_fk_returns_empty() { - // Regular non-nullable column without FK should NOT trigger recreation - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; - let plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![MigrationAction::AddColumn { - table: "post".into(), - column: Box::new(ColumnDef { - name: "user_id1".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }], - }; - // Should return empty — this column needs fill_with but that's handled separately - assert!(find_non_nullable_fk_add_columns(&plan, &[]).is_empty()); - } - - #[test] - fn find_fk_on_existing_non_nullable_column_detects_recreate() { - // Adding FK constraint to an existing non-nullable column should trigger recreation - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; - let plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![MigrationAction::AddConstraint { - table: "post".into(), - constraint: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "user".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }], - }; - let models = vec![TableDef { - name: "post".into(), - description: None, - columns: vec![ - ColumnDef { - name: "id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ], - constraints: vec![], - }]; - let result = find_non_nullable_fk_add_columns(&plan, &models); - assert_eq!(result.len(), 1); - assert_eq!(result[0].table, "post"); - assert_eq!(result[0].column, "user_id"); - assert_eq!(result[0].reason, RecreateReason::AddFkToExistingColumn); - } - - #[test] - fn find_fk_on_existing_nullable_column_returns_empty() { - // Adding FK constraint to an existing nullable column should NOT trigger recreation - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; - let plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![MigrationAction::AddConstraint { - table: "post".into(), - constraint: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "user".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }], - }; - let models = vec![TableDef { - name: "post".into(), - description: None, - columns: vec![ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: true, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }], - constraints: vec![], - }]; - assert!(find_non_nullable_fk_add_columns(&plan, &models).is_empty()); - } - - #[test] - fn find_fk_on_existing_column_with_default_returns_empty() { - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; - - let plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![MigrationAction::AddConstraint { - table: "post".into(), - constraint: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "user".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }], - }; - let models = vec![TableDef { - name: "post".into(), - description: None, - columns: vec![ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: Some(true.into()), - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }], - constraints: vec![], - }]; - - assert!(find_non_nullable_fk_add_columns(&plan, &models).is_empty()); - } - - #[test] - fn find_fk_on_existing_column_missing_from_model_returns_empty() { - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; - - let plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![MigrationAction::AddConstraint { - table: "post".into(), - constraint: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "user".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }], - }; - let models = vec![TableDef { - name: "post".into(), - description: None, - columns: vec![ColumnDef { - name: "other_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }], - constraints: vec![], - }]; - - assert!(find_non_nullable_fk_add_columns(&plan, &models).is_empty()); - } - - #[test] - fn rewrite_plan_replaces_actions_with_recreate() { - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![ - MigrationAction::AddColumn { - table: "post".into(), - column: Box::new(ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }, - MigrationAction::AddConstraint { - table: "post".into(), - constraint: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "user".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }, - ], - }; - - let recreate = vec![RecreateTableRequired { - table: "post".into(), - column: "user_id".into(), - reason: RecreateReason::AddColumnWithFk, - }]; - - let models = vec![TableDef { - name: "post".into(), - description: None, - columns: vec![ - ColumnDef { - name: "id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ], - constraints: vec![], - }]; - - rewrite_plan_for_recreation(&mut plan, &recreate, &models); - - assert_eq!(plan.actions.len(), 2); - assert!( - matches!(&plan.actions[0], MigrationAction::DeleteTable { table } if table == "post") - ); - assert!( - matches!(&plan.actions[1], MigrationAction::CreateTable { table, .. } if table == "post") - ); - } - - #[test] - fn rewrite_plan_keeps_non_table_actions() { - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![ - MigrationAction::RawSql { - sql: "select 1".into(), - }, - MigrationAction::AddColumn { - table: "post".into(), - column: Box::new(ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }, - ], - }; - - let recreate = vec![RecreateTableRequired { - table: "post".into(), - column: "user_id".into(), - reason: RecreateReason::AddColumnWithFk, - }]; - - let models = vec![TableDef { - name: "post".into(), - description: None, - columns: vec![ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }], - constraints: vec![], - }]; - - rewrite_plan_for_recreation(&mut plan, &recreate, &models); - - assert!(matches!(&plan.actions[0], MigrationAction::RawSql { sql } if sql == "select 1")); - assert!( - matches!(&plan.actions[1], MigrationAction::DeleteTable { table } if table == "post") - ); - assert!( - matches!(&plan.actions[2], MigrationAction::CreateTable { table, .. } if table == "post") - ); - } - - #[test] - fn handle_recreate_requirements_returns_ok_when_no_fk() { - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::RawSql { - sql: "select 1".into(), - }], - }; - - handle_recreate_requirements(&mut plan, &[], |_| Ok(true)).unwrap(); - - assert_eq!(plan.actions.len(), 1); - } - - #[test] - fn handle_recreate_requirements_bails_when_prompt_rejected() { - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![ - MigrationAction::AddColumn { - table: "post".into(), - column: Box::new(ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }, - MigrationAction::AddConstraint { - table: "post".into(), - constraint: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "user".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }, - ], - }; - - let err = handle_recreate_requirements(&mut plan, &[], |_| Ok(false)).unwrap_err(); - - assert!( - err.to_string() - .contains("Migration cancelled. To proceed without recreation") - ); - } - - #[test] - fn handle_recreate_requirements_empties_plan_when_model_missing() { - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![ - MigrationAction::AddColumn { - table: "post".into(), - column: Box::new(ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }, - MigrationAction::AddConstraint { - table: "post".into(), - constraint: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "user".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }, - ], - }; - - handle_recreate_requirements(&mut plan, &[], |_| Ok(true)).unwrap(); - - assert!(plan.actions.is_empty()); - } - - #[test] - fn handle_recreate_requirements_rewrites_plan_when_model_exists() { - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![ - MigrationAction::AddColumn { - table: "post".into(), - column: Box::new(ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }, - MigrationAction::AddConstraint { - table: "post".into(), - constraint: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "user".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }, - ], - }; - - let models = vec![TableDef { - name: "post".into(), - description: None, - columns: vec![ - ColumnDef { - name: "id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ], - constraints: vec![], - }]; - - handle_recreate_requirements(&mut plan, &models, |_| Ok(true)).unwrap(); - - assert_eq!(plan.actions.len(), 2); - assert!( - matches!(&plan.actions[0], MigrationAction::DeleteTable { table } if table == "post") - ); - assert!( - matches!(&plan.actions[1], MigrationAction::CreateTable { table, .. } if table == "post") - ); - } - - #[test] - fn test_parse_fill_with_args() { - let args = vec![ - "users.email=default@example.com".to_string(), - "orders.status=pending".to_string(), - ]; - let result = parse_fill_with_args(&args); - - assert_eq!(result.len(), 2); - assert_eq!( - result.get(&("users".to_string(), "email".to_string())), - Some(&"default@example.com".to_string()) - ); - assert_eq!( - result.get(&("orders".to_string(), "status".to_string())), - Some(&"pending".to_string()) - ); - } - - #[test] - fn test_parse_fill_with_args_invalid_format() { - let args = vec![ - "invalid_format".to_string(), - "no_equals_sign".to_string(), - "users.email=valid".to_string(), - ]; - let result = parse_fill_with_args(&args); - - // Only the valid one should be parsed - assert_eq!(result.len(), 1); - assert_eq!( - result.get(&("users".to_string(), "email".to_string())), - Some(&"valid".to_string()) - ); - } - - #[test] - fn test_apply_fill_with_to_plan_add_column() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::AddColumn { - table: "users".into(), - column: Box::new(ColumnDef { - name: "email".into(), - r#type: ColumnType::Simple(SimpleColumnType::Text), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }], - }; - - let mut fill_values = HashMap::new(); - fill_values.insert( - ("users".to_string(), "email".to_string()), - "'default@example.com'".to_string(), - ); - - apply_fill_with_to_plan(&mut plan, &fill_values); - - match &plan.actions[0] { - MigrationAction::AddColumn { fill_with, .. } => { - assert_eq!(fill_with, &Some("'default@example.com'".to_string())); - } - _ => panic!("Expected AddColumn action"), - } - } - - #[test] - fn test_apply_fill_with_to_plan_modify_column_nullable() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::ModifyColumnNullable { - table: "users".into(), - column: "status".into(), - nullable: false, - fill_with: None, - delete_null_rows: None, - }], - }; - - let mut fill_values = HashMap::new(); - fill_values.insert( - ("users".to_string(), "status".to_string()), - "'active'".to_string(), - ); - - apply_fill_with_to_plan(&mut plan, &fill_values); - - match &plan.actions[0] { - MigrationAction::ModifyColumnNullable { fill_with, .. } => { - assert_eq!(fill_with, &Some("'active'".to_string())); - } - _ => panic!("Expected ModifyColumnNullable action"), - } - } - - #[test] - fn test_apply_fill_with_to_plan_skips_existing_fill_with() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::AddColumn { - table: "users".into(), - column: Box::new(ColumnDef { - name: "email".into(), - r#type: ColumnType::Simple(SimpleColumnType::Text), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: Some("'existing@example.com'".to_string()), - }], - }; - - let mut fill_values = HashMap::new(); - fill_values.insert( - ("users".to_string(), "email".to_string()), - "'new@example.com'".to_string(), - ); - - apply_fill_with_to_plan(&mut plan, &fill_values); - - // Should keep existing value, not replace with new - match &plan.actions[0] { - MigrationAction::AddColumn { fill_with, .. } => { - assert_eq!(fill_with, &Some("'existing@example.com'".to_string())); - } - _ => panic!("Expected AddColumn action"), - } - } - - #[test] - fn test_apply_fill_with_to_plan_no_match() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::AddColumn { - table: "users".into(), - column: Box::new(ColumnDef { - name: "email".into(), - r#type: ColumnType::Simple(SimpleColumnType::Text), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }], - }; - - let mut fill_values = HashMap::new(); - fill_values.insert( - ("orders".to_string(), "status".to_string()), - "'pending'".to_string(), - ); - - apply_fill_with_to_plan(&mut plan, &fill_values); - - // Should remain None since no match - match &plan.actions[0] { - MigrationAction::AddColumn { fill_with, .. } => { - assert_eq!(fill_with, &None); - } - _ => panic!("Expected AddColumn action"), - } - } - - #[test] - fn test_apply_fill_with_to_plan_multiple_actions() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![ - MigrationAction::AddColumn { - table: "users".into(), - column: Box::new(ColumnDef { - name: "email".into(), - r#type: ColumnType::Simple(SimpleColumnType::Text), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }, - MigrationAction::ModifyColumnNullable { - table: "orders".into(), - column: "status".into(), - nullable: false, - fill_with: None, - delete_null_rows: None, - }, - ], - }; - - let mut fill_values = HashMap::new(); - fill_values.insert( - ("users".to_string(), "email".to_string()), - "'user@example.com'".to_string(), - ); - fill_values.insert( - ("orders".to_string(), "status".to_string()), - "'pending'".to_string(), - ); - - apply_fill_with_to_plan(&mut plan, &fill_values); - - match &plan.actions[0] { - MigrationAction::AddColumn { fill_with, .. } => { - assert_eq!(fill_with, &Some("'user@example.com'".to_string())); - } - _ => panic!("Expected AddColumn action"), - } - - match &plan.actions[1] { - MigrationAction::ModifyColumnNullable { fill_with, .. } => { - assert_eq!(fill_with, &Some("'pending'".to_string())); - } - _ => panic!("Expected ModifyColumnNullable action"), - } - } - - #[test] - fn test_apply_fill_with_to_plan_other_actions_ignored() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::DeleteColumn { - table: "users".into(), - column: "old_column".into(), - }], - }; - - let mut fill_values = HashMap::new(); - fill_values.insert( - ("users".to_string(), "old_column".to_string()), - "'value'".to_string(), - ); - - // Should not panic or modify anything - apply_fill_with_to_plan(&mut plan, &fill_values); - - match &plan.actions[0] { - MigrationAction::DeleteColumn { table, column } => { - assert_eq!(table, "users"); - assert_eq!(column, "old_column"); - } - _ => panic!("Expected DeleteColumn action"), - } - } - - #[test] - fn test_format_type_info_with_type_and_default() { - let result = format_type_info("integer", "0"); - assert_eq!(result, " (integer, default: 0)"); - } - - #[test] - fn test_format_type_info_with_type_only() { - let result = format_type_info("text", "''"); - assert_eq!(result, " (text, default: '')"); - } - - #[test] - fn test_format_fill_with_item() { - let result = format_fill_with_item("users", "email", " (Text)", "AddColumn"); - // The result should contain the table, column, type info, and action type - // Colors make exact matching difficult, but we can check structure - assert!(result.contains("users")); - assert!(result.contains("email")); - assert!(result.contains("(Text)")); - assert!(result.contains("AddColumn")); - assert!(result.contains("Action:")); - } - - #[test] - fn test_format_fill_with_item_empty_type_info() { - let result = format_fill_with_item("orders", "status", "", "ModifyColumnNullable"); - assert!(result.contains("orders")); - assert!(result.contains("status")); - assert!(result.contains("ModifyColumnNullable")); - } - - #[test] - fn test_format_fill_with_prompt() { - let result = format_fill_with_prompt("users", "email"); - assert!(result.contains("Enter fill value for")); - assert!(result.contains("users")); - assert!(result.contains("email")); - } - - #[test] - fn test_print_fill_with_item_and_get_prompt() { - // This function prints to stdout and returns the prompt string - let prompt = - print_fill_with_item_and_get_prompt("users", "email", "text", "''", "AddColumn"); - assert!(prompt.contains("Enter fill value for")); - assert!(prompt.contains("users")); - assert!(prompt.contains("email")); - } - - #[test] - fn test_print_fill_with_item_and_get_prompt_no_default() { - let prompt = print_fill_with_item_and_get_prompt( - "orders", - "status", - "text", - "''", - "ModifyColumnNullable", - ); - assert!(prompt.contains("Enter fill value for")); - assert!(prompt.contains("orders")); - assert!(prompt.contains("status")); - } - - #[test] - fn test_print_fill_with_item_and_get_prompt_with_default() { - let prompt = - print_fill_with_item_and_get_prompt("users", "age", "integer", "0", "AddColumn"); - assert!(prompt.contains("Enter fill value for")); - assert!(prompt.contains("users")); - assert!(prompt.contains("age")); - } - - #[test] - fn test_print_fill_with_header() { - // Just verify it doesn't panic - output goes to stdout - print_fill_with_header(); - } - - #[test] - fn test_print_fill_with_footer() { - // Just verify it doesn't panic - output goes to stdout - print_fill_with_footer(); - } - - // Mock enum prompt function for tests - returns first enum value quoted - fn mock_enum_prompt(_prompt: &str, values: &[String]) -> Result { - Ok(format!("'{}'", values[0])) - } - - #[test] - fn test_collect_fill_with_values_single_item() { - use vespertide_planner::FillWithRequired; - - let missing = vec![FillWithRequired { - action_index: 0, - table: "users".to_string(), - column: "email".to_string(), - action_type: "AddColumn", - column_type: "text".to_string(), - default_value: "''".to_string(), - enum_values: None, - has_foreign_key: false, - }]; - - let mut fill_values = HashMap::new(); - - // Mock prompt function that returns a fixed value - let mock_prompt = |_prompt: &str, _default: &str| -> Result { - Ok("'test@example.com'".to_string()) - }; - - let result = - collect_fill_with_values(&missing, &mut fill_values, mock_prompt, mock_enum_prompt); - assert!(result.is_ok()); - assert_eq!(fill_values.len(), 1); - assert_eq!( - fill_values.get(&("users".to_string(), "email".to_string())), - Some(&"'test@example.com'".to_string()) - ); - } - - #[test] - fn test_collect_fill_with_values_multiple_items() { - use vespertide_planner::FillWithRequired; - - let missing = vec![ - FillWithRequired { - action_index: 0, - table: "users".to_string(), - column: "email".to_string(), - action_type: "AddColumn", - column_type: "text".to_string(), - default_value: "''".to_string(), - enum_values: None, - has_foreign_key: false, - }, - FillWithRequired { - action_index: 1, - table: "orders".to_string(), - column: "status".to_string(), - action_type: "ModifyColumnNullable", - column_type: "text".to_string(), - default_value: "''".to_string(), - enum_values: None, - has_foreign_key: false, - }, - ]; - - let mut fill_values = HashMap::new(); - - // Mock prompt function that returns different values based on call count - let call_count = std::cell::RefCell::new(0); - let mock_prompt = |_prompt: &str, _default: &str| -> Result { - let mut count = call_count.borrow_mut(); - *count += 1; - match *count { - 1 => Ok("'user@example.com'".to_string()), - 2 => Ok("'pending'".to_string()), - _ => Ok("'default'".to_string()), - } - }; - - let result = - collect_fill_with_values(&missing, &mut fill_values, mock_prompt, mock_enum_prompt); - assert!(result.is_ok()); - assert_eq!(fill_values.len(), 2); - assert_eq!( - fill_values.get(&("users".to_string(), "email".to_string())), - Some(&"'user@example.com'".to_string()) - ); - assert_eq!( - fill_values.get(&("orders".to_string(), "status".to_string())), - Some(&"'pending'".to_string()) - ); - } - - #[test] - fn test_collect_fill_with_values_empty() { - let missing: Vec = vec![]; - let mut fill_values = HashMap::new(); - - // This function should handle empty list gracefully (though it won't be called in practice) - // But we can't test the header/footer without items since the function still prints them - // So we test with a mock that would fail if called - let mock_prompt = |_prompt: &str, _default: &str| -> Result { - panic!("Should not be called for empty list"); - }; - - // Note: The function still prints header/footer even for empty list - // This is a design choice - in practice, cmd_revision won't call this with empty list - let result = - collect_fill_with_values(&missing, &mut fill_values, mock_prompt, mock_enum_prompt); - assert!(result.is_ok()); - assert!(fill_values.is_empty()); - } - - #[test] - fn test_collect_fill_with_values_prompt_error() { - use vespertide_planner::FillWithRequired; - - let missing = vec![FillWithRequired { - action_index: 0, - table: "users".to_string(), - column: "email".to_string(), - action_type: "AddColumn", - column_type: "text".to_string(), - default_value: "''".to_string(), - enum_values: None, - has_foreign_key: false, - }]; - - let mut fill_values = HashMap::new(); - - // Mock prompt function that returns an error - let mock_prompt = |_prompt: &str, _default: &str| -> Result { - Err(anyhow::anyhow!("input cancelled")) - }; - - let result = - collect_fill_with_values(&missing, &mut fill_values, mock_prompt, mock_enum_prompt); - assert!(result.is_err()); - assert!(fill_values.is_empty()); - } - - #[test] - fn test_prompt_fill_with_value_function_exists() { - // This test verifies that prompt_fill_with_value has the correct signature. - // We cannot actually call it in tests because dialoguer::Input blocks waiting for terminal input. - // The function is excluded from coverage with #[cfg_attr(coverage_nightly, coverage(off))]. - let _: fn(&str, &str) -> Result = prompt_fill_with_value; - } - - #[test] - fn test_handle_missing_fill_with_collects_and_applies() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::AddColumn { - table: "users".into(), - column: Box::new(ColumnDef { - name: "email".into(), - r#type: ColumnType::Simple(SimpleColumnType::Text), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }], - }; - - let mut fill_values = HashMap::new(); - - // Mock prompt function - let mock_prompt = |_prompt: &str, _default: &str| -> Result { - Ok("'test@example.com'".to_string()) - }; - - let result = handle_missing_fill_with( - &mut plan, - &mut fill_values, - &[], - mock_prompt, - mock_enum_prompt, - ); - assert!(result.is_ok()); - - // Verify fill_with was applied to the plan - match &plan.actions[0] { - MigrationAction::AddColumn { fill_with, .. } => { - assert_eq!(fill_with, &Some("'test@example.com'".to_string())); - } - _ => panic!("Expected AddColumn action"), - } - - // Verify fill_values map was updated - assert_eq!( - fill_values.get(&("users".to_string(), "email".to_string())), - Some(&"'test@example.com'".to_string()) - ); - } - - #[test] - fn test_handle_missing_fill_with_no_missing() { - use vespertide_core::MigrationPlan; - - // Plan with no missing fill_with values (nullable column) - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::AddColumn { - table: "users".into(), - column: Box::new(ColumnDef { - name: "email".into(), - r#type: ColumnType::Simple(SimpleColumnType::Text), - nullable: true, // nullable, so no fill_with required - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }], - }; - - let mut fill_values = HashMap::new(); - - // Mock prompt that should never be called - let mock_prompt = |_prompt: &str, _default: &str| -> Result { - panic!("Should not be called when no missing fill_with values"); - }; - - let result = handle_missing_fill_with( - &mut plan, - &mut fill_values, - &[], - mock_prompt, - mock_enum_prompt, - ); - assert!(result.is_ok()); - assert!(fill_values.is_empty()); - } - - #[test] - fn test_handle_missing_fill_with_prompt_error() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::AddColumn { - table: "users".into(), - column: Box::new(ColumnDef { - name: "email".into(), - r#type: ColumnType::Simple(SimpleColumnType::Text), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }], - }; - - let mut fill_values = HashMap::new(); - - // Mock prompt that returns an error - let mock_prompt = |_prompt: &str, _default: &str| -> Result { - Err(anyhow::anyhow!("user cancelled")) - }; - - let result = handle_missing_fill_with( - &mut plan, - &mut fill_values, - &[], - mock_prompt, - mock_enum_prompt, - ); - assert!(result.is_err()); - - // Plan should not be modified on error - match &plan.actions[0] { - MigrationAction::AddColumn { fill_with, .. } => { - assert_eq!(fill_with, &None); - } - _ => panic!("Expected AddColumn action"), - } - } - - #[test] - fn test_handle_missing_fill_with_multiple_columns() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![ - MigrationAction::AddColumn { - table: "users".into(), - column: Box::new(ColumnDef { - name: "email".into(), - r#type: ColumnType::Simple(SimpleColumnType::Text), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }, - MigrationAction::ModifyColumnNullable { - table: "orders".into(), - column: "status".into(), - nullable: false, - fill_with: None, - delete_null_rows: None, - }, - ], - }; - - let mut fill_values = HashMap::new(); - - // Mock prompt that returns different values based on call count - let call_count = std::cell::RefCell::new(0); - let mock_prompt = |_prompt: &str, _default: &str| -> Result { - let mut count = call_count.borrow_mut(); - *count += 1; - match *count { - 1 => Ok("'user@example.com'".to_string()), - 2 => Ok("'pending'".to_string()), - _ => Ok("'default'".to_string()), - } - }; - - let result = handle_missing_fill_with( - &mut plan, - &mut fill_values, - &[], - mock_prompt, - mock_enum_prompt, - ); - assert!(result.is_ok()); - - // Verify both actions were updated - match &plan.actions[0] { - MigrationAction::AddColumn { fill_with, .. } => { - assert_eq!(fill_with, &Some("'user@example.com'".to_string())); - } - _ => panic!("Expected AddColumn action"), - } - - match &plan.actions[1] { - MigrationAction::ModifyColumnNullable { fill_with, .. } => { - assert_eq!(fill_with, &Some("'pending'".to_string())); - } - _ => panic!("Expected ModifyColumnNullable action"), - } - } - - #[test] - fn test_collect_fill_with_values_enum_column() { - use vespertide_planner::FillWithRequired; - - let missing = vec![FillWithRequired { - action_index: 0, - table: "orders".to_string(), - column: "status".to_string(), - action_type: "AddColumn", - column_type: "enum".to_string(), - default_value: "''".to_string(), - enum_values: Some(vec![ - "pending".to_string(), - "confirmed".to_string(), - "shipped".to_string(), - ]), - has_foreign_key: false, - }]; - - let mut fill_values = HashMap::new(); - - // Mock prompt function that should NOT be called for enum columns - let mock_prompt = |_prompt: &str, _default: &str| -> Result { - panic!("Should not be called for enum columns"); - }; - - // Mock enum prompt that selects the second value - let mock_enum = |_prompt: &str, values: &[String]| -> Result { - // Select "confirmed" (index 1) - Ok(format!("'{}'", values[1])) - }; - - let result = collect_fill_with_values(&missing, &mut fill_values, mock_prompt, mock_enum); - assert!(result.is_ok()); - assert_eq!(fill_values.len(), 1); - assert_eq!( - fill_values.get(&("orders".to_string(), "status".to_string())), - Some(&"'confirmed'".to_string()) - ); - } - - #[test] - fn test_wrap_if_spaces_empty() { - assert_eq!(wrap_if_spaces("".to_string()), ""); - } - - #[test] - fn test_wrap_if_spaces_no_spaces() { - assert_eq!(wrap_if_spaces("value".to_string()), "value"); - } - - #[test] - fn test_wrap_if_spaces_with_spaces() { - assert_eq!(wrap_if_spaces("my value".to_string()), "'my value'"); - } - - #[test] - fn test_wrap_if_spaces_already_quoted() { - assert_eq!( - wrap_if_spaces("'already quoted'".to_string()), - "'already quoted'" - ); - } - - #[test] - fn test_wrap_if_spaces_multiple_spaces() { - assert_eq!(wrap_if_spaces("a b c".to_string()), "'a b c'"); - } - - // ── enum fill_with tests ─────────────────────────────────────────── - - #[test] - fn test_collect_enum_fill_with_values_single_removal() { - use vespertide_planner::EnumFillWithRequired; - - let missing = vec![EnumFillWithRequired { - action_index: 0, - table: "orders".to_string(), - column: "status".to_string(), - removed_values: vec!["cancelled".to_string()], - remaining_values: vec!["pending".to_string(), "shipped".to_string()], - }]; - - // Mock prompt: always select first remaining value - let mock_enum = - |_prompt: &str, values: &[String]| -> Result { Ok(values[0].to_string()) }; - - let result = collect_enum_fill_with_values(&missing, mock_enum); - assert!(result.is_ok()); - let collected = result.unwrap(); - assert_eq!(collected.len(), 1); - assert_eq!(collected[0].0, 0); // action_index - assert_eq!( - collected[0].1.get("cancelled"), - Some(&"pending".to_string()) - ); - } - - #[test] - fn test_collect_enum_fill_with_values_multiple_removals() { - use vespertide_planner::EnumFillWithRequired; - - let missing = vec![EnumFillWithRequired { - action_index: 0, - table: "orders".to_string(), - column: "status".to_string(), - removed_values: vec!["cancelled".to_string(), "draft".to_string()], - remaining_values: vec!["pending".to_string(), "shipped".to_string()], - }]; - - // Mock prompt: always select second remaining value - let mock_enum = - |_prompt: &str, values: &[String]| -> Result { Ok(values[1].to_string()) }; - - let result = collect_enum_fill_with_values(&missing, mock_enum); - assert!(result.is_ok()); - let collected = result.unwrap(); - assert_eq!(collected[0].1.len(), 2); - assert_eq!( - collected[0].1.get("cancelled"), - Some(&"shipped".to_string()) - ); - assert_eq!(collected[0].1.get("draft"), Some(&"shipped".to_string())); - } - - #[test] - fn test_apply_enum_fill_with_to_plan() { - use vespertide_core::{ColumnType, ComplexColumnType, EnumValues}; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![MigrationAction::ModifyColumnType { - table: "orders".into(), - column: "status".into(), - new_type: ColumnType::Complex(ComplexColumnType::Enum { - name: "order_status".into(), - values: EnumValues::String(vec!["pending".into(), "shipped".into()]), - }), - fill_with: None, - }], - }; - - let mut mappings = BTreeMap::new(); - mappings.insert("cancelled".to_string(), "pending".to_string()); - let collected = vec![(0usize, mappings)]; - - apply_enum_fill_with_to_plan(&mut plan, &collected); - - if let MigrationAction::ModifyColumnType { fill_with, .. } = &plan.actions[0] { - let fw = fill_with.as_ref().expect("fill_with should be set"); - assert_eq!(fw.get("cancelled"), Some(&"pending".to_string())); - } else { - panic!("Expected ModifyColumnType"); - } - } - - #[test] - fn test_handle_missing_enum_fill_with_collects_and_applies() { - use vespertide_core::{ColumnDef, ColumnType, ComplexColumnType, EnumValues}; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![MigrationAction::ModifyColumnType { - table: "orders".into(), - column: "status".into(), - new_type: ColumnType::Complex(ComplexColumnType::Enum { - name: "order_status".into(), - values: EnumValues::String(vec!["pending".into(), "shipped".into()]), - }), - fill_with: None, - }], - }; - - let baseline = vec![TableDef { - name: "orders".into(), - description: None, - columns: vec![ColumnDef { - name: "status".into(), - r#type: ColumnType::Complex(ComplexColumnType::Enum { - name: "order_status".into(), - values: EnumValues::String(vec![ - "pending".into(), - "shipped".into(), - "cancelled".into(), - ]), - }), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }], - constraints: vec![], - }]; - - // Mock: always select first remaining value - let mock_enum = - |_prompt: &str, values: &[String]| -> Result { Ok(values[0].to_string()) }; - - let result = handle_missing_enum_fill_with(&mut plan, &baseline, mock_enum); - assert!(result.is_ok()); - - if let MigrationAction::ModifyColumnType { fill_with, .. } = &plan.actions[0] { - let fw = fill_with.as_ref().expect("fill_with should be populated"); - assert_eq!(fw.get("cancelled"), Some(&"pending".to_string())); - } else { - panic!("Expected ModifyColumnType"); - } - } - - #[test] - fn test_handle_missing_enum_fill_with_no_missing() { - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![], - }; - - let mock_enum = |_prompt: &str, _values: &[String]| -> Result { - panic!("Should not be called when nothing is missing"); - }; - - let result = handle_missing_enum_fill_with(&mut plan, &[], mock_enum); - assert!(result.is_ok()); - } - - #[test] - fn test_apply_enum_fill_with_to_plan_extends_existing() { - use vespertide_core::{ColumnType, ComplexColumnType, EnumValues}; - - // Start with a fill_with that already has one entry - let mut existing_fw = BTreeMap::new(); - existing_fw.insert("draft".to_string(), "pending".to_string()); - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![MigrationAction::ModifyColumnType { - table: "orders".into(), - column: "status".into(), - new_type: ColumnType::Complex(ComplexColumnType::Enum { - name: "order_status".into(), - values: EnumValues::String(vec!["pending".into(), "shipped".into()]), - }), - fill_with: Some(existing_fw), - }], - }; - - // Collect additional mappings - let mut new_mappings = BTreeMap::new(); - new_mappings.insert("cancelled".to_string(), "shipped".to_string()); - let collected = vec![(0usize, new_mappings)]; - - apply_enum_fill_with_to_plan(&mut plan, &collected); - - if let MigrationAction::ModifyColumnType { fill_with, .. } = &plan.actions[0] { - let fw = fill_with.as_ref().expect("fill_with should be set"); - // Original entry preserved - assert_eq!(fw.get("draft"), Some(&"pending".to_string())); - // New entry added - assert_eq!(fw.get("cancelled"), Some(&"shipped".to_string())); - // Total 2 entries - assert_eq!(fw.len(), 2); - } else { - panic!("Expected ModifyColumnType"); - } - } - - #[test] - fn test_strip_enum_quotes_with_quotes() { - assert_eq!(strip_enum_quotes("'active'".to_string()), "active"); - } - - #[test] - fn test_strip_enum_quotes_bare_value() { - assert_eq!(strip_enum_quotes("active".to_string()), "active"); - } - - #[test] - fn test_strip_enum_quotes_empty() { - assert_eq!(strip_enum_quotes(String::new()), ""); - } - - #[test] - fn test_strip_enum_quotes_only_leading() { - assert_eq!(strip_enum_quotes("'active".to_string()), "active"); - } - - #[test] - fn test_strip_enum_quotes_only_trailing() { - assert_eq!(strip_enum_quotes("active'".to_string()), "active"); - } - - #[test] - fn test_parse_delete_null_rows_args() { - let args = vec!["users.email".to_string(), "orders.user_id".to_string()]; - let result = parse_delete_null_rows_args(&args); - assert_eq!(result.len(), 2); - assert!(result.contains(&("users".to_string(), "email".to_string()))); - assert!(result.contains(&("orders".to_string(), "user_id".to_string()))); - } - - #[test] - fn test_parse_delete_null_rows_args_invalid_format() { - let args = vec!["invalid_no_dot".to_string(), "valid.column".to_string()]; - let result = parse_delete_null_rows_args(&args); - assert_eq!(result.len(), 1); - assert!(result.contains(&("valid".to_string(), "column".to_string()))); - } - - #[test] - fn test_parse_delete_null_rows_args_empty() { - let result = parse_delete_null_rows_args(&[]); - assert!(result.is_empty()); - } - - #[test] - fn test_apply_delete_null_rows_to_plan() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::ModifyColumnNullable { - table: "orders".into(), - column: "user_id".into(), - nullable: false, - fill_with: None, - delete_null_rows: None, - }], - }; - - let mut delete_set = HashSet::new(); - delete_set.insert(("orders".to_string(), "user_id".to_string())); - apply_delete_null_rows_to_plan(&mut plan, &delete_set); - - match &plan.actions[0] { - MigrationAction::ModifyColumnNullable { - delete_null_rows, .. - } => { - assert_eq!(delete_null_rows, &Some(true)); - } - _ => panic!("Expected ModifyColumnNullable action"), - } - } - - #[test] - fn test_apply_delete_null_rows_to_plan_skips_nullable_true() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::ModifyColumnNullable { - table: "orders".into(), - column: "user_id".into(), - nullable: true, - fill_with: None, - delete_null_rows: None, - }], - }; - - let mut delete_set = HashSet::new(); - delete_set.insert(("orders".to_string(), "user_id".to_string())); - apply_delete_null_rows_to_plan(&mut plan, &delete_set); - - match &plan.actions[0] { - MigrationAction::ModifyColumnNullable { - delete_null_rows, .. - } => { - assert_eq!(delete_null_rows, &None); - } - _ => panic!("Expected ModifyColumnNullable action"), - } - } - - #[test] - fn test_apply_delete_null_rows_to_plan_skips_already_set() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::ModifyColumnNullable { - table: "orders".into(), - column: "user_id".into(), - nullable: false, - fill_with: None, - delete_null_rows: Some(false), - }], - }; - - let mut delete_set = HashSet::new(); - delete_set.insert(("orders".to_string(), "user_id".to_string())); - apply_delete_null_rows_to_plan(&mut plan, &delete_set); - - match &plan.actions[0] { - MigrationAction::ModifyColumnNullable { - delete_null_rows, .. - } => { - assert_eq!(delete_null_rows, &Some(false)); - } - _ => panic!("Expected ModifyColumnNullable action"), - } - } - - #[test] - fn test_apply_delete_null_rows_to_plan_no_match() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::ModifyColumnNullable { - table: "orders".into(), - column: "user_id".into(), - nullable: false, - fill_with: None, - delete_null_rows: None, - }], - }; - - let mut delete_set = HashSet::new(); - delete_set.insert(("other_table".to_string(), "other_col".to_string())); - apply_delete_null_rows_to_plan(&mut plan, &delete_set); - - match &plan.actions[0] { - MigrationAction::ModifyColumnNullable { - delete_null_rows, .. - } => { - assert_eq!(delete_null_rows, &None); - } - _ => panic!("Expected ModifyColumnNullable action"), - } - } - - #[test] - fn test_handle_delete_null_rows_fk_accepted() { - use vespertide_core::MigrationPlan; - use vespertide_planner::FillWithRequired; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::ModifyColumnNullable { - table: "orders".into(), - column: "user_id".into(), - nullable: false, - fill_with: None, - delete_null_rows: None, - }], - }; - - let mut missing = vec![FillWithRequired { - action_index: 0, - table: "orders".to_string(), - column: "user_id".to_string(), - action_type: "ModifyColumnNullable", - column_type: "integer".to_string(), - default_value: "0".to_string(), - enum_values: None, - has_foreign_key: true, - }]; - - let delete_set = HashSet::new(); - - let mock_prompt = |_table: &str, _column: &str| -> Result { Ok(true) }; - - let result = handle_delete_null_rows(&mut plan, &mut missing, &delete_set, mock_prompt); - assert!(result.is_ok()); - - assert!(missing.is_empty()); - - match &plan.actions[0] { - MigrationAction::ModifyColumnNullable { - delete_null_rows, .. - } => { - assert_eq!(delete_null_rows, &Some(true)); - } - _ => panic!("Expected ModifyColumnNullable"), - } - } - - #[test] - fn test_handle_delete_null_rows_fk_declined() { - use vespertide_core::MigrationPlan; - use vespertide_planner::FillWithRequired; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::ModifyColumnNullable { - table: "orders".into(), - column: "user_id".into(), - nullable: false, - fill_with: None, - delete_null_rows: None, - }], - }; - - let mut missing = vec![FillWithRequired { - action_index: 0, - table: "orders".to_string(), - column: "user_id".to_string(), - action_type: "ModifyColumnNullable", - column_type: "integer".to_string(), - default_value: "0".to_string(), - enum_values: None, - has_foreign_key: true, - }]; - - let delete_set = HashSet::new(); - - let mock_prompt = |_table: &str, _column: &str| -> Result { Ok(false) }; - - let result = handle_delete_null_rows(&mut plan, &mut missing, &delete_set, mock_prompt); - assert!(result.is_ok()); - - assert_eq!(missing.len(), 1); - assert_eq!(missing[0].table, "orders"); - - match &plan.actions[0] { - MigrationAction::ModifyColumnNullable { - delete_null_rows, .. - } => { - assert_eq!(delete_null_rows, &None); - } - _ => panic!("Expected ModifyColumnNullable"), - } - } - - #[test] - fn test_handle_delete_null_rows_cli_provided() { - use vespertide_core::MigrationPlan; - use vespertide_planner::FillWithRequired; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::ModifyColumnNullable { - table: "orders".into(), - column: "user_id".into(), - nullable: false, - fill_with: None, - delete_null_rows: None, - }], - }; - - let mut missing = vec![FillWithRequired { - action_index: 0, - table: "orders".to_string(), - column: "user_id".to_string(), - action_type: "ModifyColumnNullable", - column_type: "integer".to_string(), - default_value: "0".to_string(), - enum_values: None, - has_foreign_key: false, - }]; - - let mut delete_set = HashSet::new(); - delete_set.insert(("orders".to_string(), "user_id".to_string())); - - let mock_prompt = |_table: &str, _column: &str| -> Result { - panic!("Should not be called for CLI-provided items"); - }; - - let result = handle_delete_null_rows(&mut plan, &mut missing, &delete_set, mock_prompt); - assert!(result.is_ok()); - - assert!(missing.is_empty()); - - match &plan.actions[0] { - MigrationAction::ModifyColumnNullable { - delete_null_rows, .. - } => { - assert_eq!(delete_null_rows, &Some(true)); - } - _ => panic!("Expected ModifyColumnNullable"), - } - } - - #[test] - fn test_handle_delete_null_rows_non_fk_passthrough() { - use vespertide_core::MigrationPlan; - use vespertide_planner::FillWithRequired; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::ModifyColumnNullable { - table: "users".into(), - column: "email".into(), - nullable: false, - fill_with: None, - delete_null_rows: None, - }], - }; - - let mut missing = vec![FillWithRequired { - action_index: 0, - table: "users".to_string(), - column: "email".to_string(), - action_type: "ModifyColumnNullable", - column_type: "text".to_string(), - default_value: "''".to_string(), - enum_values: None, - has_foreign_key: false, - }]; - - let delete_set = HashSet::new(); - - let mock_prompt = |_table: &str, _column: &str| -> Result { - panic!("Should not be called for non-FK items"); - }; - - let result = handle_delete_null_rows(&mut plan, &mut missing, &delete_set, mock_prompt); - assert!(result.is_ok()); - - assert_eq!(missing.len(), 1); - assert_eq!(missing[0].column, "email"); - - match &plan.actions[0] { - MigrationAction::ModifyColumnNullable { - delete_null_rows, .. - } => { - assert_eq!(delete_null_rows, &None); - } - _ => panic!("Expected ModifyColumnNullable"), - } - } - - #[test] - fn test_handle_delete_null_rows_prompt_error() { - use vespertide_core::MigrationPlan; - use vespertide_planner::FillWithRequired; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::ModifyColumnNullable { - table: "orders".into(), - column: "user_id".into(), - nullable: false, - fill_with: None, - delete_null_rows: None, - }], - }; - - let mut missing = vec![FillWithRequired { - action_index: 0, - table: "orders".to_string(), - column: "user_id".to_string(), - action_type: "ModifyColumnNullable", - column_type: "integer".to_string(), - default_value: "0".to_string(), - enum_values: None, - has_foreign_key: true, - }]; - - let delete_set = HashSet::new(); - - let mock_prompt = |_table: &str, _column: &str| -> Result { - Err(anyhow::anyhow!("user cancelled")) - }; - - let result = handle_delete_null_rows(&mut plan, &mut missing, &delete_set, mock_prompt); - assert!(result.is_err()); - } - - /// Integration test: FK column nullable→not-null triggers handle_delete_null_rows (line 489) - #[tokio::test] - #[serial_test::serial] - async fn cmd_revision_core_handles_delete_null_rows_for_fk_column() { - use vespertide_core::MigrationPlan; - use vespertide_core::schema::foreign_key::{ForeignKeyDef, ForeignKeySyntax}; - - let tmp = tempdir().unwrap(); - let _guard = CwdGuard::new(&tmp.path().to_path_buf()); - - let cfg = write_config(); - std_fs::create_dir_all(cfg.migrations_dir()).unwrap(); - - // Write v1 migration: create "orders" table with nullable user_id - let v1 = MigrationPlan { - id: "v1-id".to_string(), - comment: Some("init".to_string()), - created_at: None, - version: 1, - actions: vec![MigrationAction::CreateTable { - table: "orders".into(), - columns: vec![ - ColumnDef { - name: "id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: true, // nullable in v1 - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ], - constraints: vec![ - TableConstraint::PrimaryKey { - auto_increment: false, - columns: vec!["id".into()], - }, - TableConstraint::ForeignKey { - name: Some("fk_orders__user_id".into()), - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - ], - }], - }; - let v1_path = cfg.migrations_dir().join("0001_init.vespertide.json"); - std_fs::write(&v1_path, serde_json::to_string_pretty(&v1).unwrap()).unwrap(); - - // Write updated model: user_id is now NOT NULL - let models_dir = PathBuf::from("models"); - std_fs::create_dir_all(&models_dir).unwrap(); - let users_model = TableDef { - name: "users".to_string(), - description: None, - columns: vec![ColumnDef { - name: "id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }], - constraints: vec![TableConstraint::PrimaryKey { - auto_increment: false, - columns: vec!["id".into()], - }], - }; - std_fs::write( - models_dir.join("users.json"), - serde_json::to_string_pretty(&users_model).unwrap(), - ) - .unwrap(); - - let model = TableDef { - name: "orders".to_string(), - description: None, - columns: vec![ - ColumnDef { - name: "id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: false, // NOT NULL now - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: Some(ForeignKeySyntax::Object(ForeignKeyDef { - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - })), - }, - ], - constraints: vec![TableConstraint::PrimaryKey { - auto_increment: false, - columns: vec!["id".into()], - }], - }; - std_fs::write( - models_dir.join("orders.json"), - serde_json::to_string_pretty(&model).unwrap(), - ) - .unwrap(); - - // Mock prompts - let recreate_prompt = |_: &[RecreateTableRequired]| -> Result { Ok(true) }; - let delete_prompt = |_table: &str, _col: &str| -> Result { Ok(true) }; - let fill_prompt = |_p: &str, _d: &str| -> Result { - panic!("fill prompt should not be called — FK handled by delete_null_rows"); - }; - let enum_prompt = |_p: &str, _v: &[String]| -> Result { - panic!("enum prompt should not be called"); - }; - let enum_bare_prompt = |_p: &str, _v: &[String]| -> Result { - panic!("enum bare prompt should not be called"); - }; - - let result = cmd_revision_core( - "make user_id required".into(), - vec![], - vec![], - RevisionPromptFns { - recreate_prompt_fn: recreate_prompt, - delete_null_rows_prompt_fn: delete_prompt, - fill_with_prompt_fn: fill_prompt, - enum_prompt_fn: enum_prompt, - enum_bare_prompt_fn: enum_bare_prompt, - }, - ) - .await; - - assert!( - result.is_ok(), - "cmd_revision_core failed: {:?}", - result.err() - ); - - // Verify migration was created - let entries: Vec<_> = std_fs::read_dir(cfg.migrations_dir()) - .unwrap() - .filter_map(|e| e.ok()) - .collect(); - // Should have 2 files: v1 + new v2 - assert_eq!(entries.len(), 2); - } - - /// Integration test: non-FK column nullable→not-null triggers collect_fill_with_values (lines 494-495) - #[tokio::test] - #[serial_test::serial] - async fn cmd_revision_core_handles_fill_with_for_non_fk_column() { - use vespertide_core::MigrationPlan; - - let tmp = tempdir().unwrap(); - let _guard = CwdGuard::new(&tmp.path().to_path_buf()); - - let cfg = write_config(); - std_fs::create_dir_all(cfg.migrations_dir()).unwrap(); - - // Write v1 migration: create "users" table with nullable email - let v1 = MigrationPlan { - id: "v1-id".to_string(), - comment: Some("init".to_string()), - created_at: None, - version: 1, - actions: vec![MigrationAction::CreateTable { - table: "users".into(), - columns: vec![ - ColumnDef { - name: "id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ColumnDef { - name: "email".into(), - r#type: ColumnType::Simple(SimpleColumnType::Text), - nullable: true, // nullable in v1 - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ], - constraints: vec![TableConstraint::PrimaryKey { - auto_increment: false, - columns: vec!["id".into()], - }], - }], - }; - let v1_path = cfg.migrations_dir().join("0001_init.vespertide.json"); - std_fs::write(&v1_path, serde_json::to_string_pretty(&v1).unwrap()).unwrap(); - - // Write updated model: email is now NOT NULL (no default) - let models_dir = PathBuf::from("models"); - std_fs::create_dir_all(&models_dir).unwrap(); - let model = TableDef { - name: "users".to_string(), - description: None, - columns: vec![ - ColumnDef { - name: "id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ColumnDef { - name: "email".into(), - r#type: ColumnType::Simple(SimpleColumnType::Text), - nullable: false, // NOT NULL now - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ], - constraints: vec![TableConstraint::PrimaryKey { - auto_increment: false, - columns: vec!["id".into()], - }], - }; - std_fs::write( - models_dir.join("users.json"), - serde_json::to_string_pretty(&model).unwrap(), - ) - .unwrap(); - - // Mock prompts - let recreate_prompt = |_: &[RecreateTableRequired]| -> Result { Ok(true) }; - let delete_prompt = |_table: &str, _col: &str| -> Result { Ok(false) }; - let fill_prompt = |_p: &str, _d: &str| -> Result { Ok("'unknown'".to_string()) }; - let enum_prompt = |_p: &str, _v: &[String]| -> Result { - panic!("enum prompt should not be called"); - }; - let enum_bare_prompt = |_p: &str, _v: &[String]| -> Result { - panic!("enum bare prompt should not be called"); - }; - - let result = cmd_revision_core( - "make email required".into(), - vec![], - vec![], - RevisionPromptFns { - recreate_prompt_fn: recreate_prompt, - delete_null_rows_prompt_fn: delete_prompt, - fill_with_prompt_fn: fill_prompt, - enum_prompt_fn: enum_prompt, - enum_bare_prompt_fn: enum_bare_prompt, - }, - ) - .await; - - assert!( - result.is_ok(), - "cmd_revision_core failed: {:?}", - result.err() - ); - - // Verify migration was written with fill_with - let entries: Vec<_> = std_fs::read_dir(cfg.migrations_dir()) - .unwrap() - .filter_map(|e| e.ok()) - .collect(); - assert_eq!(entries.len(), 2); - - // Read the v2 migration and verify fill_with was applied - let v2_path = entries - .iter() - .find(|e| e.file_name().to_string_lossy().contains("0002")) - .expect("v2 migration not found"); - let v2_content = std_fs::read_to_string(v2_path.path()).unwrap(); - assert!( - v2_content.contains("fill_with"), - "Expected fill_with in migration, got: {}", - v2_content - ); - } -} diff --git a/crates/vespertide-cli/src/commands/revision/emit.rs b/crates/vespertide-cli/src/commands/revision/emit.rs new file mode 100644 index 00000000..69cd8ea9 --- /dev/null +++ b/crates/vespertide-cli/src/commands/revision/emit.rs @@ -0,0 +1,255 @@ +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + +use anyhow::Result; +use vespertide_core::{MigrationAction, MigrationPlan, TableConstraint, TableDef}; + +/// Apply `fill_with` values to a migration plan. +pub(super) fn apply_fill_with_to_plan( + plan: &mut MigrationPlan, + fill_values: &HashMap<(String, String), String>, +) { + for action in &mut plan.actions { + match action { + MigrationAction::AddColumn { + table, + column, + fill_with, + } => { + if fill_with.is_none() + && let Some(value) = fill_values.get(&(table.clone(), column.name.clone())) + { + *fill_with = Some(value.clone()); + } + } + MigrationAction::ModifyColumnNullable { + table, + column, + fill_with, + .. + } => { + if fill_with.is_none() + && let Some(value) = fill_values.get(&(table.clone(), column.clone())) + { + *fill_with = Some(value.clone()); + } + } + _ => {} + } + } +} + +/// Apply `delete_null_rows` flags to matching `ModifyColumnNullable` actions. +pub(super) fn apply_delete_null_rows_to_plan( + plan: &mut MigrationPlan, + delete_set: &HashSet<(String, String)>, +) { + for action in &mut plan.actions { + if let MigrationAction::ModifyColumnNullable { + table, + column, + nullable, + delete_null_rows, + .. + } = action + && !*nullable + && delete_null_rows.is_none() + && delete_set.contains(&(table.clone(), column.clone())) + { + *delete_null_rows = Some(true); + } + } +} +/// Apply collected enum `fill_with` mappings to the migration plan. +pub(super) fn apply_enum_fill_with_to_plan( + plan: &mut MigrationPlan, + collected: &[(usize, BTreeMap)], +) { + for (action_index, mappings) in collected { + if let Some(MigrationAction::ModifyColumnType { fill_with, .. }) = + plan.actions.get_mut(*action_index) + { + match fill_with { + Some(existing) => { + existing.extend(mappings.clone()); + } + None => { + *fill_with = Some(mappings.clone()); + } + } + } + } +} +/// Reason why a table needs to be recreated. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum RecreateReason { + /// A new non-nullable FK column is being added. + AddColumnWithFk, + /// A FK constraint is being added to an existing non-nullable column. + AddFkToExistingColumn, +} + +/// A table that needs to be recreated because of a non-nullable FK constraint issue. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct RecreateTableRequired { + pub(super) table: String, + pub(super) column: String, + pub(super) reason: RecreateReason, +} + +/// Find actions that require table recreation due to non-nullable FK constraints. +/// +/// Two cases are detected: +/// 1. **`AddColumn` with FK**: A new non-nullable FK column is being added (no default). +/// 2. **AddConstraint(FK) on existing column**: A FK constraint is being added to an +/// existing non-nullable column without a default. +/// +/// In both cases, existing rows cannot satisfy the foreign key constraint, +/// so the table must be recreated (`DeleteTable` + `CreateTable`). +pub(super) fn find_non_nullable_fk_add_columns( + plan: &MigrationPlan, + current_models: &[TableDef], +) -> Vec { + // Collect FK columns from AddConstraint actions; lookup-only, ordering unused. + let mut fk_columns: HashSet<(String, String)> = HashSet::new(); + for action in &plan.actions { + if let MigrationAction::AddConstraint { + table, + constraint: TableConstraint::ForeignKey { columns, .. }, + } = action + { + for col in columns { + fk_columns.insert((table.clone(), col.clone())); + } + } + } + + // Collect columns being added in this migration (to distinguish new vs existing); lookup-only, ordering unused. + let mut added_columns: HashSet<(String, String)> = HashSet::new(); + for action in &plan.actions { + if let MigrationAction::AddColumn { table, column, .. } = action { + added_columns.insert((table.clone(), column.name.clone())); + } + } + + let mut result = Vec::new(); + + // Case 1: AddColumn with FK (new non-nullable FK column) + for action in &plan.actions { + if let MigrationAction::AddColumn { table, column, .. } = action { + let has_fk = column.foreign_key.is_some() + || fk_columns.contains(&(table.clone(), column.name.clone())); + if has_fk && !column.nullable && column.default.is_none() { + result.push(RecreateTableRequired { + table: table.clone(), + column: column.name.clone(), + reason: RecreateReason::AddColumnWithFk, + }); + } + } + } + + // Case 2: AddConstraint(FK) on existing non-nullable column + for action in &plan.actions { + if let MigrationAction::AddConstraint { + table, + constraint: TableConstraint::ForeignKey { columns, .. }, + } = action + { + for col_name in columns { + // Skip if this column is being added in this migration (handled by Case 1) + if added_columns.contains(&(table.clone(), col_name.clone())) { + continue; + } + // Look up column in current models to check nullability + if let Some(model) = current_models + .iter() + .find(|m| m.name.as_str() == table.as_str()) + && let Some(col_def) = model + .columns + .iter() + .find(|c| c.name.as_str() == col_name.as_str()) + && !col_def.nullable + && col_def.default.is_none() + { + result.push(RecreateTableRequired { + table: table.clone(), + column: col_name.clone(), + reason: RecreateReason::AddFkToExistingColumn, + }); + } + } + } + } + + result +} + +/// Rewrite the migration plan to recreate tables instead of adding columns. +/// Removes all column/constraint actions targeting the recreated tables and replaces +/// them with `DeleteTable` + `CreateTable` using the full target model. +pub(super) fn rewrite_plan_for_recreation( + plan: &mut MigrationPlan, + recreate_tables: &[RecreateTableRequired], + current_models: &[TableDef], +) { + let tables_to_recreate: BTreeSet<&str> = + recreate_tables.iter().map(|r| r.table.as_str()).collect(); + + // Remove all column/constraint actions targeting recreated tables + plan.actions.retain(|action| { + let table = match action { + MigrationAction::AddColumn { table, .. } + | MigrationAction::DeleteColumn { table, .. } + | MigrationAction::RenameColumn { table, .. } + | MigrationAction::ModifyColumnType { table, .. } + | MigrationAction::ModifyColumnNullable { table, .. } + | MigrationAction::ModifyColumnDefault { table, .. } + | MigrationAction::ModifyColumnComment { table, .. } + | MigrationAction::AddConstraint { table, .. } + | MigrationAction::RemoveConstraint { table, .. } + | MigrationAction::ReplaceConstraint { table, .. } => Some(table.as_str()), + _ => None, + }; + table.is_none_or(|t| !tables_to_recreate.contains(t)) + }); + + // Add DeleteTable + CreateTable for each recreated table + for table_name in &tables_to_recreate { + if let Some(model) = current_models + .iter() + .find(|m| m.name.as_str() == *table_name) + { + plan.actions.push(MigrationAction::DeleteTable { + table: table_name.to_string(), + }); + plan.actions.push(MigrationAction::CreateTable { + table: model.name.clone(), + columns: model.columns.clone(), + constraints: model.constraints.clone(), + }); + } + } +} + +pub(super) fn handle_recreate_requirements( + plan: &mut MigrationPlan, + current_models: &[TableDef], + prompt_fn: F, +) -> Result<()> +where + F: Fn(&[RecreateTableRequired]) -> Result, +{ + let recreate_tables = find_non_nullable_fk_add_columns(plan, current_models); + if recreate_tables.is_empty() { + return Ok(()); + } + + if !prompt_fn(&recreate_tables)? { + anyhow::bail!( + "Migration cancelled. To proceed without recreation, make the column nullable or add it with a default value that references an existing row." + ); + } + + rewrite_plan_for_recreation(plan, &recreate_tables, current_models); + Ok(()) +} diff --git a/crates/vespertide-cli/src/commands/revision/mod.rs b/crates/vespertide-cli/src/commands/revision/mod.rs new file mode 100644 index 00000000..03364e34 --- /dev/null +++ b/crates/vespertide-cli/src/commands/revision/mod.rs @@ -0,0 +1,161 @@ +use anyhow::Result; +use chrono::Utc; +use colored::Colorize; +use vespertide_planner::{find_missing_fill_with, plan_next_migration, schema_from_plans}; + +use crate::utils::{load_config, load_migrations, load_models}; + +mod emit; +mod parse; +mod prompts; +mod write; + +#[cfg(test)] +mod tests; + +#[cfg(test)] +use emit::*; +#[cfg(test)] +use parse::*; +#[cfg(test)] +use prompts::*; + +use emit::RecreateTableRequired; + +pub async fn cmd_revision( + message: String, + fill_with_args: Vec, + delete_null_rows_args: Vec, +) -> Result<()> { + cmd_revision_core( + message, + fill_with_args, + delete_null_rows_args, + RevisionPromptFns { + recreate: prompts::prompt_recreate_tables, + delete_null_rows: prompts::prompt_delete_null_rows, + fill_with: prompts::prompt_fill_with_value, + enum_quoted: prompts::prompt_enum_value, + enum_bare: prompts::prompt_enum_value_bare, + }, + ) + .await +} + +struct RevisionPromptFns { + recreate: R, + delete_null_rows: D, + fill_with: F, + enum_quoted: E, + enum_bare: EB, +} + +async fn cmd_revision_core( + message: String, + fill_with_args: Vec, + delete_null_rows_args: Vec, + prompt_fns: RevisionPromptFns, +) -> Result<()> +where + R: Fn(&[RecreateTableRequired]) -> Result, + D: Fn(&str, &str) -> Result, + F: Fn(&str, &str) -> Result, + E: Fn(&str, &[String]) -> Result, + EB: Fn(&str, &[String]) -> Result, +{ + let RevisionPromptFns { + recreate: recreate_prompt_fn, + delete_null_rows: delete_null_rows_prompt_fn, + fill_with: fill_with_prompt_fn, + enum_quoted: enum_prompt_fn, + enum_bare: enum_bare_prompt_fn, + } = prompt_fns; + + let config = load_config()?; + let current_models = load_models(&config)?; + let applied_plans = load_migrations(&config)?; + + let mut plan = plan_next_migration(¤t_models, &applied_plans) + .map_err(|e| anyhow::anyhow!("planning error: {e}"))?; + + // Check for non-nullable FK changes that require table recreation. + emit::handle_recreate_requirements(&mut plan, ¤t_models, recreate_prompt_fn)?; + + if plan.actions.is_empty() { + println!( + "{} {}", + "No changes detected.".bright_yellow(), + "Nothing to migrate.".bright_white() + ); + return Ok(()); + } + + // Reconstruct baseline schema for column type lookups + let baseline_schema = schema_from_plans(&applied_plans) + .map_err(|e| anyhow::anyhow!("schema reconstruction error: {e}"))?; + + // Parse CLI fill_with arguments + let mut fill_values = parse::parse_fill_with_args(&fill_with_args); + let delete_set = parse::parse_delete_null_rows_args(&delete_null_rows_args); + + // Apply any CLI-provided fill_with values first + emit::apply_fill_with_to_plan(&mut plan, &fill_values); + emit::apply_delete_null_rows_to_plan(&mut plan, &delete_set); + + // Find all missing fill_with values + let mut missing = find_missing_fill_with(&plan, &baseline_schema); + + // Handle FK columns with delete_null_rows option first + if !missing.is_empty() { + prompts::handle_delete_null_rows( + &mut plan, + &mut missing, + &delete_set, + delete_null_rows_prompt_fn, + )?; + } + + // Handle remaining missing fill_with values interactively + if !missing.is_empty() { + prompts::collect_fill_with_values( + &missing, + &mut fill_values, + fill_with_prompt_fn, + enum_prompt_fn, + )?; + emit::apply_fill_with_to_plan(&mut plan, &fill_values); + } + + // Handle any missing enum fill_with values (for removed enum values) interactively + prompts::handle_missing_enum_fill_with(&mut plan, &baseline_schema, enum_bare_prompt_fn)?; + + plan.id = uuid::Uuid::new_v4().to_string(); + plan.comment = Some(message); + if plan.created_at.is_none() { + // Record creation time in RFC3339 (UTC). + plan.created_at = Some(Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)); + } + + let path = write::write_migration_file(&config, &plan).await?; + + println!( + "{} {}", + "Created migration:".bright_green().bold(), + format!("{}", path.display()).bright_white() + ); + println!( + " {} {}", + "Version:".bright_cyan(), + plan.version.to_string().bright_magenta().bold() + ); + println!( + " {} {}", + "Actions:".bright_cyan(), + plan.actions.len().to_string().bright_yellow() + ); + if let Some(comment) = &plan.comment { + println!(" {} {}", "Comment:".bright_cyan(), comment.bright_white()); + } + + Ok(()) +} diff --git a/crates/vespertide-cli/src/commands/revision/parse.rs b/crates/vespertide-cli/src/commands/revision/parse.rs new file mode 100644 index 00000000..0db77727 --- /dev/null +++ b/crates/vespertide-cli/src/commands/revision/parse.rs @@ -0,0 +1,27 @@ +use std::collections::{HashMap, HashSet}; + +/// Parse `fill_with` arguments from CLI. +/// Format: table.column=value +pub(super) fn parse_fill_with_args(args: &[String]) -> HashMap<(String, String), String> { + let mut map = HashMap::new(); + for arg in args { + if let Some((key, value)) = arg.split_once('=') + && let Some((table, column)) = key.split_once('.') + { + map.insert((table.to_string(), column.to_string()), value.to_string()); + } + } + map +} + +/// Parse `delete_null_rows` arguments from CLI. +/// Format: table.column +pub(super) fn parse_delete_null_rows_args(args: &[String]) -> HashSet<(String, String)> { + let mut set = HashSet::new(); + for arg in args { + if let Some((table, column)) = arg.split_once('.') { + set.insert((table.to_string(), column.to_string())); + } + } + set +} diff --git a/crates/vespertide-cli/src/commands/revision/prompts.rs b/crates/vespertide-cli/src/commands/revision/prompts.rs new file mode 100644 index 00000000..29418ced --- /dev/null +++ b/crates/vespertide-cli/src/commands/revision/prompts.rs @@ -0,0 +1,364 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; + +use anyhow::{Context, Result}; +use colored::Colorize; +use dialoguer::{Confirm, Input, Select}; +use vespertide_core::{MigrationAction, MigrationPlan, TableDef}; +#[cfg(test)] +use vespertide_planner::find_missing_fill_with; +use vespertide_planner::{EnumFillWithRequired, FillWithRequired, find_missing_enum_fill_with}; + +#[cfg(test)] +use super::emit::apply_fill_with_to_plan; +use super::emit::{RecreateReason, RecreateTableRequired, apply_enum_fill_with_to_plan}; + +/// Format the type info string for display. +/// Includes column type and default value hint if available. +pub(super) fn format_type_info(column_type: &str, default_value: &str) -> String { + format!(" ({column_type}, default: {default_value})") +} + +/// Format a single `fill_with` item for display. +pub(super) fn format_fill_with_item( + table: &str, + column: &str, + type_info: &str, + action_type: &str, +) -> String { + format!( + " {} {}.{}{}\n {} {}", + "•".bright_cyan(), + table.bright_white(), + column.bright_green(), + type_info.bright_black(), + "Action:".bright_black(), + action_type.bright_magenta() + ) +} + +/// Format the prompt string for interactive input. +pub(super) fn format_fill_with_prompt(table: &str, column: &str) -> String { + format!( + " Enter fill value for {}.{}", + table.bright_white(), + column.bright_green() + ) +} + +/// Print the header for `fill_with` prompts. +pub(super) fn print_fill_with_header() { + println!( + "\n{} {}", + "⚠".bright_yellow(), + "The following columns require fill_with values:".bright_yellow() + ); + println!("{}", "─".repeat(60).bright_black()); +} + +/// Print the footer for `fill_with` prompts. +pub(super) fn print_fill_with_footer() { + println!("{}", "─".repeat(60).bright_black()); +} + +/// Print a `fill_with` item and return the formatted prompt. +pub(super) fn print_fill_with_item_and_get_prompt( + table: &str, + column: &str, + column_type: &str, + default_value: &str, + action_type: &str, +) -> String { + let type_info = format_type_info(column_type, default_value); + let item_display = format_fill_with_item(table, column, &type_info, action_type); + println!("{item_display}"); + format_fill_with_prompt(table, column) +} + +/// Wrap a value with single quotes if it contains spaces and isn't already quoted. +pub(super) fn wrap_if_spaces(value: String) -> String { + if value.is_empty() { + return value; + } + // Already wrapped with single quotes + if value.starts_with('\'') && value.ends_with('\'') { + return value; + } + // Contains spaces: wrap with single quotes + if value.contains(' ') { + return format!("'{value}'"); + } + value +} + +/// Prompt the user for a `fill_with` value using dialoguer. +/// This function wraps terminal I/O and cannot be unit tested without a real terminal. +#[cfg(not(tarpaulin_include))] +pub(super) fn prompt_fill_with_value(prompt: &str, default: &str) -> Result { + let value: String = Input::new() + .with_prompt(prompt) + .default(default.to_string()) + .interact_text() + .context("failed to read input")?; + Ok(wrap_if_spaces(value)) +} + +/// Prompt the user to select an enum value using dialoguer Select. +/// Returns the selected value wrapped in single quotes for SQL. +#[cfg(not(tarpaulin_include))] +pub(super) fn prompt_enum_value(prompt: &str, enum_values: &[String]) -> Result { + let selection = Select::new() + .with_prompt(prompt) + .items(enum_values) + .default(0) + .interact() + .context("failed to read selection")?; + // Return the selected value with single quotes for SQL enum literal + Ok(format!("'{}'", enum_values[selection])) +} + +/// Prompt for enum value selection and return bare (unquoted) value. +/// Used by `cmd_revision` for enum `fill_with` collection where `BTreeMap` stores bare names. +#[cfg(not(tarpaulin_include))] +pub(super) fn prompt_enum_value_bare(prompt: &str, values: &[String]) -> Result { + let selected = prompt_enum_value(prompt, values)?; + Ok(strip_enum_quotes(&selected)) +} + +/// Strip SQL single-quotes from an enum value string. +/// `BTreeMap` stores bare enum names; the SQL layer handles quoting via `Expr::val()`. +pub(super) fn strip_enum_quotes(value: &str) -> String { + value + .trim_start_matches('\'') + .trim_end_matches('\'') + .to_string() +} + +/// Collect `fill_with` values interactively for missing columns. +/// The `prompt_fn` parameter allows injecting a mock for testing. +/// The `enum_prompt_fn` parameter handles enum type columns with selection UI. +pub(super) fn collect_fill_with_values( + missing: &[vespertide_planner::FillWithRequired], + fill_values: &mut HashMap<(String, String), String>, + prompt_fn: F, + enum_prompt_fn: E, +) -> Result<()> +where + F: Fn(&str, &str) -> Result, + E: Fn(&str, &[String]) -> Result, +{ + print_fill_with_header(); + + for item in missing { + let prompt = print_fill_with_item_and_get_prompt( + &item.table, + &item.column, + &item.column_type, + &item.default_value, + item.action_type, + ); + + let value = if let Some(enum_values) = &item.enum_values { + // Use selection UI for enum types + enum_prompt_fn(&prompt, enum_values)? + } else { + // Use text input with default pre-filled + prompt_fn(&prompt, &item.default_value)? + }; + fill_values.insert((item.table.clone(), item.column.clone()), value); + } + + print_fill_with_footer(); + Ok(()) +} +/// Handle interactive `fill_with` collection if there are missing values. +/// Returns the updated `fill_values` map after collecting from user. +#[cfg(test)] +pub(super) fn handle_missing_fill_with( + plan: &mut MigrationPlan, + fill_values: &mut HashMap<(String, String), String>, + current_schema: &[TableDef], + prompt_fn: F, + enum_prompt_fn: E, +) -> Result<()> +where + F: Fn(&str, &str) -> Result, + E: Fn(&str, &[String]) -> Result, +{ + let missing = find_missing_fill_with(plan, current_schema); + + if !missing.is_empty() { + collect_fill_with_values(&missing, fill_values, prompt_fn, enum_prompt_fn)?; + + // Apply the collected fill_with values + apply_fill_with_to_plan(plan, fill_values); + } + + Ok(()) +} + +#[cfg(not(tarpaulin_include))] +pub(super) fn prompt_delete_null_rows(table: &str, column: &str) -> Result { + let confirmed = Confirm::new() + .with_prompt(format!(" Delete rows where {table}.{column} IS NULL?")) + .default(false) + .interact() + .context("failed to read confirmation")?; + Ok(confirmed) +} + +pub(super) fn handle_delete_null_rows( + plan: &mut MigrationPlan, + missing: &mut Vec, + delete_set: &HashSet<(String, String)>, + prompt_fn: F, +) -> Result<()> +where + F: Fn(&str, &str) -> Result, +{ + let mut to_delete = Vec::new(); + let mut remaining = Vec::new(); + + for item in missing.drain(..) { + if item.has_foreign_key && !delete_set.contains(&(item.table.clone(), item.column.clone())) + { + // FK column without CLI arg — prompt user + println!( + " {} {}.{} has a foreign key constraint — fill_with may not work.", + "\u{2022}".bright_cyan(), + item.table.bright_white(), + item.column.bright_green() + ); + if prompt_fn(&item.table, &item.column)? { + to_delete.push((item.table.clone(), item.column.clone())); + } else { + remaining.push(item); + } + } else if delete_set.contains(&(item.table.clone(), item.column.clone())) { + to_delete.push((item.table.clone(), item.column.clone())); + } else { + remaining.push(item); + } + } + + // Apply delete_null_rows to plan + for (table, column) in &to_delete { + for action in &mut plan.actions { + if let MigrationAction::ModifyColumnNullable { + table: t, + column: c, + delete_null_rows, + .. + } = action + && t == table + && c == column + { + *delete_null_rows = Some(true); + } + } + } + + *missing = remaining; + Ok(()) +} +/// Collect enum `fill_with` values interactively for removed enum values. +/// The `enum_prompt_fn` parameter handles enum type columns with selection UI. +pub(super) fn collect_enum_fill_with_values( + missing: &[EnumFillWithRequired], + enum_prompt_fn: E, +) -> Result)>> +where + E: Fn(&str, &[String]) -> Result, +{ + let mut results = Vec::new(); + + println!( + "\n{} {}", + "\u{26a0}".bright_yellow(), + "The following enum value removals require replacement mappings:".bright_yellow() + ); + println!("{}", "\u{2500}".repeat(60).bright_black()); + + for item in missing { + println!( + " {} {}.{}: removing enum values", + "\u{2022}".bright_cyan(), + item.table.bright_white(), + item.column.bright_green() + ); + + let mut mappings = BTreeMap::new(); + for removed in &item.removed_values { + let prompt = format!( + " Replace '{}' in {}.{} with", + removed.bright_red(), + item.table.bright_white(), + item.column.bright_green() + ); + let value = enum_prompt_fn(&prompt, &item.remaining_values)?; + mappings.insert(removed.clone(), value); + } + results.push((item.action_index, mappings)); + } + + println!("{}", "\u{2500}".repeat(60).bright_black()); + Ok(results) +} + +/// Handle interactive enum `fill_with` collection if there are missing values. +pub(super) fn handle_missing_enum_fill_with( + plan: &mut MigrationPlan, + current_schema: &[TableDef], + enum_prompt_fn: E, +) -> Result<()> +where + E: Fn(&str, &[String]) -> Result, +{ + let missing = find_missing_enum_fill_with(plan, current_schema); + + if !missing.is_empty() { + let collected = collect_enum_fill_with_values(&missing, enum_prompt_fn)?; + apply_enum_fill_with_to_plan(plan, &collected); + } + + Ok(()) +} +/// Prompt the user to confirm table recreation. +/// Returns true if the user confirms, false otherwise. +#[cfg(not(tarpaulin_include))] +pub(super) fn prompt_recreate_tables(tables: &[RecreateTableRequired]) -> Result { + println!( + "\n{} {}", + "\u{26a0}".bright_yellow(), + "The following tables need to be RECREATED:".bright_yellow() + ); + println!("{}", "\u{2500}".repeat(60).bright_black()); + + for item in tables { + let reason_msg = match item.reason { + RecreateReason::AddColumnWithFk => "adding required FK column", + RecreateReason::AddFkToExistingColumn => "adding FK to existing required column", + }; + println!( + " {} Table {} \u{2014} {} {}", + "\u{2022}".bright_cyan(), + item.table.bright_white(), + reason_msg, + item.column.bright_green() + ); + } + + println!("{}", "\u{2500}".repeat(60).bright_black()); + println!( + " {} {}", + "\u{26a0}".bright_red(), + "ALL DATA in these tables will be DELETED.".bright_red() + ); + + let confirmed = Confirm::new() + .with_prompt(" Proceed with table recreation?") + .default(false) + .interact() + .context("failed to read confirmation")?; + + Ok(confirmed) +} diff --git a/crates/vespertide-cli/src/commands/revision/tests.rs b/crates/vespertide-cli/src/commands/revision/tests.rs new file mode 100644 index 00000000..170283ad --- /dev/null +++ b/crates/vespertide-cli/src/commands/revision/tests.rs @@ -0,0 +1,78 @@ +use super::*; +pub(super) use anyhow::Result; +pub(super) use std::{ + collections::{BTreeMap, HashMap, HashSet}, + env, fs as std_fs, + path::PathBuf, +}; +pub(super) use tempfile::tempdir; +pub(super) use vespertide_config::{FileFormat, VespertideConfig}; +pub(super) use vespertide_core::{ + ColumnDef, ColumnType, MigrationAction, MigrationPlan, SimpleColumnType, TableConstraint, + TableDef, +}; + +struct CwdGuard { + original: PathBuf, +} + +impl CwdGuard { + fn new(dir: &PathBuf) -> Self { + let original = env::current_dir().unwrap(); + env::set_current_dir(dir).unwrap(); + Self { original } + } +} + +impl Drop for CwdGuard { + fn drop(&mut self) { + let _ = env::set_current_dir(&self.original); + } +} + +fn write_config() -> VespertideConfig { + write_config_with_format(None) +} + +fn write_config_with_format(fmt: Option) -> VespertideConfig { + let mut cfg = VespertideConfig::default(); + if let Some(f) = fmt { + cfg.migration_format = f; + } + let text = serde_json::to_string_pretty(&cfg).unwrap(); + std_fs::write("vespertide.json", text).unwrap(); + cfg +} + +fn write_model(name: &str) { + let models_dir = PathBuf::from("models"); + std_fs::create_dir_all(&models_dir).unwrap(); + let table = TableDef { + name: name.to_string(), + description: None, + columns: vec![ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["id".into()], + }], + }; + let path = models_dir.join(format!("{name}.json")); + std_fs::write(path, serde_json::to_string_pretty(&table).unwrap()).unwrap(); +} + +mod delete_null_rows; +mod fill_with; +mod integration; +#[path = "tests/prompts.rs"] +mod prompt_tests; +mod recreate; diff --git a/crates/vespertide-cli/src/commands/revision/tests/delete_null_rows.rs b/crates/vespertide-cli/src/commands/revision/tests/delete_null_rows.rs new file mode 100644 index 00000000..b1cbeb21 --- /dev/null +++ b/crates/vespertide-cli/src/commands/revision/tests/delete_null_rows.rs @@ -0,0 +1,394 @@ +use super::*; + +#[test] +fn test_parse_delete_null_rows_args() { + let args = vec!["users.email".to_string(), "orders.user_id".to_string()]; + let result = parse_delete_null_rows_args(&args); + assert_eq!(result.len(), 2); + assert!(result.contains(&("users".to_string(), "email".to_string()))); + assert!(result.contains(&("orders".to_string(), "user_id".to_string()))); +} + +#[test] +fn test_parse_delete_null_rows_args_invalid_format() { + let args = vec!["invalid_no_dot".to_string(), "valid.column".to_string()]; + let result = parse_delete_null_rows_args(&args); + assert_eq!(result.len(), 1); + assert!(result.contains(&("valid".to_string(), "column".to_string()))); +} + +#[test] +fn test_parse_delete_null_rows_args_empty() { + let result = parse_delete_null_rows_args(&[]); + assert!(result.is_empty()); +} + +#[test] +fn test_apply_delete_null_rows_to_plan() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::ModifyColumnNullable { + table: "orders".into(), + column: "user_id".into(), + nullable: false, + fill_with: None, + delete_null_rows: None, + }], + }; + + let mut delete_set = HashSet::new(); + delete_set.insert(("orders".to_string(), "user_id".to_string())); + apply_delete_null_rows_to_plan(&mut plan, &delete_set); + + match &plan.actions[0] { + MigrationAction::ModifyColumnNullable { + delete_null_rows, .. + } => { + assert_eq!(delete_null_rows, &Some(true)); + } + _ => panic!("Expected ModifyColumnNullable action"), + } +} + +#[test] +fn test_apply_delete_null_rows_to_plan_skips_nullable_true() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::ModifyColumnNullable { + table: "orders".into(), + column: "user_id".into(), + nullable: true, + fill_with: None, + delete_null_rows: None, + }], + }; + + let mut delete_set = HashSet::new(); + delete_set.insert(("orders".to_string(), "user_id".to_string())); + apply_delete_null_rows_to_plan(&mut plan, &delete_set); + + match &plan.actions[0] { + MigrationAction::ModifyColumnNullable { + delete_null_rows, .. + } => { + assert_eq!(delete_null_rows, &None); + } + _ => panic!("Expected ModifyColumnNullable action"), + } +} + +#[test] +fn test_apply_delete_null_rows_to_plan_skips_already_set() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::ModifyColumnNullable { + table: "orders".into(), + column: "user_id".into(), + nullable: false, + fill_with: None, + delete_null_rows: Some(false), + }], + }; + + let mut delete_set = HashSet::new(); + delete_set.insert(("orders".to_string(), "user_id".to_string())); + apply_delete_null_rows_to_plan(&mut plan, &delete_set); + + match &plan.actions[0] { + MigrationAction::ModifyColumnNullable { + delete_null_rows, .. + } => { + assert_eq!(delete_null_rows, &Some(false)); + } + _ => panic!("Expected ModifyColumnNullable action"), + } +} + +#[test] +fn test_apply_delete_null_rows_to_plan_no_match() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::ModifyColumnNullable { + table: "orders".into(), + column: "user_id".into(), + nullable: false, + fill_with: None, + delete_null_rows: None, + }], + }; + + let mut delete_set = HashSet::new(); + delete_set.insert(("other_table".to_string(), "other_col".to_string())); + apply_delete_null_rows_to_plan(&mut plan, &delete_set); + + match &plan.actions[0] { + MigrationAction::ModifyColumnNullable { + delete_null_rows, .. + } => { + assert_eq!(delete_null_rows, &None); + } + _ => panic!("Expected ModifyColumnNullable action"), + } +} + +#[test] +fn test_handle_delete_null_rows_fk_accepted() { + use vespertide_core::MigrationPlan; + use vespertide_planner::FillWithRequired; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::ModifyColumnNullable { + table: "orders".into(), + column: "user_id".into(), + nullable: false, + fill_with: None, + delete_null_rows: None, + }], + }; + + let mut missing = vec![FillWithRequired { + action_index: 0, + table: "orders".to_string(), + column: "user_id".to_string(), + action_type: "ModifyColumnNullable", + column_type: "integer".to_string(), + default_value: "0".to_string(), + enum_values: None, + has_foreign_key: true, + }]; + + let delete_set = HashSet::new(); + + let mock_prompt = |_table: &str, _column: &str| -> Result { Ok(true) }; + + let result = handle_delete_null_rows(&mut plan, &mut missing, &delete_set, mock_prompt); + assert!(result.is_ok()); + + assert!(missing.is_empty()); + + match &plan.actions[0] { + MigrationAction::ModifyColumnNullable { + delete_null_rows, .. + } => { + assert_eq!(delete_null_rows, &Some(true)); + } + _ => panic!("Expected ModifyColumnNullable"), + } +} + +#[test] +fn test_handle_delete_null_rows_fk_declined() { + use vespertide_core::MigrationPlan; + use vespertide_planner::FillWithRequired; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::ModifyColumnNullable { + table: "orders".into(), + column: "user_id".into(), + nullable: false, + fill_with: None, + delete_null_rows: None, + }], + }; + + let mut missing = vec![FillWithRequired { + action_index: 0, + table: "orders".to_string(), + column: "user_id".to_string(), + action_type: "ModifyColumnNullable", + column_type: "integer".to_string(), + default_value: "0".to_string(), + enum_values: None, + has_foreign_key: true, + }]; + + let delete_set = HashSet::new(); + + let mock_prompt = |_table: &str, _column: &str| -> Result { Ok(false) }; + + let result = handle_delete_null_rows(&mut plan, &mut missing, &delete_set, mock_prompt); + assert!(result.is_ok()); + + assert_eq!(missing.len(), 1); + assert_eq!(missing[0].table, "orders"); + + match &plan.actions[0] { + MigrationAction::ModifyColumnNullable { + delete_null_rows, .. + } => { + assert_eq!(delete_null_rows, &None); + } + _ => panic!("Expected ModifyColumnNullable"), + } +} + +#[test] +fn test_handle_delete_null_rows_cli_provided() { + use vespertide_core::MigrationPlan; + use vespertide_planner::FillWithRequired; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::ModifyColumnNullable { + table: "orders".into(), + column: "user_id".into(), + nullable: false, + fill_with: None, + delete_null_rows: None, + }], + }; + + let mut missing = vec![FillWithRequired { + action_index: 0, + table: "orders".to_string(), + column: "user_id".to_string(), + action_type: "ModifyColumnNullable", + column_type: "integer".to_string(), + default_value: "0".to_string(), + enum_values: None, + has_foreign_key: false, + }]; + + let mut delete_set = HashSet::new(); + delete_set.insert(("orders".to_string(), "user_id".to_string())); + + let mock_prompt = |_table: &str, _column: &str| -> Result { + panic!("Should not be called for CLI-provided items"); + }; + + let result = handle_delete_null_rows(&mut plan, &mut missing, &delete_set, mock_prompt); + assert!(result.is_ok()); + + assert!(missing.is_empty()); + + match &plan.actions[0] { + MigrationAction::ModifyColumnNullable { + delete_null_rows, .. + } => { + assert_eq!(delete_null_rows, &Some(true)); + } + _ => panic!("Expected ModifyColumnNullable"), + } +} + +#[test] +fn test_handle_delete_null_rows_non_fk_passthrough() { + use vespertide_core::MigrationPlan; + use vespertide_planner::FillWithRequired; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::ModifyColumnNullable { + table: "users".into(), + column: "email".into(), + nullable: false, + fill_with: None, + delete_null_rows: None, + }], + }; + + let mut missing = vec![FillWithRequired { + action_index: 0, + table: "users".to_string(), + column: "email".to_string(), + action_type: "ModifyColumnNullable", + column_type: "text".to_string(), + default_value: "''".to_string(), + enum_values: None, + has_foreign_key: false, + }]; + + let delete_set = HashSet::new(); + + let mock_prompt = |_table: &str, _column: &str| -> Result { + panic!("Should not be called for non-FK items"); + }; + + let result = handle_delete_null_rows(&mut plan, &mut missing, &delete_set, mock_prompt); + assert!(result.is_ok()); + + assert_eq!(missing.len(), 1); + assert_eq!(missing[0].column, "email"); + + match &plan.actions[0] { + MigrationAction::ModifyColumnNullable { + delete_null_rows, .. + } => { + assert_eq!(delete_null_rows, &None); + } + _ => panic!("Expected ModifyColumnNullable"), + } +} + +#[test] +fn test_handle_delete_null_rows_prompt_error() { + use vespertide_core::MigrationPlan; + use vespertide_planner::FillWithRequired; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::ModifyColumnNullable { + table: "orders".into(), + column: "user_id".into(), + nullable: false, + fill_with: None, + delete_null_rows: None, + }], + }; + + let mut missing = vec![FillWithRequired { + action_index: 0, + table: "orders".to_string(), + column: "user_id".to_string(), + action_type: "ModifyColumnNullable", + column_type: "integer".to_string(), + default_value: "0".to_string(), + enum_values: None, + has_foreign_key: true, + }]; + + let delete_set = HashSet::new(); + + let mock_prompt = + |_table: &str, _column: &str| -> Result { Err(anyhow::anyhow!("user cancelled")) }; + + let result = handle_delete_null_rows(&mut plan, &mut missing, &delete_set, mock_prompt); + assert!(result.is_err()); +} diff --git a/crates/vespertide-cli/src/commands/revision/tests/fill_with.rs b/crates/vespertide-cli/src/commands/revision/tests/fill_with.rs new file mode 100644 index 00000000..5cd324f4 --- /dev/null +++ b/crates/vespertide-cli/src/commands/revision/tests/fill_with.rs @@ -0,0 +1,294 @@ +use super::*; + +#[test] +fn test_parse_fill_with_args() { + let args = vec![ + "users.email=default@example.com".to_string(), + "orders.status=pending".to_string(), + ]; + let result = parse_fill_with_args(&args); + + assert_eq!(result.len(), 2); + assert_eq!( + result.get(&("users".to_string(), "email".to_string())), + Some(&"default@example.com".to_string()) + ); + assert_eq!( + result.get(&("orders".to_string(), "status".to_string())), + Some(&"pending".to_string()) + ); +} + +#[test] +fn test_parse_fill_with_args_invalid_format() { + let args = vec![ + "invalid_format".to_string(), + "no_equals_sign".to_string(), + "users.email=valid".to_string(), + ]; + let result = parse_fill_with_args(&args); + + // Only the valid one should be parsed + assert_eq!(result.len(), 1); + assert_eq!( + result.get(&("users".to_string(), "email".to_string())), + Some(&"valid".to_string()) + ); +} + +#[test] +fn test_apply_fill_with_to_plan_add_column() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::AddColumn { + table: "users".into(), + column: Box::new(ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }], + }; + + let mut fill_values = HashMap::new(); + fill_values.insert( + ("users".to_string(), "email".to_string()), + "'default@example.com'".to_string(), + ); + + apply_fill_with_to_plan(&mut plan, &fill_values); + + match &plan.actions[0] { + MigrationAction::AddColumn { fill_with, .. } => { + assert_eq!(fill_with, &Some("'default@example.com'".to_string())); + } + _ => panic!("Expected AddColumn action"), + } +} + +#[test] +fn test_apply_fill_with_to_plan_modify_column_nullable() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::ModifyColumnNullable { + table: "users".into(), + column: "status".into(), + nullable: false, + fill_with: None, + delete_null_rows: None, + }], + }; + + let mut fill_values = HashMap::new(); + fill_values.insert( + ("users".to_string(), "status".to_string()), + "'active'".to_string(), + ); + + apply_fill_with_to_plan(&mut plan, &fill_values); + + match &plan.actions[0] { + MigrationAction::ModifyColumnNullable { fill_with, .. } => { + assert_eq!(fill_with, &Some("'active'".to_string())); + } + _ => panic!("Expected ModifyColumnNullable action"), + } +} + +#[test] +fn test_apply_fill_with_to_plan_skips_existing_fill_with() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::AddColumn { + table: "users".into(), + column: Box::new(ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: Some("'existing@example.com'".to_string()), + }], + }; + + let mut fill_values = HashMap::new(); + fill_values.insert( + ("users".to_string(), "email".to_string()), + "'new@example.com'".to_string(), + ); + + apply_fill_with_to_plan(&mut plan, &fill_values); + + // Should keep existing value, not replace with new + match &plan.actions[0] { + MigrationAction::AddColumn { fill_with, .. } => { + assert_eq!(fill_with, &Some("'existing@example.com'".to_string())); + } + _ => panic!("Expected AddColumn action"), + } +} + +#[test] +fn test_apply_fill_with_to_plan_no_match() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::AddColumn { + table: "users".into(), + column: Box::new(ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }], + }; + + let mut fill_values = HashMap::new(); + fill_values.insert( + ("orders".to_string(), "status".to_string()), + "'pending'".to_string(), + ); + + apply_fill_with_to_plan(&mut plan, &fill_values); + + // Should remain None since no match + match &plan.actions[0] { + MigrationAction::AddColumn { fill_with, .. } => { + assert_eq!(fill_with, &None); + } + _ => panic!("Expected AddColumn action"), + } +} + +#[test] +fn test_apply_fill_with_to_plan_multiple_actions() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![ + MigrationAction::AddColumn { + table: "users".into(), + column: Box::new(ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }, + MigrationAction::ModifyColumnNullable { + table: "orders".into(), + column: "status".into(), + nullable: false, + fill_with: None, + delete_null_rows: None, + }, + ], + }; + + let mut fill_values = HashMap::new(); + fill_values.insert( + ("users".to_string(), "email".to_string()), + "'user@example.com'".to_string(), + ); + fill_values.insert( + ("orders".to_string(), "status".to_string()), + "'pending'".to_string(), + ); + + apply_fill_with_to_plan(&mut plan, &fill_values); + + match &plan.actions[0] { + MigrationAction::AddColumn { fill_with, .. } => { + assert_eq!(fill_with, &Some("'user@example.com'".to_string())); + } + _ => panic!("Expected AddColumn action"), + } + + match &plan.actions[1] { + MigrationAction::ModifyColumnNullable { fill_with, .. } => { + assert_eq!(fill_with, &Some("'pending'".to_string())); + } + _ => panic!("Expected ModifyColumnNullable action"), + } +} + +#[test] +fn test_apply_fill_with_to_plan_other_actions_ignored() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::DeleteColumn { + table: "users".into(), + column: "old_column".into(), + }], + }; + + let mut fill_values = HashMap::new(); + fill_values.insert( + ("users".to_string(), "old_column".to_string()), + "'value'".to_string(), + ); + + // Should not panic or modify anything + apply_fill_with_to_plan(&mut plan, &fill_values); + + match &plan.actions[0] { + MigrationAction::DeleteColumn { table, column } => { + assert_eq!(table, "users"); + assert_eq!(column, "old_column"); + } + _ => panic!("Expected DeleteColumn action"), + } +} diff --git a/crates/vespertide-cli/src/commands/revision/tests/integration.rs b/crates/vespertide-cli/src/commands/revision/tests/integration.rs new file mode 100644 index 00000000..202ee844 --- /dev/null +++ b/crates/vespertide-cli/src/commands/revision/tests/integration.rs @@ -0,0 +1,322 @@ +use super::*; + +/// Integration test: FK column nullable→not-null triggers `handle_delete_null_rows` (line 489) +#[tokio::test] +#[serial_test::serial] +async fn cmd_revision_core_handles_delete_null_rows_for_fk_column() { + use vespertide_core::MigrationPlan; + use vespertide_core::schema::foreign_key::{ForeignKeyDef, ForeignKeySyntax}; + + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + + let cfg = write_config(); + std_fs::create_dir_all(cfg.migrations_dir()).unwrap(); + + // Write v1 migration: create "orders" table with nullable user_id + let v1 = MigrationPlan { + id: "v1-id".to_string(), + comment: Some("init".to_string()), + created_at: None, + version: 1, + actions: vec![MigrationAction::CreateTable { + table: "orders".into(), + columns: vec![ + ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: true, // nullable in v1 + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["id".into()], + }, + TableConstraint::ForeignKey { + name: Some("fk_orders__user_id".into()), + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + ], + }], + }; + let v1_path = cfg.migrations_dir().join("0001_init.vespertide.json"); + std_fs::write(&v1_path, serde_json::to_string_pretty(&v1).unwrap()).unwrap(); + + // Write updated model: user_id is now NOT NULL + let models_dir = PathBuf::from("models"); + std_fs::create_dir_all(&models_dir).unwrap(); + let users_model = TableDef { + name: "users".to_string(), + description: None, + columns: vec![ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["id".into()], + }], + }; + std_fs::write( + models_dir.join("users.json"), + serde_json::to_string_pretty(&users_model).unwrap(), + ) + .unwrap(); + + let model = TableDef { + name: "orders".to_string(), + description: None, + columns: vec![ + ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, // NOT NULL now + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: Some(ForeignKeySyntax::Object(ForeignKeyDef { + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + })), + }, + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["id".into()], + }], + }; + std_fs::write( + models_dir.join("orders.json"), + serde_json::to_string_pretty(&model).unwrap(), + ) + .unwrap(); + + // Mock prompts + let recreate_prompt = |_: &[RecreateTableRequired]| -> Result { Ok(true) }; + let delete_prompt = |_table: &str, _col: &str| -> Result { Ok(true) }; + let fill_prompt = |_p: &str, _d: &str| -> Result { + panic!("fill prompt should not be called — FK handled by delete_null_rows"); + }; + let enum_prompt = |_p: &str, _v: &[String]| -> Result { + panic!("enum prompt should not be called"); + }; + let enum_bare_prompt = |_p: &str, _v: &[String]| -> Result { + panic!("enum bare prompt should not be called"); + }; + + let result = cmd_revision_core( + "make user_id required".into(), + vec![], + vec![], + RevisionPromptFns { + recreate: recreate_prompt, + delete_null_rows: delete_prompt, + fill_with: fill_prompt, + enum_quoted: enum_prompt, + enum_bare: enum_bare_prompt, + }, + ) + .await; + + assert!( + result.is_ok(), + "cmd_revision_core failed: {:?}", + result.err() + ); + + // Verify migration was created + let entries: Vec<_> = std_fs::read_dir(cfg.migrations_dir()) + .unwrap() + .filter_map(std::result::Result::ok) + .collect(); + // Should have 2 files: v1 + new v2 + assert_eq!(entries.len(), 2); +} + +/// Integration test: non-FK column nullable→not-null triggers `collect_fill_with_values` (lines 494-495) +#[tokio::test] +#[serial_test::serial] +async fn cmd_revision_core_handles_fill_with_for_non_fk_column() { + use vespertide_core::MigrationPlan; + + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + + let cfg = write_config(); + std_fs::create_dir_all(cfg.migrations_dir()).unwrap(); + + // Write v1 migration: create "users" table with nullable email + let v1 = MigrationPlan { + id: "v1-id".to_string(), + comment: Some("init".to_string()), + created_at: None, + version: 1, + actions: vec![MigrationAction::CreateTable { + table: "users".into(), + columns: vec![ + ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: true, // nullable in v1 + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["id".into()], + }], + }], + }; + let v1_path = cfg.migrations_dir().join("0001_init.vespertide.json"); + std_fs::write(&v1_path, serde_json::to_string_pretty(&v1).unwrap()).unwrap(); + + // Write updated model: email is now NOT NULL (no default) + let models_dir = PathBuf::from("models"); + std_fs::create_dir_all(&models_dir).unwrap(); + let model = TableDef { + name: "users".to_string(), + description: None, + columns: vec![ + ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: false, // NOT NULL now + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["id".into()], + }], + }; + std_fs::write( + models_dir.join("users.json"), + serde_json::to_string_pretty(&model).unwrap(), + ) + .unwrap(); + + // Mock prompts + let recreate_prompt = |_: &[RecreateTableRequired]| -> Result { Ok(true) }; + let delete_prompt = |_table: &str, _col: &str| -> Result { Ok(false) }; + let fill_prompt = |_p: &str, _d: &str| -> Result { Ok("'unknown'".to_string()) }; + let enum_prompt = |_p: &str, _v: &[String]| -> Result { + panic!("enum prompt should not be called"); + }; + let enum_bare_prompt = |_p: &str, _v: &[String]| -> Result { + panic!("enum bare prompt should not be called"); + }; + + let result = cmd_revision_core( + "make email required".into(), + vec![], + vec![], + RevisionPromptFns { + recreate: recreate_prompt, + delete_null_rows: delete_prompt, + fill_with: fill_prompt, + enum_quoted: enum_prompt, + enum_bare: enum_bare_prompt, + }, + ) + .await; + + assert!( + result.is_ok(), + "cmd_revision_core failed: {:?}", + result.err() + ); + + // Verify migration was written with fill_with + let entries: Vec<_> = std_fs::read_dir(cfg.migrations_dir()) + .unwrap() + .filter_map(std::result::Result::ok) + .collect(); + assert_eq!(entries.len(), 2); + + // Read the v2 migration and verify fill_with was applied + let v2_path = entries + .iter() + .find(|e| e.file_name().to_string_lossy().contains("0002")) + .expect("v2 migration not found"); + let v2_content = std_fs::read_to_string(v2_path.path()).unwrap(); + assert!( + v2_content.contains("fill_with"), + "Expected fill_with in migration, got: {v2_content}" + ); +} diff --git a/crates/vespertide-cli/src/commands/revision/tests/prompts.rs b/crates/vespertide-cli/src/commands/revision/tests/prompts.rs new file mode 100644 index 00000000..06fc4339 --- /dev/null +++ b/crates/vespertide-cli/src/commands/revision/tests/prompts.rs @@ -0,0 +1,762 @@ +use super::*; + +#[test] +fn test_format_type_info_with_type_and_default() { + let result = format_type_info("integer", "0"); + assert_eq!(result, " (integer, default: 0)"); +} + +#[test] +fn test_format_type_info_with_type_only() { + let result = format_type_info("text", "''"); + assert_eq!(result, " (text, default: '')"); +} + +#[test] +fn test_format_fill_with_item() { + let result = format_fill_with_item("users", "email", " (Text)", "AddColumn"); + // The result should contain the table, column, type info, and action type + // Colors make exact matching difficult, but we can check structure + assert!(result.contains("users")); + assert!(result.contains("email")); + assert!(result.contains("(Text)")); + assert!(result.contains("AddColumn")); + assert!(result.contains("Action:")); +} + +#[test] +fn test_format_fill_with_item_empty_type_info() { + let result = format_fill_with_item("orders", "status", "", "ModifyColumnNullable"); + assert!(result.contains("orders")); + assert!(result.contains("status")); + assert!(result.contains("ModifyColumnNullable")); +} + +#[test] +fn test_format_fill_with_prompt() { + let result = format_fill_with_prompt("users", "email"); + assert!(result.contains("Enter fill value for")); + assert!(result.contains("users")); + assert!(result.contains("email")); +} + +#[test] +fn test_print_fill_with_item_and_get_prompt() { + // This function prints to stdout and returns the prompt string + let prompt = print_fill_with_item_and_get_prompt("users", "email", "text", "''", "AddColumn"); + assert!(prompt.contains("Enter fill value for")); + assert!(prompt.contains("users")); + assert!(prompt.contains("email")); +} + +#[test] +fn test_print_fill_with_item_and_get_prompt_no_default() { + let prompt = print_fill_with_item_and_get_prompt( + "orders", + "status", + "text", + "''", + "ModifyColumnNullable", + ); + assert!(prompt.contains("Enter fill value for")); + assert!(prompt.contains("orders")); + assert!(prompt.contains("status")); +} + +#[test] +fn test_print_fill_with_item_and_get_prompt_with_default() { + let prompt = print_fill_with_item_and_get_prompt("users", "age", "integer", "0", "AddColumn"); + assert!(prompt.contains("Enter fill value for")); + assert!(prompt.contains("users")); + assert!(prompt.contains("age")); +} + +#[test] +fn test_print_fill_with_header() { + // Just verify it doesn't panic - output goes to stdout + print_fill_with_header(); +} + +#[test] +fn test_print_fill_with_footer() { + // Just verify it doesn't panic - output goes to stdout + print_fill_with_footer(); +} + +// Mock enum prompt function for tests - returns first enum value quoted +fn mock_enum_prompt(_prompt: &str, values: &[String]) -> Result { + let first = values + .first() + .ok_or_else(|| anyhow::anyhow!("mock enum prompt requires at least one value"))?; + Ok(format!("'{first}'")) +} + +#[test] +fn test_collect_fill_with_values_single_item() { + use vespertide_planner::FillWithRequired; + + let missing = vec![FillWithRequired { + action_index: 0, + table: "users".to_string(), + column: "email".to_string(), + action_type: "AddColumn", + column_type: "text".to_string(), + default_value: "''".to_string(), + enum_values: None, + has_foreign_key: false, + }]; + + let mut fill_values = HashMap::new(); + + // Mock prompt function that returns a fixed value + let mock_prompt = + |_prompt: &str, _default: &str| -> Result { Ok("'test@example.com'".to_string()) }; + + let result = + collect_fill_with_values(&missing, &mut fill_values, mock_prompt, mock_enum_prompt); + assert!(result.is_ok()); + assert_eq!(fill_values.len(), 1); + assert_eq!( + fill_values.get(&("users".to_string(), "email".to_string())), + Some(&"'test@example.com'".to_string()) + ); +} + +#[test] +fn test_collect_fill_with_values_multiple_items() { + use vespertide_planner::FillWithRequired; + + let missing = vec![ + FillWithRequired { + action_index: 0, + table: "users".to_string(), + column: "email".to_string(), + action_type: "AddColumn", + column_type: "text".to_string(), + default_value: "''".to_string(), + enum_values: None, + has_foreign_key: false, + }, + FillWithRequired { + action_index: 1, + table: "orders".to_string(), + column: "status".to_string(), + action_type: "ModifyColumnNullable", + column_type: "text".to_string(), + default_value: "''".to_string(), + enum_values: None, + has_foreign_key: false, + }, + ]; + + let mut fill_values = HashMap::new(); + + // Mock prompt function that returns different values based on call count + let call_count = std::cell::RefCell::new(0); + let mock_prompt = |_prompt: &str, _default: &str| -> Result { + let mut count = call_count.borrow_mut(); + *count += 1; + match *count { + 1 => Ok("'user@example.com'".to_string()), + 2 => Ok("'pending'".to_string()), + _ => Ok("'default'".to_string()), + } + }; + + let result = + collect_fill_with_values(&missing, &mut fill_values, mock_prompt, mock_enum_prompt); + assert!(result.is_ok()); + assert_eq!(fill_values.len(), 2); + assert_eq!( + fill_values.get(&("users".to_string(), "email".to_string())), + Some(&"'user@example.com'".to_string()) + ); + assert_eq!( + fill_values.get(&("orders".to_string(), "status".to_string())), + Some(&"'pending'".to_string()) + ); +} + +#[test] +fn test_collect_fill_with_values_empty() { + let missing: Vec = vec![]; + let mut fill_values = HashMap::new(); + + // This function should handle empty list gracefully (though it won't be called in practice) + // But we can't test the header/footer without items since the function still prints them + // So we test with a mock that would fail if called + let mock_prompt = |_prompt: &str, _default: &str| -> Result { + panic!("Should not be called for empty list"); + }; + + // Note: The function still prints header/footer even for empty list + // This is a design choice - in practice, cmd_revision won't call this with empty list + let result = + collect_fill_with_values(&missing, &mut fill_values, mock_prompt, mock_enum_prompt); + assert!(result.is_ok()); + assert!(fill_values.is_empty()); +} + +#[test] +fn test_collect_fill_with_values_prompt_error() { + use vespertide_planner::FillWithRequired; + + let missing = vec![FillWithRequired { + action_index: 0, + table: "users".to_string(), + column: "email".to_string(), + action_type: "AddColumn", + column_type: "text".to_string(), + default_value: "''".to_string(), + enum_values: None, + has_foreign_key: false, + }]; + + let mut fill_values = HashMap::new(); + + // Mock prompt function that returns an error + let mock_prompt = |_prompt: &str, _default: &str| -> Result { + Err(anyhow::anyhow!("input cancelled")) + }; + + let result = + collect_fill_with_values(&missing, &mut fill_values, mock_prompt, mock_enum_prompt); + assert!(result.is_err()); + assert!(fill_values.is_empty()); +} + +#[test] +fn test_prompt_fill_with_value_function_exists() { + // This test verifies that prompt_fill_with_value has the correct signature. + // We cannot actually call it in tests because dialoguer::Input blocks waiting for terminal input. + // The function is excluded from coverage with #[cfg_attr(coverage_nightly, coverage(off))]. + let _: fn(&str, &str) -> Result = prompt_fill_with_value; +} + +#[test] +fn test_handle_missing_fill_with_collects_and_applies() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::AddColumn { + table: "users".into(), + column: Box::new(ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }], + }; + + let mut fill_values = HashMap::new(); + + // Mock prompt function + let mock_prompt = + |_prompt: &str, _default: &str| -> Result { Ok("'test@example.com'".to_string()) }; + + let result = handle_missing_fill_with( + &mut plan, + &mut fill_values, + &[], + mock_prompt, + mock_enum_prompt, + ); + assert!(result.is_ok()); + + // Verify fill_with was applied to the plan + match &plan.actions[0] { + MigrationAction::AddColumn { fill_with, .. } => { + assert_eq!(fill_with, &Some("'test@example.com'".to_string())); + } + _ => panic!("Expected AddColumn action"), + } + + // Verify fill_values map was updated + assert_eq!( + fill_values.get(&("users".to_string(), "email".to_string())), + Some(&"'test@example.com'".to_string()) + ); +} + +#[test] +fn test_handle_missing_fill_with_no_missing() { + use vespertide_core::MigrationPlan; + + // Plan with no missing fill_with values (nullable column) + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::AddColumn { + table: "users".into(), + column: Box::new(ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: true, // nullable, so no fill_with required + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }], + }; + + let mut fill_values = HashMap::new(); + + // Mock prompt that should never be called + let mock_prompt = |_prompt: &str, _default: &str| -> Result { + panic!("Should not be called when no missing fill_with values"); + }; + + let result = handle_missing_fill_with( + &mut plan, + &mut fill_values, + &[], + mock_prompt, + mock_enum_prompt, + ); + assert!(result.is_ok()); + assert!(fill_values.is_empty()); +} + +#[test] +fn test_handle_missing_fill_with_prompt_error() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::AddColumn { + table: "users".into(), + column: Box::new(ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }], + }; + + let mut fill_values = HashMap::new(); + + // Mock prompt that returns an error + let mock_prompt = |_prompt: &str, _default: &str| -> Result { + Err(anyhow::anyhow!("user cancelled")) + }; + + let result = handle_missing_fill_with( + &mut plan, + &mut fill_values, + &[], + mock_prompt, + mock_enum_prompt, + ); + assert!(result.is_err()); + + // Plan should not be modified on error + match &plan.actions[0] { + MigrationAction::AddColumn { fill_with, .. } => { + assert_eq!(fill_with, &None); + } + _ => panic!("Expected AddColumn action"), + } +} + +#[test] +fn test_handle_missing_fill_with_multiple_columns() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![ + MigrationAction::AddColumn { + table: "users".into(), + column: Box::new(ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }, + MigrationAction::ModifyColumnNullable { + table: "orders".into(), + column: "status".into(), + nullable: false, + fill_with: None, + delete_null_rows: None, + }, + ], + }; + + let mut fill_values = HashMap::new(); + + // Mock prompt that returns different values based on call count + let call_count = std::cell::RefCell::new(0); + let mock_prompt = |_prompt: &str, _default: &str| -> Result { + let mut count = call_count.borrow_mut(); + *count += 1; + match *count { + 1 => Ok("'user@example.com'".to_string()), + 2 => Ok("'pending'".to_string()), + _ => Ok("'default'".to_string()), + } + }; + + let result = handle_missing_fill_with( + &mut plan, + &mut fill_values, + &[], + mock_prompt, + mock_enum_prompt, + ); + assert!(result.is_ok()); + + // Verify both actions were updated + match &plan.actions[0] { + MigrationAction::AddColumn { fill_with, .. } => { + assert_eq!(fill_with, &Some("'user@example.com'".to_string())); + } + _ => panic!("Expected AddColumn action"), + } + + match &plan.actions[1] { + MigrationAction::ModifyColumnNullable { fill_with, .. } => { + assert_eq!(fill_with, &Some("'pending'".to_string())); + } + _ => panic!("Expected ModifyColumnNullable action"), + } +} + +#[test] +fn test_collect_fill_with_values_enum_column() { + use vespertide_planner::FillWithRequired; + + let missing = vec![FillWithRequired { + action_index: 0, + table: "orders".to_string(), + column: "status".to_string(), + action_type: "AddColumn", + column_type: "enum".to_string(), + default_value: "''".to_string(), + enum_values: Some(vec![ + "pending".to_string(), + "confirmed".to_string(), + "shipped".to_string(), + ]), + has_foreign_key: false, + }]; + + let mut fill_values = HashMap::new(); + + // Mock prompt function that should NOT be called for enum columns + let mock_prompt = |_prompt: &str, _default: &str| -> Result { + panic!("Should not be called for enum columns"); + }; + + // Mock enum prompt that selects the second value + let mock_enum = |_prompt: &str, values: &[String]| -> Result { + // Select "confirmed" (index 1) + Ok(format!("'{}'", values[1])) + }; + + let result = collect_fill_with_values(&missing, &mut fill_values, mock_prompt, mock_enum); + assert!(result.is_ok()); + assert_eq!(fill_values.len(), 1); + assert_eq!( + fill_values.get(&("orders".to_string(), "status".to_string())), + Some(&"'confirmed'".to_string()) + ); +} + +#[test] +fn test_wrap_if_spaces_empty() { + assert_eq!(wrap_if_spaces(String::new()), ""); +} + +#[test] +fn test_wrap_if_spaces_no_spaces() { + assert_eq!(wrap_if_spaces("value".to_string()), "value"); +} + +#[test] +fn test_wrap_if_spaces_with_spaces() { + assert_eq!(wrap_if_spaces("my value".to_string()), "'my value'"); +} + +#[test] +fn test_wrap_if_spaces_already_quoted() { + assert_eq!( + wrap_if_spaces("'already quoted'".to_string()), + "'already quoted'" + ); +} + +#[test] +fn test_wrap_if_spaces_multiple_spaces() { + assert_eq!(wrap_if_spaces("a b c".to_string()), "'a b c'"); +} + +// ── enum fill_with tests ─────────────────────────────────────────── + +#[test] +fn test_collect_enum_fill_with_values_single_removal() { + use vespertide_planner::EnumFillWithRequired; + + let missing = vec![EnumFillWithRequired { + action_index: 0, + table: "orders".to_string(), + column: "status".to_string(), + removed_values: vec!["cancelled".to_string()], + remaining_values: vec!["pending".to_string(), "shipped".to_string()], + }]; + + // Mock prompt: always select first remaining value + let mock_enum = |_prompt: &str, values: &[String]| -> Result { Ok(values[0].clone()) }; + + let result = collect_enum_fill_with_values(&missing, mock_enum); + assert!(result.is_ok()); + let collected = result.unwrap(); + assert_eq!(collected.len(), 1); + assert_eq!(collected[0].0, 0); // action_index + assert_eq!( + collected[0].1.get("cancelled"), + Some(&"pending".to_string()) + ); +} + +#[test] +fn test_collect_enum_fill_with_values_multiple_removals() { + use vespertide_planner::EnumFillWithRequired; + + let missing = vec![EnumFillWithRequired { + action_index: 0, + table: "orders".to_string(), + column: "status".to_string(), + removed_values: vec!["cancelled".to_string(), "draft".to_string()], + remaining_values: vec!["pending".to_string(), "shipped".to_string()], + }]; + + // Mock prompt: always select second remaining value + let mock_enum = |_prompt: &str, values: &[String]| -> Result { Ok(values[1].clone()) }; + + let result = collect_enum_fill_with_values(&missing, mock_enum); + assert!(result.is_ok()); + let collected = result.unwrap(); + assert_eq!(collected[0].1.len(), 2); + assert_eq!( + collected[0].1.get("cancelled"), + Some(&"shipped".to_string()) + ); + assert_eq!(collected[0].1.get("draft"), Some(&"shipped".to_string())); +} + +#[test] +fn test_apply_enum_fill_with_to_plan() { + use vespertide_core::{ColumnType, ComplexColumnType, EnumValues}; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![MigrationAction::ModifyColumnType { + table: "orders".into(), + column: "status".into(), + new_type: ColumnType::Complex(ComplexColumnType::Enum { + name: "order_status".into(), + values: EnumValues::String(vec!["pending".into(), "shipped".into()]), + }), + fill_with: None, + }], + }; + + let mut mappings = BTreeMap::new(); + mappings.insert("cancelled".to_string(), "pending".to_string()); + let collected = vec![(0usize, mappings)]; + + apply_enum_fill_with_to_plan(&mut plan, &collected); + + if let MigrationAction::ModifyColumnType { fill_with, .. } = &plan.actions[0] { + let fw = fill_with.as_ref().expect("fill_with should be set"); + assert_eq!(fw.get("cancelled"), Some(&"pending".to_string())); + } else { + panic!("Expected ModifyColumnType"); + } +} + +#[test] +fn test_handle_missing_enum_fill_with_collects_and_applies() { + use vespertide_core::{ColumnDef, ColumnType, ComplexColumnType, EnumValues}; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![MigrationAction::ModifyColumnType { + table: "orders".into(), + column: "status".into(), + new_type: ColumnType::Complex(ComplexColumnType::Enum { + name: "order_status".into(), + values: EnumValues::String(vec!["pending".into(), "shipped".into()]), + }), + fill_with: None, + }], + }; + + let baseline = vec![TableDef { + name: "orders".into(), + description: None, + columns: vec![ColumnDef { + name: "status".into(), + r#type: ColumnType::Complex(ComplexColumnType::Enum { + name: "order_status".into(), + values: EnumValues::String(vec![ + "pending".into(), + "shipped".into(), + "cancelled".into(), + ]), + }), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }], + constraints: vec![], + }]; + + // Mock: always select first remaining value + let mock_enum = |_prompt: &str, values: &[String]| -> Result { Ok(values[0].clone()) }; + + let result = handle_missing_enum_fill_with(&mut plan, &baseline, mock_enum); + assert!(result.is_ok()); + + if let MigrationAction::ModifyColumnType { fill_with, .. } = &plan.actions[0] { + let fw = fill_with.as_ref().expect("fill_with should be populated"); + assert_eq!(fw.get("cancelled"), Some(&"pending".to_string())); + } else { + panic!("Expected ModifyColumnType"); + } +} + +#[test] +fn test_handle_missing_enum_fill_with_no_missing() { + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![], + }; + + let mock_enum = |_prompt: &str, _values: &[String]| -> Result { + panic!("Should not be called when nothing is missing"); + }; + + let result = handle_missing_enum_fill_with(&mut plan, &[], mock_enum); + assert!(result.is_ok()); +} + +#[test] +fn test_apply_enum_fill_with_to_plan_extends_existing() { + use vespertide_core::{ColumnType, ComplexColumnType, EnumValues}; + + // Start with a fill_with that already has one entry + let mut existing_fw = BTreeMap::new(); + existing_fw.insert("draft".to_string(), "pending".to_string()); + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![MigrationAction::ModifyColumnType { + table: "orders".into(), + column: "status".into(), + new_type: ColumnType::Complex(ComplexColumnType::Enum { + name: "order_status".into(), + values: EnumValues::String(vec!["pending".into(), "shipped".into()]), + }), + fill_with: Some(existing_fw), + }], + }; + + // Collect additional mappings + let mut new_mappings = BTreeMap::new(); + new_mappings.insert("cancelled".to_string(), "shipped".to_string()); + let collected = vec![(0usize, new_mappings)]; + + apply_enum_fill_with_to_plan(&mut plan, &collected); + + if let MigrationAction::ModifyColumnType { fill_with, .. } = &plan.actions[0] { + let fw = fill_with.as_ref().expect("fill_with should be set"); + // Original entry preserved + assert_eq!(fw.get("draft"), Some(&"pending".to_string())); + // New entry added + assert_eq!(fw.get("cancelled"), Some(&"shipped".to_string())); + // Total 2 entries + assert_eq!(fw.len(), 2); + } else { + panic!("Expected ModifyColumnType"); + } +} + +#[test] +fn test_strip_enum_quotes_with_quotes() { + assert_eq!(strip_enum_quotes("'active'"), "active"); +} + +#[test] +fn test_strip_enum_quotes_bare_value() { + assert_eq!(strip_enum_quotes("active"), "active"); +} + +#[test] +fn test_strip_enum_quotes_empty() { + assert_eq!(strip_enum_quotes(""), ""); +} + +#[test] +fn test_strip_enum_quotes_only_leading() { + assert_eq!(strip_enum_quotes("'active"), "active"); +} + +#[test] +fn test_strip_enum_quotes_only_trailing() { + assert_eq!(strip_enum_quotes("active'"), "active"); +} diff --git a/crates/vespertide-cli/src/commands/revision/tests/recreate.rs b/crates/vespertide-cli/src/commands/revision/tests/recreate.rs new file mode 100644 index 00000000..b3edcb55 --- /dev/null +++ b/crates/vespertide-cli/src/commands/revision/tests/recreate.rs @@ -0,0 +1,720 @@ +use super::*; + +#[tokio::test] +#[serial_test::serial] +async fn cmd_revision_writes_migration() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + + let cfg = write_config(); + write_model("users"); + std_fs::create_dir_all(cfg.migrations_dir()).unwrap(); + + cmd_revision("init".into(), vec![], vec![]).await.unwrap(); + + let entries: Vec<_> = std_fs::read_dir(cfg.migrations_dir()).unwrap().collect(); + assert!(!entries.is_empty()); +} + +#[tokio::test] +#[serial_test::serial] +async fn cmd_revision_no_changes_short_circuits() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + + let cfg = write_config(); + // no models, no migrations -> plan with no actions -> early return + assert!(cmd_revision("noop".into(), vec![], vec![]).await.is_ok()); + // migrations dir should not be created + assert!(!cfg.migrations_dir().exists()); +} + +#[tokio::test] +#[serial_test::serial] +async fn cmd_revision_writes_yaml_when_configured() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + + let cfg = write_config_with_format(Some(FileFormat::Yaml)); + write_model("users"); + // ensure migrations dir absent to exercise create_dir_all branch + if cfg.migrations_dir().exists() { + std_fs::remove_dir_all(cfg.migrations_dir()).unwrap(); + } + + cmd_revision("yaml".into(), vec![], vec![]).await.unwrap(); + + let entries: Vec<_> = std_fs::read_dir(cfg.migrations_dir()).unwrap().collect(); + assert!(!entries.is_empty()); + let has_yaml = entries.iter().any(|e| { + e.as_ref() + .unwrap() + .path() + .extension() + .is_some_and(|s| s == "yaml") + }); + assert!(has_yaml); +} + +#[test] +fn find_non_nullable_fk_add_column_detects_recreate() { + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + let plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![ + MigrationAction::AddColumn { + table: "post".into(), + column: Box::new(ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: Some("1".into()), + }, + MigrationAction::AddConstraint { + table: "post".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + ], + }; + let result = find_non_nullable_fk_add_columns(&plan, &[]); + assert_eq!(result.len(), 1); + assert_eq!(result[0].table, "post"); + assert_eq!(result[0].column, "user_id"); + assert_eq!(result[0].reason, RecreateReason::AddColumnWithFk); +} + +#[test] +fn find_non_nullable_inline_fk_add_column_detects_recreate() { + use vespertide_core::schema::foreign_key::{ForeignKeyDef, ForeignKeySyntax}; + use vespertide_core::{ColumnDef, ColumnType, ReferenceAction, SimpleColumnType}; + + let plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![MigrationAction::AddColumn { + table: "post".into(), + column: Box::new(ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: Some(ForeignKeySyntax::Object(ForeignKeyDef { + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: None, + })), + }), + fill_with: None, + }], + }; + + let result = find_non_nullable_fk_add_columns(&plan, &[]); + assert_eq!(result.len(), 1); + assert_eq!(result[0].table, "post"); + assert_eq!(result[0].column, "user_id"); + assert_eq!(result[0].reason, RecreateReason::AddColumnWithFk); +} + +#[test] +fn find_nullable_fk_add_column_returns_empty() { + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + let plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![ + MigrationAction::AddColumn { + table: "post".into(), + column: Box::new(ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }, + MigrationAction::AddConstraint { + table: "post".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + ], + }; + assert!(find_non_nullable_fk_add_columns(&plan, &[]).is_empty()); +} + +#[test] +fn find_non_nullable_no_fk_returns_empty() { + // Regular non-nullable column without FK should NOT trigger recreation + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + let plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![MigrationAction::AddColumn { + table: "post".into(), + column: Box::new(ColumnDef { + name: "user_id1".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }], + }; + // Should return empty — this column needs fill_with but that's handled separately + assert!(find_non_nullable_fk_add_columns(&plan, &[]).is_empty()); +} + +#[test] +fn find_fk_on_existing_non_nullable_column_detects_recreate() { + // Adding FK constraint to an existing non-nullable column should trigger recreation + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + let plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![MigrationAction::AddConstraint { + table: "post".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }], + }; + let models = vec![TableDef { + name: "post".into(), + description: None, + columns: vec![ + ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![], + }]; + let result = find_non_nullable_fk_add_columns(&plan, &models); + assert_eq!(result.len(), 1); + assert_eq!(result[0].table, "post"); + assert_eq!(result[0].column, "user_id"); + assert_eq!(result[0].reason, RecreateReason::AddFkToExistingColumn); +} + +#[test] +fn find_fk_on_existing_nullable_column_returns_empty() { + // Adding FK constraint to an existing nullable column should NOT trigger recreation + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + let plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![MigrationAction::AddConstraint { + table: "post".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }], + }; + let models = vec![TableDef { + name: "post".into(), + description: None, + columns: vec![ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }], + constraints: vec![], + }]; + assert!(find_non_nullable_fk_add_columns(&plan, &models).is_empty()); +} + +#[test] +fn find_fk_on_existing_column_with_default_returns_empty() { + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + + let plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![MigrationAction::AddConstraint { + table: "post".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }], + }; + let models = vec![TableDef { + name: "post".into(), + description: None, + columns: vec![ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: Some(true.into()), + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }], + constraints: vec![], + }]; + + assert!(find_non_nullable_fk_add_columns(&plan, &models).is_empty()); +} + +#[test] +fn find_fk_on_existing_column_missing_from_model_returns_empty() { + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + + let plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![MigrationAction::AddConstraint { + table: "post".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }], + }; + let models = vec![TableDef { + name: "post".into(), + description: None, + columns: vec![ColumnDef { + name: "other_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }], + constraints: vec![], + }]; + + assert!(find_non_nullable_fk_add_columns(&plan, &models).is_empty()); +} + +#[test] +fn rewrite_plan_replaces_actions_with_recreate() { + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![ + MigrationAction::AddColumn { + table: "post".into(), + column: Box::new(ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }, + MigrationAction::AddConstraint { + table: "post".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + ], + }; + + let recreate = vec![RecreateTableRequired { + table: "post".into(), + column: "user_id".into(), + reason: RecreateReason::AddColumnWithFk, + }]; + + let models = vec![TableDef { + name: "post".into(), + description: None, + columns: vec![ + ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![], + }]; + + rewrite_plan_for_recreation(&mut plan, &recreate, &models); + + assert_eq!(plan.actions.len(), 2); + assert!(matches!(&plan.actions[0], MigrationAction::DeleteTable { table } if table == "post")); + assert!( + matches!(&plan.actions[1], MigrationAction::CreateTable { table, .. } if table == "post") + ); +} + +#[test] +fn rewrite_plan_keeps_non_table_actions() { + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![ + MigrationAction::RawSql { + sql: "select 1".into(), + }, + MigrationAction::AddColumn { + table: "post".into(), + column: Box::new(ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }, + ], + }; + + let recreate = vec![RecreateTableRequired { + table: "post".into(), + column: "user_id".into(), + reason: RecreateReason::AddColumnWithFk, + }]; + + let models = vec![TableDef { + name: "post".into(), + description: None, + columns: vec![ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }], + constraints: vec![], + }]; + + rewrite_plan_for_recreation(&mut plan, &recreate, &models); + + assert!(matches!(&plan.actions[0], MigrationAction::RawSql { sql } if sql == "select 1")); + assert!(matches!(&plan.actions[1], MigrationAction::DeleteTable { table } if table == "post")); + assert!( + matches!(&plan.actions[2], MigrationAction::CreateTable { table, .. } if table == "post") + ); +} + +#[test] +fn handle_recreate_requirements_returns_ok_when_no_fk() { + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::RawSql { + sql: "select 1".into(), + }], + }; + + handle_recreate_requirements(&mut plan, &[], |_| Ok(true)).unwrap(); + + assert_eq!(plan.actions.len(), 1); +} + +#[test] +fn handle_recreate_requirements_bails_when_prompt_rejected() { + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![ + MigrationAction::AddColumn { + table: "post".into(), + column: Box::new(ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }, + MigrationAction::AddConstraint { + table: "post".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + ], + }; + + let err = handle_recreate_requirements(&mut plan, &[], |_| Ok(false)).unwrap_err(); + + assert!( + err.to_string() + .contains("Migration cancelled. To proceed without recreation") + ); +} + +#[test] +fn handle_recreate_requirements_empties_plan_when_model_missing() { + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![ + MigrationAction::AddColumn { + table: "post".into(), + column: Box::new(ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }, + MigrationAction::AddConstraint { + table: "post".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + ], + }; + + handle_recreate_requirements(&mut plan, &[], |_| Ok(true)).unwrap(); + + assert!(plan.actions.is_empty()); +} + +#[test] +fn handle_recreate_requirements_rewrites_plan_when_model_exists() { + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![ + MigrationAction::AddColumn { + table: "post".into(), + column: Box::new(ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }, + MigrationAction::AddConstraint { + table: "post".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + ], + }; + + let models = vec![TableDef { + name: "post".into(), + description: None, + columns: vec![ + ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![], + }]; + + handle_recreate_requirements(&mut plan, &models, |_| Ok(true)).unwrap(); + + assert_eq!(plan.actions.len(), 2); + assert!(matches!(&plan.actions[0], MigrationAction::DeleteTable { table } if table == "post")); + assert!( + matches!(&plan.actions[1], MigrationAction::CreateTable { table, .. } if table == "post") + ); +} diff --git a/crates/vespertide-cli/src/commands/revision/write.rs b/crates/vespertide-cli/src/commands/revision/write.rs new file mode 100644 index 00000000..dfb12fda --- /dev/null +++ b/crates/vespertide-cli/src/commands/revision/write.rs @@ -0,0 +1,69 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde_json::Value; +use tokio::fs; +use vespertide_config::{FileFormat, VespertideConfig}; +use vespertide_core::MigrationPlan; + +use crate::utils::{migration_filename_with_format_and_pattern, schema_url}; + +pub(super) async fn write_migration_file( + config: &VespertideConfig, + plan: &MigrationPlan, +) -> Result { + let migrations_dir = config.migrations_dir(); + if !migrations_dir.exists() { + fs::create_dir_all(&migrations_dir) + .await + .context("create migrations directory")?; + } + + let format = config.migration_format(); + let filename = migration_filename_with_format_and_pattern( + plan.version, + plan.comment.as_deref(), + format, + config.migration_filename_pattern(), + ); + let path = migrations_dir.join(&filename); + + let schema_url = schema_url("migration.schema.json"); + match format { + FileFormat::Json => write_json_with_schema(&path, plan, &schema_url).await?, + FileFormat::Yaml | FileFormat::Yml => write_yaml(&path, plan, &schema_url).await?, + } + + Ok(path) +} + +pub(super) async fn write_json_with_schema( + path: &Path, + plan: &MigrationPlan, + schema_url: &str, +) -> Result<()> { + let mut value = serde_json::to_value(plan).context("serialize migration plan to json")?; + if let Value::Object(ref mut map) = value { + map.insert("$schema".to_string(), Value::String(schema_url.to_string())); + } + let text = serde_json::to_string_pretty(&value).context("stringify json with schema")?; + fs::write(path, text) + .await + .with_context(|| format!("write file: {}", path.display()))?; + Ok(()) +} + +pub(super) async fn write_yaml(path: &Path, plan: &MigrationPlan, schema_url: &str) -> Result<()> { + let mut value = serde_yaml::to_value(plan).context("serialize migration plan to yaml value")?; + if let serde_yaml::Value::Mapping(ref mut map) = value { + map.insert( + serde_yaml::Value::String("$schema".to_string()), + serde_yaml::Value::String(schema_url.to_string()), + ); + } + let text = serde_yaml::to_string(&value).context("serialize yaml with schema")?; + fs::write(path, text) + .await + .with_context(|| format!("write file: {}", path.display()))?; + Ok(()) +} diff --git a/crates/vespertide-cli/src/commands/sql.rs b/crates/vespertide-cli/src/commands/sql.rs index b40538a8..55f678b5 100644 --- a/crates/vespertide-cli/src/commands/sql.rs +++ b/crates/vespertide-cli/src/commands/sql.rs @@ -17,12 +17,12 @@ pub async fn cmd_sql(backend: DatabaseBackend) -> Result<()> { .map(|p| p.with_prefix(prefix)) .collect(); let baseline_schema = schema_from_plans(&prefixed_plans) - .map_err(|e| anyhow::anyhow!("failed to reconstruct schema: {}", e))?; + .map_err(|e| anyhow::anyhow!("failed to reconstruct schema: {e}"))?; // Plan next migration using the pre-computed baseline let plan = plan_next_migration_with_baseline(¤t_models, &prefixed_plans, &baseline_schema) - .map_err(|e| anyhow::anyhow!("planning error: {}", e))?; + .map_err(|e| anyhow::anyhow!("planning error: {e}"))?; // Apply prefix to the new plan for SQL generation let prefixed_plan = plan.with_prefix(prefix); @@ -45,7 +45,7 @@ fn emit_sql( } let plan_queries = build_plan_queries(plan, current_schema) - .map_err(|e| anyhow::anyhow!("query build error: {}", e))?; + .map_err(|e| anyhow::anyhow!("query build error: {e}"))?; // Select queries for the specified backend let queries: Vec<_> = plan_queries @@ -102,7 +102,7 @@ fn emit_sql( if queries.len() > 1 { format!("-{}", j + 1) } else { - "".to_string() + String::new() } .bright_magenta() .bold(), diff --git a/crates/vespertide-cli/src/commands/status.rs b/crates/vespertide-cli/src/commands/status.rs index 6fc69b66..746efd4f 100644 --- a/crates/vespertide-cli/src/commands/status.rs +++ b/crates/vespertide-cli/src/commands/status.rs @@ -5,6 +5,10 @@ use vespertide_planner::schema_from_plans; use crate::utils::{load_config, load_migrations, load_models}; use std::collections::HashSet; +#[expect( + clippy::too_many_lines, + reason = "status command output is a single linear report" +)] pub async fn cmd_status() -> Result<()> { let config = load_config()?; let current_models = load_models(&config)?; @@ -50,7 +54,9 @@ pub async fn cmd_status() -> Result<()> { applied_plans.len().to_string().bright_yellow() ); if !applied_plans.is_empty() { - let latest = applied_plans.last().unwrap(); + let Some(latest) = applied_plans.last() else { + return Ok(()); + }; println!( " {} {}", "Latest version:".cyan(), @@ -112,7 +118,7 @@ pub async fn cmd_status() -> Result<()> { if !applied_plans.is_empty() { let baseline = schema_from_plans(&applied_plans) - .map_err(|e| anyhow::anyhow!("schema reconstruction error: {}", e))?; + .map_err(|e| anyhow::anyhow!("schema reconstruction error: {e}"))?; let baseline_tables: HashSet<_> = baseline.iter().map(|t| &t.name).collect(); let current_tables: HashSet<_> = current_models.iter().map(|t| &t.name).collect(); @@ -274,6 +280,26 @@ mod tests { cmd_status().await.unwrap(); } + #[tokio::test] + #[serial] + async fn cmd_status_empty_migration_list_returns_ok() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + let cfg = write_config(); + fs::create_dir_all(cfg.models_dir()).unwrap(); + fs::create_dir_all(cfg.migrations_dir()).unwrap(); + + cmd_status().await.unwrap(); + } + + #[test] + fn cmd_status_does_not_unwrap_latest_migration() { + let source = include_str!("status.rs"); + let needle = ["applied_plans.last()", ".unwrap()"].join(""); + + assert!(!source.contains(&needle)); + } + #[tokio::test] #[serial] async fn cmd_status_models_no_migrations_prints_hint() { diff --git a/crates/vespertide-cli/src/utils.rs b/crates/vespertide-cli/src/utils.rs index fbc4a48a..e9b4b7a4 100644 --- a/crates/vespertide-cli/src/utils.rs +++ b/crates/vespertide-cli/src/utils.rs @@ -1,8 +1,20 @@ +use std::fmt::Write as _; use vespertide_config::FileFormat; // Re-export loader functions for convenience pub use vespertide_loader::{load_config, load_migrations, load_models}; +pub(crate) fn schema_url(schema_filename: &str) -> String { + // If not set, default to public raw GitHub schema location. + // Users can override via VESP_SCHEMA_BASE_URL. + let base = std::env::var("VESP_SCHEMA_BASE_URL").ok(); + let base = base.as_deref().unwrap_or( + "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas", + ); + let base = base.trim_end_matches('/'); + format!("{base}/{schema_filename}") +} + /// Generate a migration filename from version and optional comment with format and pattern. pub fn migration_filename_with_format_and_pattern( version: u32, @@ -43,7 +55,7 @@ fn sanitize_comment(comment: Option<&str>) -> String { } fn render_migration_name(pattern: &str, version: u32, sanitized_comment: &str) -> String { - let default_version = format!("{:04}", version); + let default_version = format!("{version:04}"); let chars: Vec = pattern.chars().collect(); let mut i = 0; let mut out = String::new(); @@ -73,7 +85,7 @@ fn render_migration_name(pattern: &str, version: u32, sanitized_comment: &str) - if w == 0 { out.push_str(&default_version); } else { - out.push_str(&format!("{:0width$}", version, width = w)); + let _ = write!(out, "{version:0w$}"); } i = j + 1; continue; diff --git a/crates/vespertide-config/Cargo.toml b/crates/vespertide-config/Cargo.toml index 0cdcbe9f..39c8eb58 100644 --- a/crates/vespertide-config/Cargo.toml +++ b/crates/vespertide-config/Cargo.toml @@ -7,6 +7,9 @@ repository.workspace = true homepage.workspace = true documentation.workspace = true description = "Manages models/migrations directories and naming-case preferences" +keywords = ["database", "migration", "schema", "orm", "sql"] +categories = ["database"] +readme = "../../README.md" [dependencies] serde = { version = "1", features = ["derive"] } @@ -20,3 +23,6 @@ schema = ["dep:schemars"] [dev-dependencies] serde_json = "1" + +[lints] +workspace = true diff --git a/crates/vespertide-config/src/config.rs b/crates/vespertide-config/src/config.rs index 0b7a4822..4242f2c7 100644 --- a/crates/vespertide-config/src/config.rs +++ b/crates/vespertide-config/src/config.rs @@ -22,7 +22,7 @@ pub struct SeaOrmConfig { /// Additional derive macros to add to generated entity model types. #[serde(default)] pub extra_model_derives: Vec, - /// Naming case for serde rename_all attribute on generated enums. + /// Naming case for serde `rename_all` attribute on generated enums. /// Default: `Camel` (generates `#[serde(rename_all = "camelCase")]`) #[serde(default = "default_enum_naming_case")] pub enum_naming_case: NameCase, @@ -66,7 +66,7 @@ impl SeaOrmConfig { &self.extra_model_derives } - /// Get the naming case for serde rename_all attribute on generated enums. + /// Get the naming case for serde `rename_all` attribute on generated enums. pub fn enum_naming_case(&self) -> NameCase { self.enum_naming_case } diff --git a/crates/vespertide-config/src/lib.rs b/crates/vespertide-config/src/lib.rs index 1ec2e86a..e8f6640d 100644 --- a/crates/vespertide-config/src/lib.rs +++ b/crates/vespertide-config/src/lib.rs @@ -1,3 +1,8 @@ +//! Configuration parsing for vespertide projects. +//! +//! Reads `vespertide.json` (or `.yaml`) with paths, naming conventions, +//! and file format preferences. + pub mod config; pub mod file_format; pub mod name_case; @@ -67,7 +72,7 @@ mod tests { #[test] fn seaorm_config_deserialize_with_defaults() { - let json = r#"{}"#; + let json = r"{}"; let cfg: SeaOrmConfig = serde_json::from_str(json).unwrap(); assert_eq!(cfg.extra_enum_derives(), &["vespera::Schema".to_string()]); assert!(cfg.extra_model_derives().is_empty()); diff --git a/crates/vespertide-config/src/name_case.rs b/crates/vespertide-config/src/name_case.rs index edd01448..d5d9c702 100644 --- a/crates/vespertide-config/src/name_case.rs +++ b/crates/vespertide-config/src/name_case.rs @@ -26,7 +26,7 @@ impl NameCase { matches!(self, NameCase::Pascal) } - /// Returns the serde rename_all attribute value for this case. + /// Returns the serde `rename_all` attribute value for this case. pub fn serde_rename_all(self) -> &'static str { match self { NameCase::Snake => "snake_case", diff --git a/crates/vespertide-core/AGENTS.md b/crates/vespertide-core/AGENTS.md index fe3c76b6..f5650fa4 100644 --- a/crates/vespertide-core/AGENTS.md +++ b/crates/vespertide-core/AGENTS.md @@ -7,7 +7,7 @@ Core data structures for schema definition and migration planning. ``` src/ ├── lib.rs # Re-exports all public types -├── action.rs # MigrationAction (12 variants), MigrationPlan +├── action.rs # MigrationAction (14 variants), MigrationPlan (1236 lines; scheduled split) ├── migration.rs # MigrationError, MigrationOptions └── schema/ ├── column.rs # ColumnDef, ColumnType, SimpleColumnType, ComplexColumnType @@ -66,3 +66,11 @@ MigrationAction::AddColumn { table, column, fill_with } | Omitting inline fields in ColumnDef | Include all 4: `primary_key`, `unique`, `index`, `foreign_key` | | Using TableDef without normalize() | Call `normalize()` before diffing | | Direct TableConstraint in column | Use inline syntax, let normalize() convert | + +## NOTES + +- Model and migration serialization supports both JSON and YAML. +- Prefer typed `MigrationAction` enums; `MigrationAction::RawSql` exists as a documented emergency escape hatch, but is not recommended for normal use. +- Shared proptest strategies live behind the `arbitrary` feature. Run property tests with `cargo test -p vespertide-core --features arbitrary`. +- Every `.rs` file must stay ≤ 1000 lines (CI enforced); current hotspots include `action.rs` (1236 lines) and `schema/table.rs` (1526 lines). +- Workspace lints warn on unsafe code and Clippy all: `unsafe_code = "warn"`, `clippy::all = { level = "warn", priority = -1 }`. diff --git a/crates/vespertide-core/Cargo.toml b/crates/vespertide-core/Cargo.toml index 9f79e1e2..4bd6e87f 100644 --- a/crates/vespertide-core/Cargo.toml +++ b/crates/vespertide-core/Cargo.toml @@ -7,17 +7,32 @@ repository.workspace = true homepage.workspace = true documentation.workspace = true description = "Data models for tables, columns, constraints, indexes, and migration actions" +keywords = ["database", "migration", "schema", "orm", "sql"] +categories = ["database"] +readme = "../../README.md" [dependencies] +proptest = { version = "1", optional = true, default-features = false, features = ["std", "bit-set"] } serde = { version = "1", features = ["derive"] } +sea-orm = { version = "2.0.0-rc.38", default-features = false } schemars = { version = "1.2", optional = true } thiserror = "2" vespertide-naming = { workspace = true } [features] +arbitrary = ["dep:proptest"] default = ["schema"] schema = ["dep:schemars"] [dev-dependencies] +criterion = { version = "0.5", features = ["html_reports"] } +proptest = "1" rstest = "0.26" serde_json = "1" + +[[bench]] +name = "normalize_benchmarks" +harness = false + +[lints] +workspace = true diff --git a/crates/vespertide-core/benches/normalize_benchmarks.rs b/crates/vespertide-core/benches/normalize_benchmarks.rs new file mode 100644 index 00000000..88c03236 --- /dev/null +++ b/crates/vespertide-core/benches/normalize_benchmarks.rs @@ -0,0 +1,81 @@ +use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main}; +use vespertide_core::schema::foreign_key::{ForeignKeyDef, ForeignKeySyntax}; +use vespertide_core::schema::primary_key::PrimaryKeySyntax; +use vespertide_core::{ + ColumnDef, ColumnType, ReferenceAction, SimpleColumnType, StrOrBoolOrArray, TableDef, +}; + +fn simple_type(ty: SimpleColumnType) -> ColumnType { + ColumnType::Simple(ty) +} + +fn build_table(n_columns: usize, with_inline_constraints: bool) -> TableDef { + let mut columns = Vec::with_capacity(n_columns.max(1)); + columns.push( + ColumnDef::new("id", simple_type(SimpleColumnType::Integer), false) + .primary_key(PrimaryKeySyntax::Bool(true)), + ); + + for i in 1..n_columns { + let mut column = ColumnDef::new( + format!("column_{i}"), + if i % 3 == 0 { + simple_type(SimpleColumnType::Integer) + } else { + simple_type(SimpleColumnType::Text) + }, + i % 7 == 0, + ); + + if with_inline_constraints { + if i % 5 == 0 { + column = column.index(StrOrBoolOrArray::Bool(true)); + } + if i % 11 == 0 { + column = column.unique(StrOrBoolOrArray::Str(format!("uq_norm__column_{i}"))); + } + if i % 17 == 0 { + column = column.foreign_key(ForeignKeySyntax::Object(ForeignKeyDef { + ref_table: "parent".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: None, + })); + } + } + + columns.push(column); + } + + TableDef { + name: format!("normalize_{n_columns}_{with_inline_constraints}"), + description: None, + columns, + constraints: vec![], + } +} + +fn bench_normalize(c: &mut Criterion) { + let mut group = c.benchmark_group("table_normalize"); + for n_columns in [10, 100, 500] { + for with_inline_constraints in [false, true] { + let table = build_table(n_columns, with_inline_constraints); + group.bench_with_input( + BenchmarkId::new( + if with_inline_constraints { + "with_inline_constraints" + } else { + "without_inline_constraints" + }, + n_columns, + ), + &table, + |b, table| b.iter(|| black_box(table).normalize()), + ); + } + } + group.finish(); +} + +criterion_group!(benches, bench_normalize); +criterion_main!(benches); diff --git a/crates/vespertide-core/src/action.rs b/crates/vespertide-core/src/action.rs deleted file mode 100644 index a8fe7437..00000000 --- a/crates/vespertide-core/src/action.rs +++ /dev/null @@ -1,1270 +0,0 @@ -use crate::schema::{ColumnDef, ColumnName, ColumnType, TableConstraint, TableName}; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use std::fmt; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -#[serde(rename_all = "snake_case")] -pub struct MigrationPlan { - /// Unique identifier for this migration (UUID format). - /// Defaults to empty string for backward compatibility with old migration files. - #[serde(default)] - pub id: String, - pub comment: Option, - #[serde(default)] - pub created_at: Option, - pub version: u32, - pub actions: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum MigrationAction { - CreateTable { - table: TableName, - columns: Vec, - constraints: Vec, - }, - DeleteTable { - table: TableName, - }, - AddColumn { - table: TableName, - column: Box, - /// Optional fill value to backfill existing rows when adding NOT NULL without default. - fill_with: Option, - }, - RenameColumn { - table: TableName, - from: ColumnName, - to: ColumnName, - }, - DeleteColumn { - table: TableName, - column: ColumnName, - }, - ModifyColumnType { - table: TableName, - column: ColumnName, - new_type: ColumnType, - /// Mapping of removed enum values to replacement values for safe enum value removal. - /// e.g., {"cancelled": "'pending'"} generates UPDATE before type change. - #[serde(default, skip_serializing_if = "Option::is_none")] - fill_with: Option>, - }, - ModifyColumnNullable { - table: TableName, - column: ColumnName, - nullable: bool, - /// Required when changing from nullable to non-nullable to backfill existing NULL values. - fill_with: Option, - /// When true, rows with NULL values in the column are deleted instead of backfilled. - /// Mutually exclusive with `fill_with`. Useful for FK columns where a valid fill value - /// may not exist. - #[serde(default, skip_serializing_if = "Option::is_none")] - delete_null_rows: Option, - }, - ModifyColumnDefault { - table: TableName, - column: ColumnName, - /// The new default value, or None to remove the default. - new_default: Option, - }, - ModifyColumnComment { - table: TableName, - column: ColumnName, - /// The new comment, or None to remove the comment. - new_comment: Option, - }, - AddConstraint { - table: TableName, - constraint: TableConstraint, - }, - RemoveConstraint { - table: TableName, - constraint: TableConstraint, - }, - ReplaceConstraint { - table: TableName, - from: TableConstraint, - to: TableConstraint, - }, - RenameTable { - from: TableName, - to: TableName, - }, - RawSql { - sql: String, - }, -} - -impl MigrationPlan { - /// Apply a prefix to all table names in the migration plan. - /// This modifies all table references in all actions. - pub fn with_prefix(self, prefix: &str) -> Self { - if prefix.is_empty() { - return self; - } - Self { - actions: self - .actions - .into_iter() - .map(|action| action.with_prefix(prefix)) - .collect(), - ..self - } - } -} - -impl MigrationAction { - /// Apply a prefix to all table names in this action. - pub fn with_prefix(self, prefix: &str) -> Self { - if prefix.is_empty() { - return self; - } - match self { - MigrationAction::CreateTable { - table, - columns, - constraints, - } => MigrationAction::CreateTable { - table: format!("{}{}", prefix, table), - columns, - constraints: constraints - .into_iter() - .map(|c| c.with_prefix(prefix)) - .collect(), - }, - MigrationAction::DeleteTable { table } => MigrationAction::DeleteTable { - table: format!("{}{}", prefix, table), - }, - MigrationAction::AddColumn { - table, - column, - fill_with, - } => MigrationAction::AddColumn { - table: format!("{}{}", prefix, table), - column, - fill_with, - }, - MigrationAction::RenameColumn { table, from, to } => MigrationAction::RenameColumn { - table: format!("{}{}", prefix, table), - from, - to, - }, - MigrationAction::DeleteColumn { table, column } => MigrationAction::DeleteColumn { - table: format!("{}{}", prefix, table), - column, - }, - MigrationAction::ModifyColumnType { - table, - column, - new_type, - fill_with, - } => MigrationAction::ModifyColumnType { - table: format!("{}{}", prefix, table), - column, - new_type, - fill_with, - }, - MigrationAction::ModifyColumnNullable { - table, - column, - nullable, - fill_with, - delete_null_rows, - } => MigrationAction::ModifyColumnNullable { - table: format!("{}{}", prefix, table), - column, - nullable, - fill_with, - delete_null_rows, - }, - MigrationAction::ModifyColumnDefault { - table, - column, - new_default, - } => MigrationAction::ModifyColumnDefault { - table: format!("{}{}", prefix, table), - column, - new_default, - }, - MigrationAction::ModifyColumnComment { - table, - column, - new_comment, - } => MigrationAction::ModifyColumnComment { - table: format!("{}{}", prefix, table), - column, - new_comment, - }, - MigrationAction::AddConstraint { table, constraint } => { - MigrationAction::AddConstraint { - table: format!("{}{}", prefix, table), - constraint: constraint.with_prefix(prefix), - } - } - MigrationAction::RemoveConstraint { table, constraint } => { - MigrationAction::RemoveConstraint { - table: format!("{}{}", prefix, table), - constraint: constraint.with_prefix(prefix), - } - } - MigrationAction::ReplaceConstraint { table, from, to } => { - MigrationAction::ReplaceConstraint { - table: format!("{}{}", prefix, table), - from: from.with_prefix(prefix), - to: to.with_prefix(prefix), - } - } - MigrationAction::RenameTable { from, to } => MigrationAction::RenameTable { - from: format!("{}{}", prefix, from), - to: format!("{}{}", prefix, to), - }, - MigrationAction::RawSql { sql } => MigrationAction::RawSql { sql }, - } - } -} - -impl fmt::Display for MigrationAction { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - MigrationAction::CreateTable { table, .. } => { - write!(f, "CreateTable: {}", table) - } - MigrationAction::DeleteTable { table } => { - write!(f, "DeleteTable: {}", table) - } - MigrationAction::AddColumn { table, column, .. } => { - write!(f, "AddColumn: {}.{}", table, column.name) - } - MigrationAction::RenameColumn { table, from, to } => { - write!(f, "RenameColumn: {}.{} -> {}", table, from, to) - } - MigrationAction::DeleteColumn { table, column } => { - write!(f, "DeleteColumn: {}.{}", table, column) - } - MigrationAction::ModifyColumnType { table, column, .. } => { - write!(f, "ModifyColumnType: {}.{}", table, column) - } - MigrationAction::ModifyColumnNullable { - table, - column, - nullable, - .. - } => { - let nullability = if *nullable { "NULL" } else { "NOT NULL" }; - write!( - f, - "ModifyColumnNullable: {}.{} -> {}", - table, column, nullability - ) - } - MigrationAction::ModifyColumnDefault { - table, - column, - new_default, - } => { - if let Some(default) = new_default { - write!( - f, - "ModifyColumnDefault: {}.{} -> {}", - table, column, default - ) - } else { - write!(f, "ModifyColumnDefault: {}.{} -> (none)", table, column) - } - } - MigrationAction::ModifyColumnComment { - table, - column, - new_comment, - } => { - if let Some(comment) = new_comment { - let display = if comment.chars().count() > 30 { - format!("{}...", comment.chars().take(27).collect::()) - } else { - comment.clone() - }; - write!( - f, - "ModifyColumnComment: {}.{} -> '{}'", - table, column, display - ) - } else { - write!(f, "ModifyColumnComment: {}.{} -> (none)", table, column) - } - } - MigrationAction::AddConstraint { table, constraint } => { - let constraint_name = match constraint { - TableConstraint::PrimaryKey { .. } => "PRIMARY KEY", - TableConstraint::Unique { name, .. } => { - if let Some(n) = name { - return write!(f, "AddConstraint: {}.{} (UNIQUE)", table, n); - } - "UNIQUE" - } - TableConstraint::ForeignKey { name, .. } => { - if let Some(n) = name { - return write!(f, "AddConstraint: {}.{} (FOREIGN KEY)", table, n); - } - "FOREIGN KEY" - } - TableConstraint::Check { name, .. } => { - return write!(f, "AddConstraint: {}.{} (CHECK)", table, name); - } - TableConstraint::Index { name, .. } => { - if let Some(n) = name { - return write!(f, "AddConstraint: {}.{} (INDEX)", table, n); - } - "INDEX" - } - }; - write!(f, "AddConstraint: {}.{}", table, constraint_name) - } - MigrationAction::RemoveConstraint { table, constraint } => { - let constraint_name = match constraint { - TableConstraint::PrimaryKey { .. } => "PRIMARY KEY", - TableConstraint::Unique { name, .. } => { - if let Some(n) = name { - return write!(f, "RemoveConstraint: {}.{} (UNIQUE)", table, n); - } - "UNIQUE" - } - TableConstraint::ForeignKey { name, .. } => { - if let Some(n) = name { - return write!(f, "RemoveConstraint: {}.{} (FOREIGN KEY)", table, n); - } - "FOREIGN KEY" - } - TableConstraint::Check { name, .. } => { - return write!(f, "RemoveConstraint: {}.{} (CHECK)", table, name); - } - TableConstraint::Index { name, .. } => { - if let Some(n) = name { - return write!(f, "RemoveConstraint: {}.{} (INDEX)", table, n); - } - "INDEX" - } - }; - write!(f, "RemoveConstraint: {}.{}", table, constraint_name) - } - MigrationAction::ReplaceConstraint { table, to, .. } => { - let constraint_name = match to { - TableConstraint::PrimaryKey { .. } => "PRIMARY KEY", - TableConstraint::Unique { name, .. } => { - if let Some(n) = name { - return write!(f, "ReplaceConstraint: {}.{} (UNIQUE)", table, n); - } - "UNIQUE" - } - TableConstraint::ForeignKey { name, .. } => { - if let Some(n) = name { - return write!(f, "ReplaceConstraint: {}.{} (FOREIGN KEY)", table, n); - } - "FOREIGN KEY" - } - TableConstraint::Check { name, .. } => { - return write!(f, "ReplaceConstraint: {}.{} (CHECK)", table, name); - } - TableConstraint::Index { name, .. } => { - if let Some(n) = name { - return write!(f, "ReplaceConstraint: {}.{} (INDEX)", table, n); - } - "INDEX" - } - }; - write!(f, "ReplaceConstraint: {}.{}", table, constraint_name) - } - MigrationAction::RenameTable { from, to } => { - write!(f, "RenameTable: {} -> {}", from, to) - } - MigrationAction::RawSql { sql } => { - // Truncate SQL if too long for display - let display_sql = if sql.len() > 50 { - format!("{}...", &sql[..47]) - } else { - sql.clone() - }; - write!(f, "RawSql: {}", display_sql) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::schema::{ReferenceAction, SimpleColumnType}; - use rstest::rstest; - - fn default_column() -> ColumnDef { - ColumnDef { - name: "email".into(), - r#type: ColumnType::Simple(SimpleColumnType::Text), - nullable: true, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - } - } - - #[rstest] - #[case::create_table( - MigrationAction::CreateTable { - table: "users".into(), - columns: vec![], - constraints: vec![], - }, - "CreateTable: users" - )] - #[case::delete_table( - MigrationAction::DeleteTable { - table: "users".into(), - }, - "DeleteTable: users" - )] - #[case::add_column( - MigrationAction::AddColumn { - table: "users".into(), - column: Box::new(default_column()), - fill_with: None, - }, - "AddColumn: users.email" - )] - #[case::rename_column( - MigrationAction::RenameColumn { - table: "users".into(), - from: "old_name".into(), - to: "new_name".into(), - }, - "RenameColumn: users.old_name -> new_name" - )] - #[case::delete_column( - MigrationAction::DeleteColumn { - table: "users".into(), - column: "email".into(), - }, - "DeleteColumn: users.email" - )] - #[case::modify_column_type( - MigrationAction::ModifyColumnType { - table: "users".into(), - column: "age".into(), - new_type: ColumnType::Simple(SimpleColumnType::Integer), - fill_with: None, - }, - "ModifyColumnType: users.age" - )] - #[case::add_constraint_index_with_name( - MigrationAction::AddConstraint { - table: "users".into(), - constraint: TableConstraint::Index { - name: Some("ix_users__email".into()), - columns: vec!["email".into()], - }, - }, - "AddConstraint: users.ix_users__email (INDEX)" - )] - #[case::add_constraint_index_without_name( - MigrationAction::AddConstraint { - table: "users".into(), - constraint: TableConstraint::Index { - name: None, - columns: vec!["email".into()], - }, - }, - "AddConstraint: users.INDEX" - )] - #[case::remove_constraint_index_with_name( - MigrationAction::RemoveConstraint { - table: "users".into(), - constraint: TableConstraint::Index { - name: Some("ix_users__email".into()), - columns: vec!["email".into()], - }, - }, - "RemoveConstraint: users.ix_users__email (INDEX)" - )] - #[case::remove_constraint_index_without_name( - MigrationAction::RemoveConstraint { - table: "users".into(), - constraint: TableConstraint::Index { - name: None, - columns: vec!["email".into()], - }, - }, - "RemoveConstraint: users.INDEX" - )] - #[case::rename_table( - MigrationAction::RenameTable { - from: "old_table".into(), - to: "new_table".into(), - }, - "RenameTable: old_table -> new_table" - )] - fn test_display_basic_actions(#[case] action: MigrationAction, #[case] expected: &str) { - assert_eq!(action.to_string(), expected); - } - - #[rstest] - #[case::add_constraint_primary_key( - MigrationAction::AddConstraint { - table: "users".into(), - constraint: TableConstraint::PrimaryKey { - auto_increment: false, - columns: vec!["id".into()], - }, - }, - "AddConstraint: users.PRIMARY KEY" - )] - #[case::add_constraint_unique_with_name( - MigrationAction::AddConstraint { - table: "users".into(), - constraint: TableConstraint::Unique { - name: Some("uq_email".into()), - columns: vec!["email".into()], - }, - }, - "AddConstraint: users.uq_email (UNIQUE)" - )] - #[case::add_constraint_unique_without_name( - MigrationAction::AddConstraint { - table: "users".into(), - constraint: TableConstraint::Unique { - name: None, - columns: vec!["email".into()], - }, - }, - "AddConstraint: users.UNIQUE" - )] - #[case::add_constraint_foreign_key_with_name( - MigrationAction::AddConstraint { - table: "posts".into(), - constraint: TableConstraint::ForeignKey { - name: Some("fk_user".into()), - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: Some(ReferenceAction::Cascade), - on_update: None, - }, - }, - "AddConstraint: posts.fk_user (FOREIGN KEY)" - )] - #[case::add_constraint_foreign_key_without_name( - MigrationAction::AddConstraint { - table: "posts".into(), - constraint: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }, - "AddConstraint: posts.FOREIGN KEY" - )] - #[case::add_constraint_check( - MigrationAction::AddConstraint { - table: "users".into(), - constraint: TableConstraint::Check { - name: "chk_age".into(), - expr: "age > 0".into(), - }, - }, - "AddConstraint: users.chk_age (CHECK)" - )] - fn test_display_add_constraint(#[case] action: MigrationAction, #[case] expected: &str) { - assert_eq!(action.to_string(), expected); - } - - #[rstest] - #[case::remove_constraint_primary_key( - MigrationAction::RemoveConstraint { - table: "users".into(), - constraint: TableConstraint::PrimaryKey { - auto_increment: false, - columns: vec!["id".into()], - }, - }, - "RemoveConstraint: users.PRIMARY KEY" - )] - #[case::remove_constraint_unique_with_name( - MigrationAction::RemoveConstraint { - table: "users".into(), - constraint: TableConstraint::Unique { - name: Some("uq_email".into()), - columns: vec!["email".into()], - }, - }, - "RemoveConstraint: users.uq_email (UNIQUE)" - )] - #[case::remove_constraint_unique_without_name( - MigrationAction::RemoveConstraint { - table: "users".into(), - constraint: TableConstraint::Unique { - name: None, - columns: vec!["email".into()], - }, - }, - "RemoveConstraint: users.UNIQUE" - )] - #[case::remove_constraint_foreign_key_with_name( - MigrationAction::RemoveConstraint { - table: "posts".into(), - constraint: TableConstraint::ForeignKey { - name: Some("fk_user".into()), - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }, - "RemoveConstraint: posts.fk_user (FOREIGN KEY)" - )] - #[case::remove_constraint_foreign_key_without_name( - MigrationAction::RemoveConstraint { - table: "posts".into(), - constraint: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }, - "RemoveConstraint: posts.FOREIGN KEY" - )] - #[case::remove_constraint_check( - MigrationAction::RemoveConstraint { - table: "users".into(), - constraint: TableConstraint::Check { - name: "chk_age".into(), - expr: "age > 0".into(), - }, - }, - "RemoveConstraint: users.chk_age (CHECK)" - )] - fn test_display_remove_constraint(#[case] action: MigrationAction, #[case] expected: &str) { - assert_eq!(action.to_string(), expected); - } - - #[rstest] - #[case::raw_sql_short( - MigrationAction::RawSql { - sql: "SELECT 1".into(), - }, - "RawSql: SELECT 1" - )] - fn test_display_raw_sql_short(#[case] action: MigrationAction, #[case] expected: &str) { - assert_eq!(action.to_string(), expected); - } - - #[test] - fn test_display_raw_sql_long() { - let action = MigrationAction::RawSql { - sql: - "SELECT * FROM users WHERE id = 1 AND name = 'test' AND email = 'test@example.com'" - .into(), - }; - let result = action.to_string(); - assert!(result.starts_with("RawSql: ")); - assert!(result.ends_with("...")); - assert!(result.len() > 10); - } - - #[rstest] - #[case::modify_column_nullable_to_not_null( - MigrationAction::ModifyColumnNullable { - table: "users".into(), - column: "email".into(), - nullable: false, - fill_with: None, - delete_null_rows: None, - }, - "ModifyColumnNullable: users.email -> NOT NULL" - )] - #[case::modify_column_nullable_to_null( - MigrationAction::ModifyColumnNullable { - table: "users".into(), - column: "email".into(), - nullable: true, - fill_with: None, - delete_null_rows: None, - }, - "ModifyColumnNullable: users.email -> NULL" - )] - fn test_display_modify_column_nullable( - #[case] action: MigrationAction, - #[case] expected: &str, - ) { - assert_eq!(action.to_string(), expected); - } - - #[rstest] - #[case::modify_column_default_set( - MigrationAction::ModifyColumnDefault { - table: "users".into(), - column: "status".into(), - new_default: Some("'active'".into()), - }, - "ModifyColumnDefault: users.status -> 'active'" - )] - #[case::modify_column_default_drop( - MigrationAction::ModifyColumnDefault { - table: "users".into(), - column: "status".into(), - new_default: None, - }, - "ModifyColumnDefault: users.status -> (none)" - )] - fn test_display_modify_column_default(#[case] action: MigrationAction, #[case] expected: &str) { - assert_eq!(action.to_string(), expected); - } - - #[rstest] - #[case::modify_column_comment_set( - MigrationAction::ModifyColumnComment { - table: "users".into(), - column: "email".into(), - new_comment: Some("User email address".into()), - }, - "ModifyColumnComment: users.email -> 'User email address'" - )] - #[case::modify_column_comment_drop( - MigrationAction::ModifyColumnComment { - table: "users".into(), - column: "email".into(), - new_comment: None, - }, - "ModifyColumnComment: users.email -> (none)" - )] - fn test_display_modify_column_comment(#[case] action: MigrationAction, #[case] expected: &str) { - assert_eq!(action.to_string(), expected); - } - - #[test] - fn test_display_modify_column_comment_long() { - // Test truncation for long comments (> 30 chars) - let action = MigrationAction::ModifyColumnComment { - table: "users".into(), - column: "email".into(), - new_comment: Some( - "This is a very long comment that should be truncated in display".into(), - ), - }; - let result = action.to_string(); - assert!(result.contains("...")); - assert!(result.contains("This is a very long comment")); - // Should be truncated at 27 chars + "..." - assert!(!result.contains("truncated in display")); - } - - // Tests for with_prefix - #[test] - fn test_action_with_prefix_create_table() { - let action = MigrationAction::CreateTable { - table: "users".into(), - columns: vec![default_column()], - constraints: vec![TableConstraint::ForeignKey { - name: Some("fk_org".into()), - columns: vec!["org_id".into()], - ref_table: "organizations".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }], - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::CreateTable { - table, constraints, .. - } = prefixed - { - assert_eq!(table.as_str(), "myapp_users"); - if let TableConstraint::ForeignKey { ref_table, .. } = &constraints[0] { - assert_eq!(ref_table.as_str(), "myapp_organizations"); - } - } else { - panic!("Expected CreateTable"); - } - } - - #[test] - fn test_action_with_prefix_delete_table() { - let action = MigrationAction::DeleteTable { - table: "users".into(), - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::DeleteTable { table } = prefixed { - assert_eq!(table.as_str(), "myapp_users"); - } else { - panic!("Expected DeleteTable"); - } - } - - #[test] - fn test_action_with_prefix_add_column() { - let action = MigrationAction::AddColumn { - table: "users".into(), - column: Box::new(default_column()), - fill_with: None, - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::AddColumn { table, .. } = prefixed { - assert_eq!(table.as_str(), "myapp_users"); - } else { - panic!("Expected AddColumn"); - } - } - - #[test] - fn test_action_with_prefix_rename_table() { - let action = MigrationAction::RenameTable { - from: "old_table".into(), - to: "new_table".into(), - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::RenameTable { from, to } = prefixed { - assert_eq!(from.as_str(), "myapp_old_table"); - assert_eq!(to.as_str(), "myapp_new_table"); - } else { - panic!("Expected RenameTable"); - } - } - - #[test] - fn test_action_with_prefix_raw_sql_unchanged() { - let action = MigrationAction::RawSql { - sql: "SELECT * FROM users".into(), - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::RawSql { sql } = prefixed { - // RawSql is not modified - user is responsible for table names - assert_eq!(sql, "SELECT * FROM users"); - } else { - panic!("Expected RawSql"); - } - } - - #[test] - fn test_action_with_prefix_empty_prefix() { - let action = MigrationAction::CreateTable { - table: "users".into(), - columns: vec![], - constraints: vec![], - }; - let prefixed = action.clone().with_prefix(""); - if let MigrationAction::CreateTable { table, .. } = prefixed { - assert_eq!(table.as_str(), "users"); - } - } - - #[test] - fn test_migration_plan_with_prefix() { - let plan = MigrationPlan { - id: String::new(), - comment: Some("test".into()), - created_at: None, - version: 1, - actions: vec![ - MigrationAction::CreateTable { - table: "users".into(), - columns: vec![], - constraints: vec![], - }, - MigrationAction::CreateTable { - table: "posts".into(), - columns: vec![], - constraints: vec![TableConstraint::ForeignKey { - name: Some("fk_user".into()), - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }], - }, - ], - }; - let prefixed = plan.with_prefix("myapp_"); - assert_eq!(prefixed.actions.len(), 2); - - if let MigrationAction::CreateTable { table, .. } = &prefixed.actions[0] { - assert_eq!(table.as_str(), "myapp_users"); - } - if let MigrationAction::CreateTable { - table, constraints, .. - } = &prefixed.actions[1] - { - assert_eq!(table.as_str(), "myapp_posts"); - if let TableConstraint::ForeignKey { ref_table, .. } = &constraints[0] { - assert_eq!(ref_table.as_str(), "myapp_users"); - } - } - } - - #[test] - fn test_action_with_prefix_rename_column() { - let action = MigrationAction::RenameColumn { - table: "users".into(), - from: "name".into(), - to: "full_name".into(), - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::RenameColumn { table, from, to } = prefixed { - assert_eq!(table.as_str(), "myapp_users"); - assert_eq!(from.as_str(), "name"); - assert_eq!(to.as_str(), "full_name"); - } else { - panic!("Expected RenameColumn"); - } - } - - #[test] - fn test_action_with_prefix_delete_column() { - let action = MigrationAction::DeleteColumn { - table: "users".into(), - column: "old_field".into(), - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::DeleteColumn { table, column } = prefixed { - assert_eq!(table.as_str(), "myapp_users"); - assert_eq!(column.as_str(), "old_field"); - } else { - panic!("Expected DeleteColumn"); - } - } - - #[test] - fn test_action_with_prefix_modify_column_type() { - let action = MigrationAction::ModifyColumnType { - table: "users".into(), - column: "age".into(), - new_type: ColumnType::Simple(SimpleColumnType::BigInt), - fill_with: None, - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::ModifyColumnType { - table, - column, - new_type, - fill_with, - } = prefixed - { - assert_eq!(table.as_str(), "myapp_users"); - assert_eq!(column.as_str(), "age"); - assert!(matches!( - new_type, - ColumnType::Simple(SimpleColumnType::BigInt) - )); - assert_eq!(fill_with, None); - } else { - panic!("Expected ModifyColumnType"); - } - } - - #[test] - fn test_action_with_prefix_modify_column_nullable() { - let action = MigrationAction::ModifyColumnNullable { - table: "users".into(), - column: "email".into(), - nullable: false, - fill_with: Some("default@example.com".into()), - delete_null_rows: None, - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::ModifyColumnNullable { - table, - column, - nullable, - fill_with, - delete_null_rows, - } = prefixed - { - assert_eq!(table.as_str(), "myapp_users"); - assert_eq!(column.as_str(), "email"); - assert!(!nullable); - assert_eq!(fill_with, Some("default@example.com".into())); - assert_eq!(delete_null_rows, None); - } else { - panic!("Expected ModifyColumnNullable"); - } - } - - #[test] - fn test_action_with_prefix_modify_column_default() { - let action = MigrationAction::ModifyColumnDefault { - table: "users".into(), - column: "status".into(), - new_default: Some("active".into()), - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::ModifyColumnDefault { - table, - column, - new_default, - } = prefixed - { - assert_eq!(table.as_str(), "myapp_users"); - assert_eq!(column.as_str(), "status"); - assert_eq!(new_default, Some("active".into())); - } else { - panic!("Expected ModifyColumnDefault"); - } - } - - #[test] - fn test_action_with_prefix_modify_column_comment() { - let action = MigrationAction::ModifyColumnComment { - table: "users".into(), - column: "bio".into(), - new_comment: Some("User biography".into()), - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::ModifyColumnComment { - table, - column, - new_comment, - } = prefixed - { - assert_eq!(table.as_str(), "myapp_users"); - assert_eq!(column.as_str(), "bio"); - assert_eq!(new_comment, Some("User biography".into())); - } else { - panic!("Expected ModifyColumnComment"); - } - } - - #[test] - fn test_action_with_prefix_add_constraint() { - let action = MigrationAction::AddConstraint { - table: "posts".into(), - constraint: TableConstraint::ForeignKey { - name: Some("fk_user".into()), - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::AddConstraint { table, constraint } = prefixed { - assert_eq!(table.as_str(), "myapp_posts"); - if let TableConstraint::ForeignKey { ref_table, .. } = constraint { - assert_eq!(ref_table.as_str(), "myapp_users"); - } else { - panic!("Expected ForeignKey constraint"); - } - } else { - panic!("Expected AddConstraint"); - } - } - - #[test] - fn test_action_with_prefix_remove_constraint() { - let action = MigrationAction::RemoveConstraint { - table: "posts".into(), - constraint: TableConstraint::ForeignKey { - name: Some("fk_user".into()), - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::RemoveConstraint { table, constraint } = prefixed { - assert_eq!(table.as_str(), "myapp_posts"); - if let TableConstraint::ForeignKey { ref_table, .. } = constraint { - assert_eq!(ref_table.as_str(), "myapp_users"); - } else { - panic!("Expected ForeignKey constraint"); - } - } else { - panic!("Expected RemoveConstraint"); - } - } - - #[rstest] - #[case::replace_constraint_primary_key( - MigrationAction::ReplaceConstraint { - table: "users".into(), - from: TableConstraint::PrimaryKey { - auto_increment: false, - columns: vec!["id".into()], - }, - to: TableConstraint::PrimaryKey { - auto_increment: true, - columns: vec!["id".into()], - }, - }, - "ReplaceConstraint: users.PRIMARY KEY" - )] - #[case::replace_constraint_unique_with_name( - MigrationAction::ReplaceConstraint { - table: "users".into(), - from: TableConstraint::Unique { - name: None, - columns: vec!["email".into()], - }, - to: TableConstraint::Unique { - name: Some("uq_email".into()), - columns: vec!["email".into()], - }, - }, - "ReplaceConstraint: users.uq_email (UNIQUE)" - )] - #[case::replace_constraint_unique_without_name( - MigrationAction::ReplaceConstraint { - table: "users".into(), - from: TableConstraint::Unique { - name: Some("uq_email".into()), - columns: vec!["email".into()], - }, - to: TableConstraint::Unique { - name: None, - columns: vec!["email".into()], - }, - }, - "ReplaceConstraint: users.UNIQUE" - )] - #[case::replace_constraint_foreign_key_with_name( - MigrationAction::ReplaceConstraint { - table: "posts".into(), - from: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - to: TableConstraint::ForeignKey { - name: Some("fk_user".into()), - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }, - "ReplaceConstraint: posts.fk_user (FOREIGN KEY)" - )] - #[case::replace_constraint_foreign_key_without_name( - MigrationAction::ReplaceConstraint { - table: "posts".into(), - from: TableConstraint::ForeignKey { - name: Some("fk_user".into()), - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - to: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }, - "ReplaceConstraint: posts.FOREIGN KEY" - )] - #[case::replace_constraint_check( - MigrationAction::ReplaceConstraint { - table: "users".into(), - from: TableConstraint::Check { - name: "chk_age".into(), - expr: "age > 0".into(), - }, - to: TableConstraint::Check { - name: "chk_age".into(), - expr: "age >= 0".into(), - }, - }, - "ReplaceConstraint: users.chk_age (CHECK)" - )] - #[case::replace_constraint_index_with_name( - MigrationAction::ReplaceConstraint { - table: "users".into(), - from: TableConstraint::Index { - name: None, - columns: vec!["email".into()], - }, - to: TableConstraint::Index { - name: Some("ix_users__email".into()), - columns: vec!["email".into()], - }, - }, - "ReplaceConstraint: users.ix_users__email (INDEX)" - )] - #[case::replace_constraint_index_without_name( - MigrationAction::ReplaceConstraint { - table: "users".into(), - from: TableConstraint::Index { - name: Some("ix_users__email".into()), - columns: vec!["email".into()], - }, - to: TableConstraint::Index { - name: None, - columns: vec!["email".into()], - }, - }, - "ReplaceConstraint: users.INDEX" - )] - fn test_display_replace_constraint(#[case] action: MigrationAction, #[case] expected: &str) { - assert_eq!(action.to_string(), expected); - } - - #[test] - fn test_action_with_prefix_replace_constraint() { - let action = MigrationAction::ReplaceConstraint { - table: "posts".into(), - from: TableConstraint::ForeignKey { - name: Some("fk_user".into()), - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: Some(ReferenceAction::Cascade), - on_update: None, - }, - to: TableConstraint::ForeignKey { - name: Some("fk_user".into()), - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: Some(ReferenceAction::SetNull), - on_update: None, - }, - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::ReplaceConstraint { table, from, to } = prefixed { - assert_eq!(table.as_str(), "myapp_posts"); - if let TableConstraint::ForeignKey { ref_table, .. } = from { - assert_eq!(ref_table.as_str(), "myapp_users"); - } else { - panic!("Expected ForeignKey constraint in from"); - } - if let TableConstraint::ForeignKey { ref_table, .. } = to { - assert_eq!(ref_table.as_str(), "myapp_users"); - } else { - panic!("Expected ForeignKey constraint in to"); - } - } else { - panic!("Expected ReplaceConstraint"); - } - } -} diff --git a/crates/vespertide-core/src/action/display.rs b/crates/vespertide-core/src/action/display.rs new file mode 100644 index 00000000..a0e3fa7b --- /dev/null +++ b/crates/vespertide-core/src/action/display.rs @@ -0,0 +1,148 @@ +use super::MigrationAction; +use crate::schema::TableConstraint; +use std::fmt; + +impl fmt::Display for MigrationAction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write_migration_action(f, self) + } +} + +fn write_migration_action(f: &mut fmt::Formatter<'_>, action: &MigrationAction) -> fmt::Result { + match action { + MigrationAction::CreateTable { table, .. } => write!(f, "CreateTable: {table}"), + MigrationAction::DeleteTable { table } => write!(f, "DeleteTable: {table}"), + MigrationAction::AddColumn { table, column, .. } => { + write!(f, "AddColumn: {}.{}", table, column.name) + } + MigrationAction::RenameColumn { table, from, to } => { + write!(f, "RenameColumn: {table}.{from} -> {to}") + } + MigrationAction::DeleteColumn { table, column } => { + write!(f, "DeleteColumn: {table}.{column}") + } + MigrationAction::ModifyColumnType { table, column, .. } => { + write!(f, "ModifyColumnType: {table}.{column}") + } + MigrationAction::ModifyColumnNullable { + table, + column, + nullable, + .. + } => write_nullable_action(f, table, column, *nullable), + MigrationAction::ModifyColumnDefault { + table, + column, + new_default, + } => write_default_action(f, table, column, new_default.as_deref()), + MigrationAction::ModifyColumnComment { + table, + column, + new_comment, + } => write_comment_action(f, table, column, new_comment.as_deref()), + MigrationAction::AddConstraint { table, constraint } => { + write_constraint_action(f, "AddConstraint", table, constraint) + } + MigrationAction::RemoveConstraint { table, constraint } => { + write_constraint_action(f, "RemoveConstraint", table, constraint) + } + MigrationAction::ReplaceConstraint { table, to, .. } => { + write_constraint_action(f, "ReplaceConstraint", table, to) + } + MigrationAction::RenameTable { from, to } => write!(f, "RenameTable: {from} -> {to}"), + MigrationAction::RawSql { sql } => write_raw_sql_action(f, sql), + } +} + +fn write_nullable_action( + f: &mut fmt::Formatter<'_>, + table: &str, + column: &str, + nullable: bool, +) -> fmt::Result { + let nullability = if nullable { "NULL" } else { "NOT NULL" }; + write!(f, "ModifyColumnNullable: {table}.{column} -> {nullability}") +} + +fn write_default_action( + f: &mut fmt::Formatter<'_>, + table: &str, + column: &str, + default: Option<&str>, +) -> fmt::Result { + if let Some(default) = default { + write!(f, "ModifyColumnDefault: {table}.{column} -> {default}") + } else { + write!(f, "ModifyColumnDefault: {table}.{column} -> (none)") + } +} + +fn write_comment_action( + f: &mut fmt::Formatter<'_>, + table: &str, + column: &str, + comment: Option<&str>, +) -> fmt::Result { + if let Some(comment) = comment { + let display = truncate_comment(comment); + write!(f, "ModifyColumnComment: {table}.{column} -> '{display}'") + } else { + write!(f, "ModifyColumnComment: {table}.{column} -> (none)") + } +} + +fn truncate_comment(comment: &str) -> String { + if comment.chars().count() > 30 { + format!("{}...", truncate_chars(comment, 27)) + } else { + comment.to_string() + } +} + +fn truncate_chars(s: &str, max_chars: usize) -> String { + s.chars().take(max_chars).collect() +} + +fn write_raw_sql_action(f: &mut fmt::Formatter<'_>, sql: &str) -> fmt::Result { + let display_sql = if sql.chars().count() > 50 { + format!("{}...", truncate_chars(sql, 47)) + } else { + sql.to_string() + }; + write!(f, "RawSql: {display_sql}") +} + +fn write_constraint_action( + f: &mut fmt::Formatter<'_>, + action: &str, + table: &str, + constraint: &TableConstraint, +) -> fmt::Result { + match constraint { + TableConstraint::PrimaryKey { .. } => write!(f, "{action}: {table}.PRIMARY KEY"), + TableConstraint::Unique { name, .. } => { + write_named_constraint(f, action, table, name.as_ref(), "UNIQUE") + } + TableConstraint::ForeignKey { name, .. } => { + write_named_constraint(f, action, table, name.as_ref(), "FOREIGN KEY") + } + TableConstraint::Check { name, .. } => write!(f, "{action}: {table}.{name} (CHECK)"), + TableConstraint::Index { name, .. } => { + write_named_constraint(f, action, table, name.as_ref(), "INDEX") + } + } +} + +fn write_named_constraint( + f: &mut fmt::Formatter<'_>, + action: &str, + table: &str, + name: Option<&String>, + fallback: &str, +) -> fmt::Result { + if let Some(name) = name { + write!(f, "{action}: {table}.{name} ({fallback})") + } else { + write!(f, "{action}: {table}.{fallback}") + } +} diff --git a/crates/vespertide-core/src/action/mod.rs b/crates/vespertide-core/src/action/mod.rs new file mode 100644 index 00000000..bf76a8f5 --- /dev/null +++ b/crates/vespertide-core/src/action/mod.rs @@ -0,0 +1,165 @@ +mod display; +mod prefix; + +#[cfg(test)] +mod tests; + +use crate::schema::{ColumnDef, ColumnName, ColumnType, TableConstraint, TableName}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// A single versioned migration, grouping a set of [`MigrationAction`]s under a version number. +/// +/// Migration plans are auto-generated by `vespertide revision` and stored as JSON or YAML files +/// in the `migrations/` directory. **Never create or edit these files manually.** +/// +/// The `version` field is a monotonically increasing integer. The `id` is a UUID that guards +/// against accidental plan substitution when the same version number appears in two different +/// migration histories. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub struct MigrationPlan { + /// Unique identifier for this migration (UUID format). + /// Defaults to empty string for backward compatibility with old migration files. + #[serde(default)] + pub id: String, + /// Human-readable description of what this migration does (from `-m "message"`). + pub comment: Option, + /// ISO 8601 timestamp of when the migration file was generated. + #[serde(default)] + pub created_at: Option, + /// Monotonically increasing version number, starting at 1. + pub version: u32, + /// Ordered list of schema changes to apply in this migration. + pub actions: Vec, +} + +/// A single schema change produced by the planner and consumed by the SQL generator. +/// +/// The planner emits a `Vec` when diffing two schemas. The SQL generator +/// (`vespertide-query`) translates each action into backend-specific DDL statements. +/// +/// Prefer typed actions over [`MigrationAction::RawSql`]. Raw SQL is an emergency escape hatch: +/// it is not portable across backends and is skipped during baseline replay, which means the +/// planner cannot reason about it. +/// +/// This enum is `#[non_exhaustive]`: new variants may be added in future releases. +/// Downstream `match` expressions should include a wildcard arm. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(tag = "type", rename_all = "snake_case")] +#[non_exhaustive] +pub enum MigrationAction { + /// Create a new table with the given columns and constraints (`CREATE TABLE`). + CreateTable { + table: TableName, + columns: Vec, + constraints: Vec, + }, + /// Drop an existing table and all its data (`DROP TABLE`). + DeleteTable { table: TableName }, + /// Add a new column to an existing table (`ALTER TABLE ... ADD COLUMN`). + AddColumn { + table: TableName, + column: Box, + /// Optional fill value to backfill existing rows when adding NOT NULL without default. + fill_with: Option, + }, + /// Rename a column in an existing table (`ALTER TABLE ... RENAME COLUMN`). + RenameColumn { + table: TableName, + from: ColumnName, + to: ColumnName, + }, + /// Remove a column from an existing table (`ALTER TABLE ... DROP COLUMN`). + DeleteColumn { + table: TableName, + column: ColumnName, + }, + /// Change the SQL type of an existing column (`ALTER TABLE ... ALTER COLUMN ... TYPE`). + ModifyColumnType { + table: TableName, + 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. + #[serde(default, skip_serializing_if = "Option::is_none")] + fill_with: Option>, + }, + /// Change whether a column accepts `NULL` values. + ModifyColumnNullable { + table: TableName, + column: ColumnName, + nullable: bool, + /// Required when changing from nullable to non-nullable to backfill existing NULL values. + fill_with: Option, + /// When true, rows with NULL values in the column are deleted instead of backfilled. + /// Mutually exclusive with `fill_with`. Useful for FK columns where a valid fill value + /// may not exist. + #[serde(default, skip_serializing_if = "Option::is_none")] + delete_null_rows: Option, + }, + /// Change or remove the default value of a column. + ModifyColumnDefault { + table: TableName, + column: ColumnName, + /// The new default value, or `None` to remove the default. + new_default: Option, + }, + /// Change or remove the comment on a column. + ModifyColumnComment { + table: TableName, + column: ColumnName, + /// The new comment, or `None` to remove the comment. + new_comment: Option, + }, + /// Add a constraint (primary key, unique, foreign key, check, or index) to a table. + AddConstraint { + table: TableName, + constraint: TableConstraint, + }, + /// Remove a constraint from a table. + RemoveConstraint { + table: TableName, + constraint: TableConstraint, + }, + /// Atomically replace one constraint with another (e.g. when columns in a composite key change). + ReplaceConstraint { + table: TableName, + from: TableConstraint, + to: TableConstraint, + }, + /// Rename a table (`ALTER TABLE ... RENAME TO`). + RenameTable { from: TableName, to: TableName }, + /// Execute a raw SQL statement verbatim. + /// + /// **Emergency escape hatch only.** Raw SQL is not portable across backends and is invisible + /// to baseline replay, so the planner cannot reason about schema state after this action. + /// Use typed actions whenever possible. + RawSql { sql: String }, +} + +impl MigrationAction { + /// Returns the primary table this action affects, if any. + /// Returns None for actions that don't bind to a single table (e.g. `RawSql`). + #[must_use] + pub fn table_name(&self) -> Option<&str> { + match self { + Self::CreateTable { table, .. } + | Self::DeleteTable { table } + | Self::AddColumn { table, .. } + | Self::DeleteColumn { table, .. } + | Self::RenameColumn { table, .. } + | Self::ModifyColumnType { table, .. } + | Self::ModifyColumnNullable { table, .. } + | Self::ModifyColumnDefault { table, .. } + | Self::ModifyColumnComment { table, .. } + | Self::AddConstraint { table, .. } + | Self::RemoveConstraint { table, .. } + | Self::ReplaceConstraint { table, .. } => Some(table.as_str()), + Self::RenameTable { from, .. } => Some(from.as_str()), + Self::RawSql { .. } => None, + } + } +} diff --git a/crates/vespertide-core/src/action/prefix.rs b/crates/vespertide-core/src/action/prefix.rs new file mode 100644 index 00000000..b2bb91f8 --- /dev/null +++ b/crates/vespertide-core/src/action/prefix.rs @@ -0,0 +1,150 @@ +use super::{MigrationAction, MigrationPlan}; + +impl MigrationPlan { + /// Apply a prefix to all table names in the migration plan. + /// This modifies all table references in all actions. + pub fn with_prefix(self, prefix: &str) -> Self { + if prefix.is_empty() { + return self; + } + Self { + actions: self + .actions + .into_iter() + .map(|action| action.with_prefix(prefix)) + .collect(), + ..self + } + } +} + +impl MigrationAction { + /// Apply a prefix to all table names in this action. + pub fn with_prefix(self, prefix: &str) -> Self { + if prefix.is_empty() { + return self; + } + + prefix_migration_action(self, prefix) + } +} + +fn prefix_migration_action(action: MigrationAction, prefix: &str) -> MigrationAction { + match action { + MigrationAction::CreateTable { + table, + columns, + constraints, + } => MigrationAction::CreateTable { + table: add_prefix(table, prefix), + columns, + constraints: constraints + .into_iter() + .map(|c| c.with_prefix(prefix)) + .collect(), + }, + MigrationAction::DeleteTable { table } => MigrationAction::DeleteTable { + table: add_prefix(table, prefix), + }, + MigrationAction::RenameTable { from, to } => MigrationAction::RenameTable { + from: add_prefix(from, prefix), + to: add_prefix(to, prefix), + }, + MigrationAction::RawSql { sql } => MigrationAction::RawSql { sql }, + action => prefix_column_or_constraint_action(action, prefix), + } +} + +fn prefix_column_or_constraint_action(action: MigrationAction, prefix: &str) -> MigrationAction { + match action { + MigrationAction::AddColumn { + table, + column, + fill_with, + } => MigrationAction::AddColumn { + table: add_prefix(table, prefix), + column, + fill_with, + }, + MigrationAction::RenameColumn { table, from, to } => MigrationAction::RenameColumn { + table: add_prefix(table, prefix), + from, + to, + }, + MigrationAction::DeleteColumn { table, column } => MigrationAction::DeleteColumn { + table: add_prefix(table, prefix), + column, + }, + MigrationAction::ModifyColumnType { + table, + column, + new_type, + fill_with, + } => MigrationAction::ModifyColumnType { + table: add_prefix(table, prefix), + column, + new_type, + fill_with, + }, + MigrationAction::ModifyColumnNullable { + table, + column, + nullable, + fill_with, + delete_null_rows, + } => MigrationAction::ModifyColumnNullable { + table: add_prefix(table, prefix), + column, + nullable, + fill_with, + delete_null_rows, + }, + action => prefix_remaining_action(action, prefix), + } +} + +fn prefix_remaining_action(action: MigrationAction, prefix: &str) -> MigrationAction { + match action { + MigrationAction::ModifyColumnDefault { + table, + column, + new_default, + } => MigrationAction::ModifyColumnDefault { + table: add_prefix(table, prefix), + column, + new_default, + }, + MigrationAction::ModifyColumnComment { + table, + column, + new_comment, + } => MigrationAction::ModifyColumnComment { + table: add_prefix(table, prefix), + column, + new_comment, + }, + MigrationAction::AddConstraint { table, constraint } => MigrationAction::AddConstraint { + table: format!("{prefix}{table}"), + constraint: constraint.with_prefix(prefix), + }, + MigrationAction::RemoveConstraint { table, constraint } => { + MigrationAction::RemoveConstraint { + table: add_prefix(table, prefix), + constraint: constraint.with_prefix(prefix), + } + } + MigrationAction::ReplaceConstraint { table, from, to } => { + MigrationAction::ReplaceConstraint { + table: add_prefix(table, prefix), + from: from.with_prefix(prefix), + to: to.with_prefix(prefix), + } + } + other => other, + } +} + +fn add_prefix(mut table: String, prefix: &str) -> String { + table.insert_str(0, prefix); + table +} diff --git a/crates/vespertide-core/src/action/tests.rs b/crates/vespertide-core/src/action/tests.rs new file mode 100644 index 00000000..30cda79a --- /dev/null +++ b/crates/vespertide-core/src/action/tests.rs @@ -0,0 +1,897 @@ +use super::*; +use crate::schema::{ReferenceAction, SimpleColumnType}; +use rstest::rstest; + +fn default_column() -> ColumnDef { + ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + } +} + +#[rstest] +#[case::create_table( + MigrationAction::CreateTable { + table: "users".into(), + columns: vec![], + constraints: vec![], + }, + "CreateTable: users" + )] +#[case::delete_table( + MigrationAction::DeleteTable { + table: "users".into(), + }, + "DeleteTable: users" + )] +#[case::add_column( + MigrationAction::AddColumn { + table: "users".into(), + column: Box::new(default_column()), + fill_with: None, + }, + "AddColumn: users.email" + )] +#[case::rename_column( + MigrationAction::RenameColumn { + table: "users".into(), + from: "old_name".into(), + to: "new_name".into(), + }, + "RenameColumn: users.old_name -> new_name" + )] +#[case::delete_column( + MigrationAction::DeleteColumn { + table: "users".into(), + column: "email".into(), + }, + "DeleteColumn: users.email" + )] +#[case::modify_column_type( + MigrationAction::ModifyColumnType { + table: "users".into(), + column: "age".into(), + new_type: ColumnType::Simple(SimpleColumnType::Integer), + fill_with: None, + }, + "ModifyColumnType: users.age" + )] +#[case::add_constraint_index_with_name( + MigrationAction::AddConstraint { + table: "users".into(), + constraint: TableConstraint::Index { + name: Some("ix_users__email".into()), + columns: vec!["email".into()], + }, + }, + "AddConstraint: users.ix_users__email (INDEX)" + )] +#[case::add_constraint_index_without_name( + MigrationAction::AddConstraint { + table: "users".into(), + constraint: TableConstraint::Index { + name: None, + columns: vec!["email".into()], + }, + }, + "AddConstraint: users.INDEX" + )] +#[case::remove_constraint_index_with_name( + MigrationAction::RemoveConstraint { + table: "users".into(), + constraint: TableConstraint::Index { + name: Some("ix_users__email".into()), + columns: vec!["email".into()], + }, + }, + "RemoveConstraint: users.ix_users__email (INDEX)" + )] +#[case::remove_constraint_index_without_name( + MigrationAction::RemoveConstraint { + table: "users".into(), + constraint: TableConstraint::Index { + name: None, + columns: vec!["email".into()], + }, + }, + "RemoveConstraint: users.INDEX" + )] +#[case::rename_table( + MigrationAction::RenameTable { + from: "old_table".into(), + to: "new_table".into(), + }, + "RenameTable: old_table -> new_table" + )] +fn test_display_basic_actions(#[case] action: MigrationAction, #[case] expected: &str) { + assert_eq!(action.to_string(), expected); +} + +#[test] +fn test_display_raw_sql_truncates_unicode_without_panicking() { + let sql = "COMMENT ON COLUMN 한국어테이블.이름 IS '日本語 café 📊';".repeat(3); + let action = MigrationAction::RawSql { sql }; + + let display = action.to_string(); + + assert!(display.starts_with("RawSql: COMMENT ON COLUMN 한국어테이블")); + assert!(display.ends_with("...")); +} + +#[rstest] +#[case::create_table( + MigrationAction::CreateTable { + table: "users".into(), + columns: vec![], + constraints: vec![], + }, + Some("users") +)] +#[case::rename_table( + MigrationAction::RenameTable { + from: "old_users".into(), + to: "users".into(), + }, + Some("old_users") +)] +#[case::raw_sql(MigrationAction::RawSql { sql: "SELECT 1".into() }, None)] +fn test_table_name(#[case] action: MigrationAction, #[case] expected: Option<&str>) { + assert_eq!(action.table_name(), expected); +} + +#[rstest] +#[case::add_constraint_primary_key( + MigrationAction::AddConstraint { + table: "users".into(), + constraint: TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["id".into()], + }, + }, + "AddConstraint: users.PRIMARY KEY" + )] +#[case::add_constraint_unique_with_name( + MigrationAction::AddConstraint { + table: "users".into(), + constraint: TableConstraint::Unique { + name: Some("uq_email".into()), + columns: vec!["email".into()], + }, + }, + "AddConstraint: users.uq_email (UNIQUE)" + )] +#[case::add_constraint_unique_without_name( + MigrationAction::AddConstraint { + table: "users".into(), + constraint: TableConstraint::Unique { + name: None, + columns: vec!["email".into()], + }, + }, + "AddConstraint: users.UNIQUE" + )] +#[case::add_constraint_foreign_key_with_name( + MigrationAction::AddConstraint { + table: "posts".into(), + constraint: TableConstraint::ForeignKey { + name: Some("fk_user".into()), + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: None, + }, + }, + "AddConstraint: posts.fk_user (FOREIGN KEY)" + )] +#[case::add_constraint_foreign_key_without_name( + MigrationAction::AddConstraint { + table: "posts".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + "AddConstraint: posts.FOREIGN KEY" + )] +#[case::add_constraint_check( + MigrationAction::AddConstraint { + table: "users".into(), + constraint: TableConstraint::Check { + name: "chk_age".into(), + expr: "age > 0".into(), + }, + }, + "AddConstraint: users.chk_age (CHECK)" + )] +fn test_display_add_constraint(#[case] action: MigrationAction, #[case] expected: &str) { + assert_eq!(action.to_string(), expected); +} + +#[rstest] +#[case::remove_constraint_primary_key( + MigrationAction::RemoveConstraint { + table: "users".into(), + constraint: TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["id".into()], + }, + }, + "RemoveConstraint: users.PRIMARY KEY" + )] +#[case::remove_constraint_unique_with_name( + MigrationAction::RemoveConstraint { + table: "users".into(), + constraint: TableConstraint::Unique { + name: Some("uq_email".into()), + columns: vec!["email".into()], + }, + }, + "RemoveConstraint: users.uq_email (UNIQUE)" + )] +#[case::remove_constraint_unique_without_name( + MigrationAction::RemoveConstraint { + table: "users".into(), + constraint: TableConstraint::Unique { + name: None, + columns: vec!["email".into()], + }, + }, + "RemoveConstraint: users.UNIQUE" + )] +#[case::remove_constraint_foreign_key_with_name( + MigrationAction::RemoveConstraint { + table: "posts".into(), + constraint: TableConstraint::ForeignKey { + name: Some("fk_user".into()), + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + "RemoveConstraint: posts.fk_user (FOREIGN KEY)" + )] +#[case::remove_constraint_foreign_key_without_name( + MigrationAction::RemoveConstraint { + table: "posts".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + "RemoveConstraint: posts.FOREIGN KEY" + )] +#[case::remove_constraint_check( + MigrationAction::RemoveConstraint { + table: "users".into(), + constraint: TableConstraint::Check { + name: "chk_age".into(), + expr: "age > 0".into(), + }, + }, + "RemoveConstraint: users.chk_age (CHECK)" + )] +fn test_display_remove_constraint(#[case] action: MigrationAction, #[case] expected: &str) { + assert_eq!(action.to_string(), expected); +} + +#[rstest] +#[case::raw_sql_short( + MigrationAction::RawSql { + sql: "SELECT 1".into(), + }, + "RawSql: SELECT 1" + )] +fn test_display_raw_sql_short(#[case] action: MigrationAction, #[case] expected: &str) { + assert_eq!(action.to_string(), expected); +} + +#[test] +fn test_display_raw_sql_long() { + let action = MigrationAction::RawSql { + sql: "SELECT * FROM users WHERE id = 1 AND name = 'test' AND email = 'test@example.com'" + .into(), + }; + let result = action.to_string(); + assert!(result.starts_with("RawSql: ")); + assert!(result.ends_with("...")); + assert!(result.len() > 10); +} + +#[rstest] +#[case::modify_column_nullable_to_not_null( + MigrationAction::ModifyColumnNullable { + table: "users".into(), + column: "email".into(), + nullable: false, + fill_with: None, + delete_null_rows: None, + }, + "ModifyColumnNullable: users.email -> NOT NULL" + )] +#[case::modify_column_nullable_to_null( + MigrationAction::ModifyColumnNullable { + table: "users".into(), + column: "email".into(), + nullable: true, + fill_with: None, + delete_null_rows: None, + }, + "ModifyColumnNullable: users.email -> NULL" + )] +fn test_display_modify_column_nullable(#[case] action: MigrationAction, #[case] expected: &str) { + assert_eq!(action.to_string(), expected); +} + +#[rstest] +#[case::modify_column_default_set( + MigrationAction::ModifyColumnDefault { + table: "users".into(), + column: "status".into(), + new_default: Some("'active'".into()), + }, + "ModifyColumnDefault: users.status -> 'active'" + )] +#[case::modify_column_default_drop( + MigrationAction::ModifyColumnDefault { + table: "users".into(), + column: "status".into(), + new_default: None, + }, + "ModifyColumnDefault: users.status -> (none)" + )] +fn test_display_modify_column_default(#[case] action: MigrationAction, #[case] expected: &str) { + assert_eq!(action.to_string(), expected); +} + +#[rstest] +#[case::modify_column_comment_set( + MigrationAction::ModifyColumnComment { + table: "users".into(), + column: "email".into(), + new_comment: Some("User email address".into()), + }, + "ModifyColumnComment: users.email -> 'User email address'" + )] +#[case::modify_column_comment_drop( + MigrationAction::ModifyColumnComment { + table: "users".into(), + column: "email".into(), + new_comment: None, + }, + "ModifyColumnComment: users.email -> (none)" + )] +fn test_display_modify_column_comment(#[case] action: MigrationAction, #[case] expected: &str) { + assert_eq!(action.to_string(), expected); +} + +#[test] +fn test_display_modify_column_comment_long() { + // Test truncation for long comments (> 30 chars) + let action = MigrationAction::ModifyColumnComment { + table: "users".into(), + column: "email".into(), + new_comment: Some("This is a very long comment that should be truncated in display".into()), + }; + let result = action.to_string(); + assert!(result.contains("...")); + assert!(result.contains("This is a very long comment")); + // Should be truncated at 27 chars + "..." + assert!(!result.contains("truncated in display")); +} + +// Tests for with_prefix +#[test] +fn test_action_with_prefix_create_table() { + let action = MigrationAction::CreateTable { + table: "users".into(), + columns: vec![default_column()], + constraints: vec![TableConstraint::ForeignKey { + name: Some("fk_org".into()), + columns: vec!["org_id".into()], + ref_table: "organizations".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }], + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::CreateTable { + table, constraints, .. + } = prefixed + { + assert_eq!(table.as_str(), "myapp_users"); + if let TableConstraint::ForeignKey { ref_table, .. } = &constraints[0] { + assert_eq!(ref_table.as_str(), "myapp_organizations"); + } + } else { + panic!("Expected CreateTable"); + } +} + +#[test] +fn test_action_with_prefix_delete_table() { + let action = MigrationAction::DeleteTable { + table: "users".into(), + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::DeleteTable { table } = prefixed { + assert_eq!(table.as_str(), "myapp_users"); + } else { + panic!("Expected DeleteTable"); + } +} + +#[test] +fn test_action_with_prefix_add_column() { + let action = MigrationAction::AddColumn { + table: "users".into(), + column: Box::new(default_column()), + fill_with: None, + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::AddColumn { table, .. } = prefixed { + assert_eq!(table.as_str(), "myapp_users"); + } else { + panic!("Expected AddColumn"); + } +} + +#[test] +fn test_action_with_prefix_rename_table() { + let action = MigrationAction::RenameTable { + from: "old_table".into(), + to: "new_table".into(), + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::RenameTable { from, to } = prefixed { + assert_eq!(from.as_str(), "myapp_old_table"); + assert_eq!(to.as_str(), "myapp_new_table"); + } else { + panic!("Expected RenameTable"); + } +} + +#[test] +fn test_action_with_prefix_raw_sql_unchanged() { + let action = MigrationAction::RawSql { + sql: "SELECT * FROM users".into(), + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::RawSql { sql } = prefixed { + // RawSql is not modified - user is responsible for table names + assert_eq!(sql, "SELECT * FROM users"); + } else { + panic!("Expected RawSql"); + } +} + +#[test] +fn test_action_with_prefix_empty_prefix() { + let action = MigrationAction::CreateTable { + table: "users".into(), + columns: vec![], + constraints: vec![], + }; + let prefixed = action.clone().with_prefix(""); + if let MigrationAction::CreateTable { table, .. } = prefixed { + assert_eq!(table.as_str(), "users"); + } +} + +#[test] +fn test_migration_plan_with_prefix() { + let plan = MigrationPlan { + id: String::new(), + comment: Some("test".into()), + created_at: None, + version: 1, + actions: vec![ + MigrationAction::CreateTable { + table: "users".into(), + columns: vec![], + constraints: vec![], + }, + MigrationAction::CreateTable { + table: "posts".into(), + columns: vec![], + constraints: vec![TableConstraint::ForeignKey { + name: Some("fk_user".into()), + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }], + }, + ], + }; + let prefixed = plan.with_prefix("myapp_"); + assert_eq!(prefixed.actions.len(), 2); + + if let MigrationAction::CreateTable { table, .. } = &prefixed.actions[0] { + assert_eq!(table.as_str(), "myapp_users"); + } + if let MigrationAction::CreateTable { + table, constraints, .. + } = &prefixed.actions[1] + { + assert_eq!(table.as_str(), "myapp_posts"); + if let TableConstraint::ForeignKey { ref_table, .. } = &constraints[0] { + assert_eq!(ref_table.as_str(), "myapp_users"); + } + } +} + +#[test] +fn test_action_with_prefix_rename_column() { + let action = MigrationAction::RenameColumn { + table: "users".into(), + from: "name".into(), + to: "full_name".into(), + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::RenameColumn { table, from, to } = prefixed { + assert_eq!(table.as_str(), "myapp_users"); + assert_eq!(from.as_str(), "name"); + assert_eq!(to.as_str(), "full_name"); + } else { + panic!("Expected RenameColumn"); + } +} + +#[test] +fn test_action_with_prefix_delete_column() { + let action = MigrationAction::DeleteColumn { + table: "users".into(), + column: "old_field".into(), + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::DeleteColumn { table, column } = prefixed { + assert_eq!(table.as_str(), "myapp_users"); + assert_eq!(column.as_str(), "old_field"); + } else { + panic!("Expected DeleteColumn"); + } +} + +#[test] +fn test_action_with_prefix_modify_column_type() { + let action = MigrationAction::ModifyColumnType { + table: "users".into(), + column: "age".into(), + new_type: ColumnType::Simple(SimpleColumnType::BigInt), + fill_with: None, + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::ModifyColumnType { + table, + column, + new_type, + fill_with, + } = prefixed + { + assert_eq!(table.as_str(), "myapp_users"); + assert_eq!(column.as_str(), "age"); + assert!(matches!( + new_type, + ColumnType::Simple(SimpleColumnType::BigInt) + )); + assert_eq!(fill_with, None); + } else { + panic!("Expected ModifyColumnType"); + } +} + +#[test] +fn test_action_with_prefix_modify_column_nullable() { + let action = MigrationAction::ModifyColumnNullable { + table: "users".into(), + column: "email".into(), + nullable: false, + fill_with: Some("default@example.com".into()), + delete_null_rows: None, + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::ModifyColumnNullable { + table, + column, + nullable, + fill_with, + delete_null_rows, + } = prefixed + { + assert_eq!(table.as_str(), "myapp_users"); + assert_eq!(column.as_str(), "email"); + assert!(!nullable); + assert_eq!(fill_with, Some("default@example.com".into())); + assert_eq!(delete_null_rows, None); + } else { + panic!("Expected ModifyColumnNullable"); + } +} + +#[test] +fn test_action_with_prefix_modify_column_default() { + let action = MigrationAction::ModifyColumnDefault { + table: "users".into(), + column: "status".into(), + new_default: Some("active".into()), + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::ModifyColumnDefault { + table, + column, + new_default, + } = prefixed + { + assert_eq!(table.as_str(), "myapp_users"); + assert_eq!(column.as_str(), "status"); + assert_eq!(new_default, Some("active".into())); + } else { + panic!("Expected ModifyColumnDefault"); + } +} + +#[test] +fn test_action_with_prefix_modify_column_comment() { + let action = MigrationAction::ModifyColumnComment { + table: "users".into(), + column: "bio".into(), + new_comment: Some("User biography".into()), + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::ModifyColumnComment { + table, + column, + new_comment, + } = prefixed + { + assert_eq!(table.as_str(), "myapp_users"); + assert_eq!(column.as_str(), "bio"); + assert_eq!(new_comment, Some("User biography".into())); + } else { + panic!("Expected ModifyColumnComment"); + } +} + +#[test] +fn test_action_with_prefix_add_constraint() { + let action = MigrationAction::AddConstraint { + table: "posts".into(), + constraint: TableConstraint::ForeignKey { + name: Some("fk_user".into()), + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::AddConstraint { table, constraint } = prefixed { + assert_eq!(table.as_str(), "myapp_posts"); + if let TableConstraint::ForeignKey { ref_table, .. } = constraint { + assert_eq!(ref_table.as_str(), "myapp_users"); + } else { + panic!("Expected ForeignKey constraint"); + } + } else { + panic!("Expected AddConstraint"); + } +} + +#[test] +fn test_action_with_prefix_remove_constraint() { + let action = MigrationAction::RemoveConstraint { + table: "posts".into(), + constraint: TableConstraint::ForeignKey { + name: Some("fk_user".into()), + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::RemoveConstraint { table, constraint } = prefixed { + assert_eq!(table.as_str(), "myapp_posts"); + if let TableConstraint::ForeignKey { ref_table, .. } = constraint { + assert_eq!(ref_table.as_str(), "myapp_users"); + } else { + panic!("Expected ForeignKey constraint"); + } + } else { + panic!("Expected RemoveConstraint"); + } +} + +#[rstest] +#[case::replace_constraint_primary_key( + MigrationAction::ReplaceConstraint { + table: "users".into(), + from: TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["id".into()], + }, + to: TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + }, + }, + "ReplaceConstraint: users.PRIMARY KEY" + )] +#[case::replace_constraint_unique_with_name( + MigrationAction::ReplaceConstraint { + table: "users".into(), + from: TableConstraint::Unique { + name: None, + columns: vec!["email".into()], + }, + to: TableConstraint::Unique { + name: Some("uq_email".into()), + columns: vec!["email".into()], + }, + }, + "ReplaceConstraint: users.uq_email (UNIQUE)" + )] +#[case::replace_constraint_unique_without_name( + MigrationAction::ReplaceConstraint { + table: "users".into(), + from: TableConstraint::Unique { + name: Some("uq_email".into()), + columns: vec!["email".into()], + }, + to: TableConstraint::Unique { + name: None, + columns: vec!["email".into()], + }, + }, + "ReplaceConstraint: users.UNIQUE" + )] +#[case::replace_constraint_foreign_key_with_name( + MigrationAction::ReplaceConstraint { + table: "posts".into(), + from: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + to: TableConstraint::ForeignKey { + name: Some("fk_user".into()), + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + "ReplaceConstraint: posts.fk_user (FOREIGN KEY)" + )] +#[case::replace_constraint_foreign_key_without_name( + MigrationAction::ReplaceConstraint { + table: "posts".into(), + from: TableConstraint::ForeignKey { + name: Some("fk_user".into()), + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + to: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + "ReplaceConstraint: posts.FOREIGN KEY" + )] +#[case::replace_constraint_check( + MigrationAction::ReplaceConstraint { + table: "users".into(), + from: TableConstraint::Check { + name: "chk_age".into(), + expr: "age > 0".into(), + }, + to: TableConstraint::Check { + name: "chk_age".into(), + expr: "age >= 0".into(), + }, + }, + "ReplaceConstraint: users.chk_age (CHECK)" + )] +#[case::replace_constraint_index_with_name( + MigrationAction::ReplaceConstraint { + table: "users".into(), + from: TableConstraint::Index { + name: None, + columns: vec!["email".into()], + }, + to: TableConstraint::Index { + name: Some("ix_users__email".into()), + columns: vec!["email".into()], + }, + }, + "ReplaceConstraint: users.ix_users__email (INDEX)" + )] +#[case::replace_constraint_index_without_name( + MigrationAction::ReplaceConstraint { + table: "users".into(), + from: TableConstraint::Index { + name: Some("ix_users__email".into()), + columns: vec!["email".into()], + }, + to: TableConstraint::Index { + name: None, + columns: vec!["email".into()], + }, + }, + "ReplaceConstraint: users.INDEX" + )] +fn test_display_replace_constraint(#[case] action: MigrationAction, #[case] expected: &str) { + assert_eq!(action.to_string(), expected); +} + +#[test] +fn test_action_with_prefix_replace_constraint() { + let action = MigrationAction::ReplaceConstraint { + table: "posts".into(), + from: TableConstraint::ForeignKey { + name: Some("fk_user".into()), + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: None, + }, + to: TableConstraint::ForeignKey { + name: Some("fk_user".into()), + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::SetNull), + on_update: None, + }, + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::ReplaceConstraint { table, from, to } = prefixed { + assert_eq!(table.as_str(), "myapp_posts"); + if let TableConstraint::ForeignKey { ref_table, .. } = from { + assert_eq!(ref_table.as_str(), "myapp_users"); + } else { + panic!("Expected ForeignKey constraint in from"); + } + if let TableConstraint::ForeignKey { ref_table, .. } = to { + assert_eq!(ref_table.as_str(), "myapp_users"); + } else { + panic!("Expected ForeignKey constraint in to"); + } + } else { + panic!("Expected ReplaceConstraint"); + } +} diff --git a/crates/vespertide-core/src/arbitrary/mod.rs b/crates/vespertide-core/src/arbitrary/mod.rs new file mode 100644 index 00000000..70027f82 --- /dev/null +++ b/crates/vespertide-core/src/arbitrary/mod.rs @@ -0,0 +1,430 @@ +use std::collections::BTreeSet; + +use proptest::{collection, prelude::*}; + +use crate::{ + MigrationAction, + schema::{ + ColumnDef, ColumnType, ComplexColumnType, DefaultValue, EnumValues, NumValue, + ReferenceAction, SimpleColumnType, StrOrBoolOrArray, StringOrBool, TableConstraint, + TableDef, + foreign_key::{ForeignKeyDef, ForeignKeySyntax, ReferenceSyntaxDef}, + primary_key::{PrimaryKeyDef, PrimaryKeySyntax}, + }, +}; + +/// Generates `snake_case` SQL-safe identifiers matching `[a-z][a-z0-9_]{0,20}`. +pub fn arb_safe_ident() -> impl Strategy { + ( + prop::char::range('a', 'z'), + collection::vec( + prop_oneof![ + prop::char::range('a', 'z'), + prop::char::range('0', '9'), + Just('_'), + ], + 0..=20, + ), + ) + .prop_map(|(first, rest)| { + let mut ident = String::with_capacity(rest.len() + 1); + ident.push(first); + ident.extend(rest); + ident + }) +} + +pub fn arb_simple_column_type() -> impl Strategy { + prop_oneof![ + Just(SimpleColumnType::SmallInt), + Just(SimpleColumnType::Integer), + Just(SimpleColumnType::BigInt), + Just(SimpleColumnType::Real), + Just(SimpleColumnType::DoublePrecision), + Just(SimpleColumnType::Text), + Just(SimpleColumnType::Boolean), + Just(SimpleColumnType::Date), + Just(SimpleColumnType::Time), + Just(SimpleColumnType::Timestamp), + Just(SimpleColumnType::Timestamptz), + Just(SimpleColumnType::Interval), + Just(SimpleColumnType::Bytea), + Just(SimpleColumnType::Uuid), + Just(SimpleColumnType::Json), + Just(SimpleColumnType::Inet), + Just(SimpleColumnType::Cidr), + Just(SimpleColumnType::Macaddr), + Just(SimpleColumnType::Xml), + ] +} + +pub fn arb_complex_column_type() -> impl Strategy { + prop_oneof![ + (1_u32..=512).prop_map(|length| ComplexColumnType::Varchar { length }), + (1_u32..=64, 0_u32..=16) + .prop_filter("scale must be <= precision", |(precision, scale)| { + scale <= precision + }) + .prop_map(|(precision, scale)| ComplexColumnType::Numeric { precision, scale }), + (1_u32..=64).prop_map(|length| ComplexColumnType::Char { length }), + arb_safe_ident().prop_map(|custom_type| ComplexColumnType::Custom { custom_type }), + (arb_safe_ident(), arb_enum_values()) + .prop_map(|(name, values)| { ComplexColumnType::Enum { name, values } }), + ] +} + +pub fn arb_column_type() -> impl Strategy { + prop_oneof![ + arb_simple_column_type().prop_map(ColumnType::Simple), + arb_complex_column_type().prop_map(ColumnType::Complex), + ] +} + +pub fn arb_reference_action() -> impl Strategy { + prop_oneof![ + Just(ReferenceAction::Cascade), + Just(ReferenceAction::Restrict), + Just(ReferenceAction::SetNull), + Just(ReferenceAction::SetDefault), + Just(ReferenceAction::NoAction), + ] +} + +pub fn arb_default_value() -> impl Strategy { + prop_oneof![ + any::().prop_map(DefaultValue::Bool), + (-10_000_i64..=10_000).prop_map(DefaultValue::Integer), + (-10_000_i32..=10_000).prop_map(|n| DefaultValue::Float(f64::from(n) / 10.0)), + arb_default_string().prop_map(DefaultValue::String), + ] +} + +pub fn arb_str_or_bool() -> impl Strategy { + arb_default_value() +} + +pub fn arb_str_or_bool_or_array() -> impl Strategy { + prop_oneof![ + arb_safe_ident().prop_map(StrOrBoolOrArray::Str), + unique_idents(1..=4).prop_map(StrOrBoolOrArray::Array), + any::().prop_map(StrOrBoolOrArray::Bool), + ] +} + +pub fn arb_column_def() -> impl Strategy { + ( + arb_safe_ident(), + arb_column_type(), + any::(), + prop::option::of(arb_str_or_bool()), + prop::option::of(arb_comment()), + prop::option::of(arb_primary_key_syntax()), + prop::option::of(arb_str_or_bool_or_array()), + prop::option::of(arb_str_or_bool_or_array()), + prop::option::of(arb_foreign_key_syntax()), + ) + .prop_map( + |(name, ty, nullable, default, comment, primary_key, unique, index, foreign_key)| { + let mut column = ColumnDef::new(name, ty, nullable); + if let Some(default) = default { + column = column.default(default); + } + if let Some(comment) = comment { + column = column.comment(comment); + } + if let Some(primary_key) = primary_key { + column = column.primary_key(primary_key); + } + if let Some(unique) = unique { + column = column.unique(unique); + } + if let Some(index) = index { + column = column.index(index); + } + if let Some(foreign_key) = foreign_key { + column = column.foreign_key(foreign_key); + } + column + }, + ) +} + +pub fn arb_table_def() -> impl Strategy { + ( + arb_safe_ident(), + prop::option::of(arb_comment()), + collection::vec(arb_column_def(), 0..=8).prop_filter("unique column names", |columns| { + names_are_unique(columns.iter().map(|column| column.name.as_str())) + }), + collection::vec(arb_table_constraint(), 0..=4), + ) + .prop_map(|(name, description, columns, constraints)| TableDef { + name, + description, + columns, + constraints, + }) +} + +pub fn arb_table_constraint() -> impl Strategy { + prop_oneof![ + (any::(), unique_idents(1..=4)).prop_map(|(auto_increment, columns)| { + TableConstraint::PrimaryKey { + auto_increment, + columns, + } + }), + (prop::option::of(arb_safe_ident()), unique_idents(1..=4)) + .prop_map(|(name, columns)| TableConstraint::Unique { name, columns },), + ( + prop::option::of(arb_safe_ident()), + unique_idents(1..=4), + arb_safe_ident(), + unique_idents(1..=4), + prop::option::of(arb_reference_action()), + prop::option::of(arb_reference_action()), + ) + .prop_map( + |(name, columns, ref_table, ref_columns, on_delete, on_update)| { + TableConstraint::ForeignKey { + name, + columns, + ref_table, + ref_columns, + on_delete, + on_update, + } + }, + ), + (arb_safe_ident(), arb_check_expr()) + .prop_map(|(name, expr)| { TableConstraint::Check { name, expr } }), + (prop::option::of(arb_safe_ident()), unique_idents(1..=4)) + .prop_map(|(name, columns)| TableConstraint::Index { name, columns }), + ] +} + +pub fn arb_migration_action() -> impl Strategy { + prop_oneof![ + arb_create_table_action(), + arb_safe_ident().prop_map(|table| MigrationAction::DeleteTable { table }), + arb_add_column_action(), + (arb_safe_ident(), arb_safe_ident(), arb_safe_ident()) + .prop_map(|(table, from, to)| { MigrationAction::RenameColumn { table, from, to } }), + (arb_safe_ident(), arb_safe_ident()) + .prop_map(|(table, column)| { MigrationAction::DeleteColumn { table, column } }), + arb_modify_column_type_action(), + arb_modify_column_nullable_action(), + ( + arb_safe_ident(), + arb_safe_ident(), + prop::option::of(arb_default_string()) + ) + .prop_map(|(table, column, new_default)| { + MigrationAction::ModifyColumnDefault { + table, + column, + new_default, + } + }), + ( + arb_safe_ident(), + arb_safe_ident(), + prop::option::of(arb_comment()) + ) + .prop_map(|(table, column, new_comment)| { + MigrationAction::ModifyColumnComment { + table, + column, + new_comment, + } + }), + (arb_safe_ident(), arb_table_constraint()).prop_map(|(table, constraint)| { + MigrationAction::AddConstraint { table, constraint } + }), + (arb_safe_ident(), arb_table_constraint()).prop_map(|(table, constraint)| { + MigrationAction::RemoveConstraint { table, constraint } + }), + ( + arb_safe_ident(), + arb_table_constraint(), + arb_table_constraint() + ) + .prop_map(|(table, from, to)| MigrationAction::ReplaceConstraint { + table, + from, + to + },), + (arb_safe_ident(), arb_safe_ident()) + .prop_map(|(from, to)| { MigrationAction::RenameTable { from, to } }), + arb_sql().prop_map(|sql| MigrationAction::RawSql { sql }), + ] +} + +fn arb_create_table_action() -> impl Strategy { + ( + arb_safe_ident(), + collection::vec(arb_column_def(), 0..=8), + collection::vec(arb_table_constraint(), 0..=4), + ) + .prop_map( + |(table, columns, constraints)| MigrationAction::CreateTable { + table, + columns, + constraints, + }, + ) +} + +fn arb_add_column_action() -> impl Strategy { + ( + arb_safe_ident(), + arb_column_def(), + prop::option::of(arb_default_string()), + ) + .prop_map(|(table, column, fill_with)| MigrationAction::AddColumn { + table, + column: Box::new(column), + fill_with, + }) +} + +fn arb_modify_column_type_action() -> impl Strategy { + ( + arb_safe_ident(), + arb_safe_ident(), + arb_column_type(), + prop::option::of(collection::btree_map( + arb_safe_ident(), + arb_default_string(), + 0..=4, + )), + ) + .prop_map( + |(table, column, new_type, fill_with)| MigrationAction::ModifyColumnType { + table, + column, + new_type, + fill_with, + }, + ) +} + +fn arb_modify_column_nullable_action() -> impl Strategy { + ( + arb_safe_ident(), + arb_safe_ident(), + any::(), + prop::option::of(arb_default_string()), + prop::option::of(any::()), + ) + .prop_map(|(table, column, nullable, fill_with, delete_null_rows)| { + MigrationAction::ModifyColumnNullable { + table, + column, + nullable, + fill_with, + delete_null_rows, + } + }) +} + +fn arb_enum_values() -> impl Strategy { + prop_oneof![ + unique_idents(1..=6).prop_map(EnumValues::String), + collection::vec((arb_safe_ident(), -1_000_i32..=1_000), 1..=6) + .prop_filter("unique enum variant names", |values| { + names_are_unique(values.iter().map(|(name, _)| name.as_str())) + }) + .prop_map(|values| { + EnumValues::Integer( + values + .into_iter() + .map(|(name, value)| NumValue { + name, + value: i64::from(value), + }) + .collect(), + ) + }), + ] +} + +fn arb_primary_key_syntax() -> impl Strategy { + prop_oneof![ + any::().prop_map(PrimaryKeySyntax::Bool), + any::() + .prop_map(|auto_increment| PrimaryKeySyntax::Object(PrimaryKeyDef { auto_increment })), + ] +} + +fn arb_foreign_key_syntax() -> impl Strategy { + prop_oneof![ + (arb_safe_ident(), arb_safe_ident()) + .prop_map(|(table, column)| ForeignKeySyntax::String(format!("{table}.{column}"))), + ( + arb_safe_ident(), + arb_safe_ident(), + prop::option::of(arb_reference_action()), + prop::option::of(arb_reference_action()) + ) + .prop_map(|(table, column, on_delete, on_update)| { + ForeignKeySyntax::Reference(ReferenceSyntaxDef { + references: format!("{table}.{column}"), + on_delete, + on_update, + }) + }), + ( + arb_safe_ident(), + unique_idents(1..=4), + prop::option::of(arb_reference_action()), + prop::option::of(arb_reference_action()) + ) + .prop_map(|(ref_table, ref_columns, on_delete, on_update)| { + ForeignKeySyntax::Object(ForeignKeyDef { + ref_table, + ref_columns, + on_delete, + on_update, + }) + }), + ] +} + +fn unique_idents(size: impl Into) -> impl Strategy> { + collection::vec(arb_safe_ident(), size).prop_filter("unique identifiers", |values| { + names_are_unique(values.iter().map(String::as_str)) + }) +} + +fn names_are_unique<'a>(names: impl Iterator) -> bool { + let mut seen = BTreeSet::new(); + names.into_iter().all(|name| seen.insert(name)) +} + +fn arb_default_string() -> impl Strategy { + prop_oneof![ + arb_safe_ident(), + arb_safe_ident().prop_map(|ident| format!("'{ident}'")), + Just("NOW()".to_string()), + Just("CURRENT_TIMESTAMP".to_string()), + ] +} + +fn arb_comment() -> impl Strategy { + collection::vec(prop::char::range('a', 'z'), 0..=80) + .prop_map(|chars| chars.into_iter().collect()) +} + +fn arb_check_expr() -> impl Strategy { + ( + arb_safe_ident(), + prop_oneof![Just(">"), Just(">="), Just("<"), Just("<=")], + 0_i32..=100, + ) + .prop_map(|(column, op, value)| format!("{column} {op} {value}")) +} + +fn arb_sql() -> impl Strategy { + arb_safe_ident().prop_map(|name| format!("SELECT 1 AS {name}")) +} diff --git a/crates/vespertide-core/src/lib.rs b/crates/vespertide-core/src/lib.rs index c433aba2..1fa203a4 100644 --- a/crates/vespertide-core/src/lib.rs +++ b/crates/vespertide-core/src/lib.rs @@ -1,11 +1,19 @@ +//! Core data structures for vespertide schema definition and migration planning. +//! +//! - [`TableDef`], [`ColumnDef`]: schema model +//! - [`MigrationAction`], [`MigrationPlan`]: typed migration operations +//! - [`MigrationError`]: runtime migration error type + pub mod action; +#[cfg(feature = "arbitrary")] +pub mod arbitrary; pub mod migration; pub mod schema; pub use action::{MigrationAction, MigrationPlan}; pub use migration::{MigrationError, MigrationOptions}; pub use schema::{ - ColumnDef, ColumnName, ColumnType, ComplexColumnType, DefaultValue, EnumValues, IndexDef, - IndexName, NumValue, ReferenceAction, SimpleColumnType, StrOrBoolOrArray, StringOrBool, - TableConstraint, TableDef, TableName, TableValidationError, + ColumnDef, ColumnName, ColumnType, ComplexColumnType, ConstraintKind, DefaultValue, EnumValues, + IndexDef, IndexName, NumValue, ReferenceAction, SimpleColumnType, StrOrBoolOrArray, + StringOrBool, TableConstraint, TableDef, TableName, TableValidationError, }; diff --git a/crates/vespertide-core/src/migration.rs b/crates/vespertide-core/src/migration.rs index ad551f29..a2153cbc 100644 --- a/crates/vespertide-core/src/migration.rs +++ b/crates/vespertide-core/src/migration.rs @@ -1,5 +1,13 @@ +/// Runtime options controlling how Vespertide tracks applied migrations. +/// +/// Pass this to the migration runner to configure the version-tracking table name. +/// The default table name used by the `vespertide_migration!` macro is `"vespertide_migrations"`. #[derive(Debug, Clone)] pub struct MigrationOptions { + /// Name of the table used to record which migration versions have been applied. + /// + /// Defaults to `"vespertide_migrations"`. Override this when multiple Vespertide-managed + /// schemas share the same database and need separate version tables. pub version_table: String, } @@ -8,7 +16,17 @@ pub enum MigrationError { #[error("migration execution is not yet implemented")] NotImplemented, #[error("database error: {0}")] + #[deprecated( + since = "0.1.62", + note = "Use Database { message, source } for proper error source chains" + )] DatabaseError(String), + #[error("database error: {message}")] + Database { + message: String, + #[source] + source: Option>, + }, #[error( "migration id mismatch for version {version}: expected '{expected}', found '{found}' in database" )] @@ -18,3 +36,12 @@ pub enum MigrationError { found: String, }, } + +impl From for MigrationError { + fn from(err: sea_orm::DbErr) -> Self { + Self::Database { + message: err.to_string(), + source: Some(Box::new(err)), + } + } +} diff --git a/crates/vespertide-core/src/schema/column.rs b/crates/vespertide-core/src/schema/column.rs index ee838724..1d3a62e8 100644 --- a/crates/vespertide-core/src/schema/column.rs +++ b/crates/vespertide-core/src/schema/column.rs @@ -7,6 +7,18 @@ use crate::schema::{ str_or_bool::{StrOrBoolOrArray, StringOrBool}, }; +/// Definition of a single table column, including its type, nullability, and inline constraints. +/// +/// Inline constraints (`primary_key`, `unique`, `index`, `foreign_key`) are the preferred way to +/// declare constraints in model JSON files. Call [`TableDef::normalize`] to convert them into +/// table-level [`TableConstraint`] entries before diffing or SQL generation. +/// +/// Use [`ColumnDef::new`] to construct a column programmatically, then chain the setter methods +/// (`.primary_key()`, `.unique()`, `.index()`, `.foreign_key()`, `.default()`, `.comment()`) to +/// attach optional fields. +/// +/// [`TableDef::normalize`]: crate::schema::TableDef::normalize +/// [`TableConstraint`]: crate::schema::TableConstraint #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] @@ -28,16 +40,30 @@ pub struct ColumnDef { pub foreign_key: Option, } +/// The SQL type of a column, either a parameter-free simple type or a parameterised complex type. +/// +/// In JSON model files a simple type is written as a plain string (`"integer"`, `"text"`, etc.) +/// while a complex type is written as an object with a `"kind"` discriminant +/// (`{"kind": "varchar", "length": 255}`). +/// +/// Always construct via the wrapped variants: +/// ``` +/// use vespertide_core::{ColumnType, SimpleColumnType, ComplexColumnType}; +/// let t1 = ColumnType::Simple(SimpleColumnType::Integer); +/// let t2 = ColumnType::Complex(ComplexColumnType::Varchar { length: 255 }); +/// ``` #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case", untagged)] pub enum ColumnType { + /// A parameter-free SQL type such as `INTEGER`, `TEXT`, or `UUID`. Simple(SimpleColumnType), + /// A parameterised SQL type such as `VARCHAR(n)`, `NUMERIC(p,s)`, or a named enum. Complex(ComplexColumnType), } impl ColumnType { - /// Returns true if this type supports auto_increment (integer types only) + /// Returns true if this type supports `auto_increment` (integer types only) pub fn supports_auto_increment(&self) -> bool { match self { ColumnType::Simple(ty) => ty.supports_auto_increment(), @@ -47,7 +73,7 @@ impl ColumnType { /// Check if two column types require a migration. /// For integer enums, no migration is ever needed because the underlying DB type is always INTEGER. - /// The enum name and values only affect code generation (SeaORM entities), not the database schema. + /// The enum name and values only affect code generation (`SeaORM` entities), not the database schema. pub fn requires_migration(&self, other: &ColumnType) -> bool { match (self, other) { ( @@ -73,7 +99,7 @@ impl ColumnType { } } - /// Convert column type to Rust type string (for SeaORM entity generation) + /// Convert column type to Rust type string (for `SeaORM` entity generation) pub fn to_rust_type(&self, nullable: bool) -> String { let base = match self { ColumnType::Simple(ty) => match ty { @@ -82,32 +108,32 @@ impl ColumnType { SimpleColumnType::BigInt => "i64".to_string(), SimpleColumnType::Real => "f32".to_string(), SimpleColumnType::DoublePrecision => "f64".to_string(), - SimpleColumnType::Text => "String".to_string(), + SimpleColumnType::Text + | SimpleColumnType::Interval + | SimpleColumnType::Inet + | SimpleColumnType::Cidr + | SimpleColumnType::Macaddr + | SimpleColumnType::Xml => "String".to_string(), SimpleColumnType::Boolean => "bool".to_string(), SimpleColumnType::Date => "Date".to_string(), SimpleColumnType::Time => "Time".to_string(), SimpleColumnType::Timestamp => "DateTime".to_string(), SimpleColumnType::Timestamptz => "DateTimeWithTimeZone".to_string(), - SimpleColumnType::Interval => "String".to_string(), SimpleColumnType::Bytea => "Vec".to_string(), SimpleColumnType::Uuid => "Uuid".to_string(), SimpleColumnType::Json => "Json".to_string(), - // SimpleColumnType::Jsonb => "Json".to_string(), - SimpleColumnType::Inet | SimpleColumnType::Cidr => "String".to_string(), - SimpleColumnType::Macaddr => "String".to_string(), - SimpleColumnType::Xml => "String".to_string(), }, ColumnType::Complex(ty) => match ty { - ComplexColumnType::Varchar { .. } => "String".to_string(), ComplexColumnType::Numeric { .. } => "Decimal".to_string(), - ComplexColumnType::Char { .. } => "String".to_string(), - ComplexColumnType::Custom { .. } => "String".to_string(), // Default for custom types - ComplexColumnType::Enum { .. } => "String".to_string(), + ComplexColumnType::Varchar { .. } + | ComplexColumnType::Char { .. } + | ComplexColumnType::Custom { .. } + | ComplexColumnType::Enum { .. } => "String".to_string(), }, }; if nullable { - format!("Option<{}>", base) + format!("Option<{base}>") } else { base } @@ -147,50 +173,170 @@ impl ColumnType { } } +impl ColumnDef { + /// Construct a new column with required fields only. + /// Use the `.primary_key()`, `.unique()`, `.index()`, `.foreign_key()`, + /// `.default()`, `.comment()` setters to add optional fields. + /// + /// # Examples + /// ``` + /// use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + /// let id = ColumnDef::new("id", ColumnType::Simple(SimpleColumnType::Integer), false); + /// ``` + #[must_use] + pub fn new(name: impl Into, r#type: ColumnType, nullable: bool) -> Self { + Self { + name: name.into(), + r#type, + nullable, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + } + } + + /// Mark this column as part of the primary key. + #[must_use] + pub fn primary_key(mut self, pk: PrimaryKeySyntax) -> Self { + self.primary_key = Some(pk); + self + } + + /// Add a unique constraint to this column. + #[must_use] + pub fn unique(mut self, unique: StrOrBoolOrArray) -> Self { + self.unique = Some(unique); + self + } + + /// Add an index on this column. + #[must_use] + pub fn index(mut self, index: StrOrBoolOrArray) -> Self { + self.index = Some(index); + self + } + + /// Add a foreign key reference from this column. + #[must_use] + pub fn foreign_key(mut self, fk: ForeignKeySyntax) -> Self { + self.foreign_key = Some(fk); + self + } + + /// Set the column default value. + #[must_use] + pub fn default(mut self, default: StringOrBool) -> Self { + self.default = Some(default); + self + } + + /// Add a column comment. + #[must_use] + pub fn comment(mut self, comment: impl Into) -> Self { + self.comment = Some(comment.into()); + self + } +} + +/// Parameter-free SQL column types supported across all backends. +/// +/// Each variant maps directly to a standard SQL type. Use these via +/// [`ColumnType::Simple`] when no length, precision, or scale is needed. +/// +/// This enum is `#[non_exhaustive]`: new variants may be added in future releases. +/// Downstream `match` expressions should include a wildcard arm. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] +#[non_exhaustive] pub enum SimpleColumnType { + /// 16-bit signed integer (`SMALLINT`). SmallInt, + /// 32-bit signed integer (`INTEGER`). Supports `auto_increment`. Integer, + /// 64-bit signed integer (`BIGINT`). Supports `auto_increment`. BigInt, + /// 32-bit floating-point number (`REAL`). Real, + /// 64-bit floating-point number (`DOUBLE PRECISION`). DoublePrecision, // Text types + /// Unbounded Unicode text (`TEXT`). Text, // Boolean type + /// Boolean true/false value (`BOOLEAN`). Boolean, // Date/Time types + /// Calendar date without time (`DATE`). Date, + /// Time of day without date (`TIME`). Time, + /// Date and time without timezone (`TIMESTAMP`). Timestamp, + /// Date and time with timezone (`TIMESTAMPTZ`). Prefer this over `Timestamp`. Timestamptz, + /// Time span / duration (`INTERVAL`). Interval, // Binary type + /// Variable-length binary data (`BYTEA`). Bytea, // UUID type + /// Universally unique identifier (`UUID`). Uuid, // JSON types + /// JSON value stored as text (`JSON`). Cross-backend compatible; prefer over `jsonb`. Json, - // Jsonb, // Network types + /// IPv4 or IPv6 host address (`INET`). PostgreSQL-specific. Inet, + /// IPv4 or IPv6 network address (`CIDR`). PostgreSQL-specific. Cidr, + /// MAC address (`MACADDR`). PostgreSQL-specific. Macaddr, // XML type + /// XML document (`XML`). PostgreSQL-specific. Xml, } impl SimpleColumnType { - /// Returns true if this type supports auto_increment (integer types only) + /// Returns the SQL type name for this simple column type. + #[must_use] + pub fn sql_type(&self) -> &'static str { + match self { + SimpleColumnType::SmallInt => "SMALLINT", + SimpleColumnType::Integer => "INTEGER", + SimpleColumnType::BigInt => "BIGINT", + SimpleColumnType::Real => "REAL", + SimpleColumnType::DoublePrecision => "DOUBLE PRECISION", + SimpleColumnType::Text => "TEXT", + SimpleColumnType::Boolean => "BOOLEAN", + SimpleColumnType::Date => "DATE", + SimpleColumnType::Time => "TIME", + SimpleColumnType::Timestamp => "TIMESTAMP", + SimpleColumnType::Timestamptz => "TIMESTAMPTZ", + SimpleColumnType::Interval => "INTERVAL", + SimpleColumnType::Bytea => "BYTEA", + SimpleColumnType::Uuid => "UUID", + SimpleColumnType::Json => "JSON", + SimpleColumnType::Inet => "INET", + SimpleColumnType::Cidr => "CIDR", + SimpleColumnType::Macaddr => "MACADDR", + SimpleColumnType::Xml => "XML", + } + } + + /// Returns true if this type supports `auto_increment` (integer types only) pub fn supports_auto_increment(&self) -> bool { matches!( self, @@ -232,14 +378,13 @@ impl SimpleColumnType { } SimpleColumnType::Real | SimpleColumnType::DoublePrecision => "0.0", SimpleColumnType::Boolean => "false", - SimpleColumnType::Text => "''", + SimpleColumnType::Text | SimpleColumnType::Bytea => "''", SimpleColumnType::Date => "'1970-01-01'", SimpleColumnType::Time => "'00:00:00'", SimpleColumnType::Timestamp | SimpleColumnType::Timestamptz => "CURRENT_TIMESTAMP", SimpleColumnType::Interval => "'0'", SimpleColumnType::Uuid => "'00000000-0000-0000-0000-000000000000'", SimpleColumnType::Json => "'{}'", - SimpleColumnType::Bytea => "''", SimpleColumnType::Inet | SimpleColumnType::Cidr => "'0.0.0.0'", SimpleColumnType::Macaddr => "'00:00:00:00:00:00'", SimpleColumnType::Xml => "''", @@ -247,20 +392,37 @@ impl SimpleColumnType { } } -/// Integer enum variant with name and numeric value +/// A single variant of an integer-backed enum, pairing a Rust-friendly name with its stored value. +/// +/// Used inside [`EnumValues::Integer`] to define enums that are stored as `INTEGER` in the +/// database. Leave gaps between values (e.g. 0, 10, 20) so new variants can be inserted later +/// without renumbering. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct NumValue { + /// The variant name used in generated code (e.g. `"active"`). pub name: String, - pub value: i32, + /// The integer value stored in the database column. + pub value: i64, } -/// Enum values definition - either all string or all integer +/// The set of allowed values for an enum column, either string-based or integer-based. +/// +/// **String enums** map to a native `PostgreSQL` `ENUM` type. Adding or removing values requires a +/// database migration (`ALTER TYPE`). +/// +/// **Integer enums** are stored as `INTEGER`. New variants can be added to the model without any +/// database migration because the underlying column type never changes. +/// +/// Choose integer enums for expandable value sets (roles, priorities) and string enums for +/// stable, human-readable status fields. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(untagged)] pub enum EnumValues { + /// String enum: each variant is a plain string stored in a native DB enum type. String(Vec), + /// Integer enum: each variant has an explicit numeric value stored as `INTEGER`. Integer(Vec), } @@ -278,7 +440,7 @@ impl EnumValues { /// Get all variant names pub fn variant_names(&self) -> Vec<&str> { match self { - EnumValues::String(values) => values.iter().map(|s| s.as_str()).collect(), + EnumValues::String(values) => values.iter().map(std::string::String::as_str).collect(), EnumValues::Integer(values) => values.iter().map(|v| v.name.as_str()).collect(), } } @@ -293,7 +455,10 @@ impl EnumValues { /// Check if there are no variants pub fn is_empty(&self) -> bool { - self.len() == 0 + match self { + EnumValues::String(values) => values.is_empty(), + EnumValues::Integer(values) => values.is_empty(), + } } /// Get SQL values for CREATE TYPE ENUM (only for string enums) @@ -317,36 +482,70 @@ impl From> for EnumValues { impl From> for EnumValues { fn from(values: Vec<&str>) -> Self { - EnumValues::String(values.into_iter().map(|s| s.to_string()).collect()) + EnumValues::String( + values + .into_iter() + .map(std::string::ToString::to_string) + .collect(), + ) } } +/// Parameterised SQL column types that require additional configuration beyond a simple keyword. +/// +/// In JSON model files these are written as objects with a `"kind"` discriminant, for example +/// `{"kind": "varchar", "length": 255}` or `{"kind": "enum", "name": "status", "values": [...]}`. +/// +/// Use these via [`ColumnType::Complex`]. +/// +/// This enum is `#[non_exhaustive]`: new variants may be added in future releases. +/// Downstream `match` expressions should include a wildcard arm. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case", tag = "kind")] +#[non_exhaustive] pub enum ComplexColumnType { + /// Variable-length character string with a maximum byte length (`VARCHAR(n)`). Varchar { length: u32 }, + /// Exact fixed-point number with configurable precision and scale (`NUMERIC(p, s)`). Numeric { precision: u32, scale: u32 }, + /// Fixed-length character string padded with spaces (`CHAR(n)`). Char { length: u32 }, + /// Escape hatch for database-specific types not covered by other variants. + /// Breaks cross-database portability; avoid unless absolutely necessary. Custom { custom_type: String }, + /// Named enum type. String enums map to a native DB enum; integer enums store as `INTEGER`. + /// See [`EnumValues`] for the distinction. Enum { name: String, values: EnumValues }, } impl ComplexColumnType { + /// Returns the base SQL type name for this complex column type, without parameters. + #[must_use] + pub fn sql_type(&self) -> &'static str { + match self { + ComplexColumnType::Varchar { .. } => "VARCHAR", + ComplexColumnType::Numeric { .. } => "NUMERIC", + ComplexColumnType::Char { .. } => "CHAR", + ComplexColumnType::Custom { .. } => "CUSTOM", + ComplexColumnType::Enum { .. } => "ENUM", + } + } + /// Convert to human-readable display string pub fn to_display_string(&self) -> String { match self { - ComplexColumnType::Varchar { length } => format!("varchar({})", length), + ComplexColumnType::Varchar { length } => format!("varchar({length})"), ComplexColumnType::Numeric { precision, scale } => { - format!("numeric({},{})", precision, scale) + format!("numeric({precision},{scale})") } - ComplexColumnType::Char { length } => format!("char({})", length), + ComplexColumnType::Char { length } => format!("char({length})"), ComplexColumnType::Custom { custom_type } => custom_type.to_lowercase(), ComplexColumnType::Enum { name, values } => { if values.is_integer() { - format!("enum<{}> (integer)", name) + format!("enum<{name}> (integer)") } else { - format!("enum<{}>", name) + format!("enum<{name}>") } } } @@ -355,624 +554,15 @@ impl ComplexColumnType { /// Get the default fill value for this type. pub fn default_fill_value(&self) -> &'static str { match self { - ComplexColumnType::Varchar { .. } | ComplexColumnType::Char { .. } => "''", ComplexColumnType::Numeric { .. } => "0", - ComplexColumnType::Custom { .. } => "''", - ComplexColumnType::Enum { .. } => "''", + ComplexColumnType::Varchar { .. } + | ComplexColumnType::Char { .. } + | ComplexColumnType::Custom { .. } + | ComplexColumnType::Enum { .. } => "''", } } } #[cfg(test)] -mod tests { - use super::*; - use rstest::rstest; - - #[rstest] - #[case(SimpleColumnType::SmallInt, "i16")] - #[case(SimpleColumnType::Integer, "i32")] - #[case(SimpleColumnType::BigInt, "i64")] - #[case(SimpleColumnType::Real, "f32")] - #[case(SimpleColumnType::DoublePrecision, "f64")] - #[case(SimpleColumnType::Text, "String")] - #[case(SimpleColumnType::Boolean, "bool")] - #[case(SimpleColumnType::Date, "Date")] - #[case(SimpleColumnType::Time, "Time")] - #[case(SimpleColumnType::Timestamp, "DateTime")] - #[case(SimpleColumnType::Timestamptz, "DateTimeWithTimeZone")] - #[case(SimpleColumnType::Interval, "String")] - #[case(SimpleColumnType::Bytea, "Vec")] - #[case(SimpleColumnType::Uuid, "Uuid")] - #[case(SimpleColumnType::Json, "Json")] - // #[case(SimpleColumnType::Jsonb, "Json")] - #[case(SimpleColumnType::Inet, "String")] - #[case(SimpleColumnType::Cidr, "String")] - #[case(SimpleColumnType::Macaddr, "String")] - #[case(SimpleColumnType::Xml, "String")] - fn test_simple_column_type_to_rust_type_not_nullable( - #[case] column_type: SimpleColumnType, - #[case] expected: &str, - ) { - assert_eq!( - ColumnType::Simple(column_type).to_rust_type(false), - expected - ); - } - - #[rstest] - #[case(SimpleColumnType::SmallInt, "Option")] - #[case(SimpleColumnType::Integer, "Option")] - #[case(SimpleColumnType::BigInt, "Option")] - #[case(SimpleColumnType::Real, "Option")] - #[case(SimpleColumnType::DoublePrecision, "Option")] - #[case(SimpleColumnType::Text, "Option")] - #[case(SimpleColumnType::Boolean, "Option")] - #[case(SimpleColumnType::Date, "Option")] - #[case(SimpleColumnType::Time, "Option