diff --git a/.github/workflows/db-strict-ci.yml b/.github/workflows/db-strict-ci.yml index 0533e5f..69ded2c 100644 --- a/.github/workflows/db-strict-ci.yml +++ b/.github/workflows/db-strict-ci.yml @@ -14,6 +14,7 @@ on: - "include/**" - "src/**" - "examples/**" + - "tests/**" - "tools/**" - "README.md" - "LICENSE" @@ -32,6 +33,7 @@ on: - "include/**" - "src/**" - "examples/**" + - "tests/**" - "tools/**" - "README.md" - "LICENSE" @@ -189,6 +191,7 @@ jobs: -DCMAKE_BUILD_TYPE=Debug \ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ -DVIX_ENABLE_SANITIZERS=OFF \ + -DVIX_DB_BUILD_TESTS=ON \ -DVIX_DB_BUILD_EXAMPLES=${{ matrix.examples }} \ -DVIX_DB_USE_MYSQL=${{ matrix.mysql }} \ -DVIX_DB_REQUIRE_MYSQL=OFF \ @@ -205,6 +208,10 @@ jobs: run: | cmake --build build -j"${BUILD_JOBS}" + - name: Test + run: | + ctest --test-dir build --output-on-failure + - name: Print executables run: | find build -type f -executable | sort || true diff --git a/CMakeLists.txt b/CMakeLists.txt index 90b0237..cc0d537 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -224,6 +224,8 @@ set(VIX_DB_PUBLIC_HEADERS include/vix/db/mig/Migration.hpp include/vix/db/mig/MigrationsRunner.hpp include/vix/db/mig/FileMigrationsRunner.hpp + include/vix/db/mig/sql/MySqlGenerator.hpp + include/vix/db/mig/sql/SQLiteGenerator.hpp ) set(VIX_DB_SOURCES @@ -235,6 +237,7 @@ set(VIX_DB_SOURCES src/schema/Json.cpp src/mig/diff/Diff.cpp src/mig/sql/MySqlGenerator.cpp + src/mig/sql/SQLiteGenerator.cpp ) # MySQL driver sources @@ -426,6 +429,37 @@ if (VIX_DB_BUILD_EXAMPLES) add_subdirectory(examples) endif() +# ------------------------------------------------------------------------------ +# Tests +# ------------------------------------------------------------------------------ +if (VIX_DB_BUILD_TESTS) + enable_testing() + + if (VIX_DB_HAS_SQLITE) + add_executable(vix_db_sql_generator_tests + tests/sql_generator_tests.cpp + tools/migrator/MakeMigrations.cpp + ) + + target_link_libraries(vix_db_sql_generator_tests PRIVATE vix::db) + target_compile_features(vix_db_sql_generator_tests PRIVATE cxx_std_20) + + if (MSVC) + target_compile_options(vix_db_sql_generator_tests PRIVATE ${_WARNINGS_MSVC}) + else() + target_compile_options(vix_db_sql_generator_tests PRIVATE ${_WARNINGS_GNU}) + endif() + + if (COMMAND vix_enable_sanitizers) + vix_enable_sanitizers(vix_db_sql_generator_tests) + endif() + + add_test(NAME vix_db_sql_generator_tests COMMAND vix_db_sql_generator_tests) + else() + message(STATUS "[vix_db] tests requested but SQLite is disabled; SQL execution tests skipped.") + endif() +endif() + # ------------------------------------------------------------------------------ # Install / export via umbrella export-set "VixTargets" # ------------------------------------------------------------------------------ diff --git a/README.md b/README.md index 5b48f7f..21f8fea 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,16 @@ vix build --clean vix build --preset release ``` +## Migration SQL generation + +Generate file-based migrations from a schema snapshot with either MySQL or SQLite SQL: + +```bash +vix_db_migrator makemigrations --new ./schema.new.json --snapshot ./schema.json --dir ./migrations --name create_users --dialect sqlite +``` + +SQLite migration generation emits native DDL for table, column, and index operations. Unsupported SQLite `ADD COLUMN` forms, such as adding primary-key, unique, autoincrement, or required columns without defaults, fail before writing migration files. Down migrations for dropped columns use SQLite 3.35+ `DROP COLUMN` syntax. + ## Tests Build all targets first, then run tests: diff --git a/include/vix/db/mig/sql/SQLiteGenerator.hpp b/include/vix/db/mig/sql/SQLiteGenerator.hpp new file mode 100644 index 0000000..8ee104a --- /dev/null +++ b/include/vix/db/mig/sql/SQLiteGenerator.hpp @@ -0,0 +1,50 @@ +/** + * + * @file SQLiteGenerator.hpp + * @author Gaspard Kirira + * + * Copyright 2025, Gaspard Kirira. + * All rights reserved. + * https://github.com/vixcpp/vix + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Vix.cpp + */ +#ifndef VIX_DB_MIG_SQL_SQLITE_GENERATOR_HPP +#define VIX_DB_MIG_SQL_SQLITE_GENERATOR_HPP + +#include + +#include +#include + +namespace vix::db::mig::sql +{ + /** + * @brief Generate SQLite SQL statements for applying a migration. + * + * Converts portable migration operations into SQLite DDL. Unsupported + * SQLite alterations fail with std::runtime_error before any SQL script + * is returned. + * + * @param ops Ordered list of migration operations. + * @return SQL script for the "up" migration. + */ + std::string to_sqlite_up(const std::vector &ops); + + /** + * @brief Generate SQLite SQL statements for reverting a migration. + * + * Produces the inverse SQL script in reverse operation order. + * SQLite DROP COLUMN is emitted using the native SQLite 3.35+ syntax. + * + * @param ops Ordered list of migration operations. + * @return SQL script for the "down" migration. + */ + std::string to_sqlite_down(const std::vector &ops); + +} // namespace vix::db::mig::sql + +#endif // VIX_DB_MIG_SQL_SQLITE_GENERATOR_HPP diff --git a/src/mig/diff/Diff.cpp b/src/mig/diff/Diff.cpp index 005c566..f97ea94 100644 --- a/src/mig/diff/Diff.cpp +++ b/src/mig/diff/Diff.cpp @@ -24,25 +24,26 @@ namespace vix::db::mig::diff auto A = map_tables(from); auto B = map_tables(to); - // 1) Drop tables missing in 'to' - for (const auto &[name, ta] : A) + // 1) Drop tables missing in 'to', preserving source schema order. + for (const auto &table : from.tables) { - if (!B.count(name)) - ops.push_back(DropTable{*ta}); + if (!B.count(table.name)) + ops.push_back(DropTable{table}); } - // 2) Create tables new in 'to' - for (const auto &[name, tb] : B) + // 2) Create tables new in 'to', preserving target schema order. + for (const auto &targetTable : to.tables) { + const auto &name = targetTable.name; if (!A.count(name)) { - ops.push_back(CreateTable{*tb}); + ops.push_back(CreateTable{targetTable}); continue; } // 3) Same table: diff columns + indexes const auto *oldT = A.at(name); - const auto *newT = tb; + const auto *newT = &targetTable; // Columns: drops for (const auto &c_old : oldT->columns) @@ -52,7 +53,7 @@ namespace vix::db::mig::diff } // Columns: adds - for (const auto &c_new : newT->columns) + for (const auto &c_new : targetTable.columns) { if (!oldT->findColumn(c_new.name)) ops.push_back(AddColumn{name, c_new}); @@ -66,7 +67,7 @@ namespace vix::db::mig::diff } // Indexes: adds - for (const auto &i_new : newT->indexes) + for (const auto &i_new : targetTable.indexes) { if (!oldT->findIndex(i_new.name)) ops.push_back(CreateIndex{name, i_new}); diff --git a/src/mig/sql/SQLiteGenerator.cpp b/src/mig/sql/SQLiteGenerator.cpp new file mode 100644 index 0000000..4e20bb5 --- /dev/null +++ b/src/mig/sql/SQLiteGenerator.cpp @@ -0,0 +1,259 @@ +#include + +#include +#include + +namespace vix::db::mig::sql +{ + using namespace vix::db::schema; + using namespace vix::db::mig::diff; + + static std::string quote_identifier(const std::string &ident) + { + std::string out; + out.reserve(ident.size() + 2); + out.push_back('"'); + for (const char ch : ident) + { + if (ch == '"') + out += "\"\""; + else + out.push_back(ch); + } + out.push_back('"'); + return out; + } + + static std::string type_sqlite(const Type &t) + { + switch (t.base) + { + case BaseType::Int: + case BaseType::BigInt: + case BaseType::Bool: + return "INTEGER"; + case BaseType::Double: + return "REAL"; + case BaseType::VarChar: + case BaseType::Text: + case BaseType::DateTime: + return "TEXT"; + } + return "TEXT"; + } + + static bool is_integer_type(const Type &t) + { + return t.base == BaseType::Int || t.base == BaseType::BigInt; + } + + static std::vector primary_key_columns(const Table &t) + { + std::vector keys; + for (const auto &c : t.columns) + if (c.primary_key) + keys.push_back(c.name); + return keys; + } + + static bool is_inline_integer_primary_key(const Column &c, const Table &t) + { + if (!c.primary_key || !is_integer_type(c.type)) + return false; + return primary_key_columns(t).size() == 1; + } + + static void validate_auto_increment(const Column &c, const Table &t) + { + if (!c.auto_increment) + return; + if (!is_inline_integer_primary_key(c, t)) + { + throw std::runtime_error( + "SQLite AUTOINCREMENT requires a single INTEGER PRIMARY KEY column: " + c.name); + } + } + + static std::string column_sqlite(const Column &c, const Table *table_context) + { + if (table_context != nullptr) + validate_auto_increment(c, *table_context); + else if (c.auto_increment) + throw std::runtime_error("SQLite ADD COLUMN does not support AUTOINCREMENT: " + c.name); + + std::ostringstream o; + o << quote_identifier(c.name) << " " << type_sqlite(c.type); + + if (table_context != nullptr && is_inline_integer_primary_key(c, *table_context)) + { + o << " PRIMARY KEY"; + if (c.auto_increment) + o << " AUTOINCREMENT"; + } + else if (!c.nullable) + { + o << " NOT NULL"; + } + + if (c.def) + o << " DEFAULT " << c.def->sql_literal; + + if (c.unique && !c.primary_key) + o << " UNIQUE"; + + return o.str(); + } + + static void validate_add_column_sqlite(const Column &c) + { + if (c.primary_key) + throw std::runtime_error("SQLite ADD COLUMN cannot add a PRIMARY KEY column: " + c.name); + if (c.unique) + throw std::runtime_error("SQLite ADD COLUMN cannot add a UNIQUE column: " + c.name); + if (c.auto_increment) + throw std::runtime_error("SQLite ADD COLUMN cannot add an AUTOINCREMENT column: " + c.name); + if (!c.nullable && !c.def) + { + throw std::runtime_error( + "SQLite ADD COLUMN cannot add a NOT NULL column without a DEFAULT: " + c.name); + } + } + + static std::string create_table_sqlite(const Table &t) + { + std::ostringstream o; + o << "CREATE TABLE IF NOT EXISTS " << quote_identifier(t.name) << " (\n"; + + const auto keys = primary_key_columns(t); + const bool table_level_pk = keys.size() > 1 || + (keys.size() == 1 && + !is_inline_integer_primary_key(*t.findColumn(keys.front()), t)); + + for (size_t i = 0; i < t.columns.size(); ++i) + { + o << " " << column_sqlite(t.columns[i], &t); + if (i + 1 < t.columns.size() || table_level_pk) + o << ","; + o << "\n"; + } + + if (table_level_pk) + { + o << " PRIMARY KEY ("; + for (size_t i = 0; i < keys.size(); ++i) + { + o << quote_identifier(keys[i]); + if (i + 1 < keys.size()) + o << ", "; + } + o << ")\n"; + } + + o << ");"; + return o.str(); + } + + static std::string drop_table_sqlite(const Table &t) + { + return "DROP TABLE IF EXISTS " + quote_identifier(t.name) + ";"; + } + + static std::string add_column_sqlite(const std::string &table, const Column &c) + { + validate_add_column_sqlite(c); + return "ALTER TABLE " + quote_identifier(table) + " ADD COLUMN " + column_sqlite(c, nullptr) + ";"; + } + + static std::string drop_column_sqlite(const std::string &table, const Column &c) + { + return "ALTER TABLE " + quote_identifier(table) + " DROP COLUMN " + quote_identifier(c.name) + ";"; + } + + static std::string create_index_sqlite(const std::string &table, const Index &i) + { + if (i.columns.empty()) + throw std::runtime_error("SQLite CREATE INDEX requires at least one column: " + i.name); + + std::ostringstream o; + o << "CREATE "; + if (i.unique) + o << "UNIQUE "; + o << "INDEX IF NOT EXISTS " << quote_identifier(i.name) + << " ON " << quote_identifier(table) << " ("; + for (size_t k = 0; k < i.columns.size(); ++k) + { + o << quote_identifier(i.columns[k]); + if (k + 1 < i.columns.size()) + o << ", "; + } + o << ");"; + return o.str(); + } + + static std::string drop_index_sqlite(const Index &i) + { + return "DROP INDEX IF EXISTS " + quote_identifier(i.name) + ";"; + } + + static std::string render_up(const Op &op) + { + return std::visit([](auto &&x) -> std::string + { + using T = std::decay_t; + if constexpr (std::is_same_v) return create_table_sqlite(x.table); + else if constexpr (std::is_same_v) return drop_table_sqlite(x.table); + else if constexpr (std::is_same_v) return add_column_sqlite(x.table, x.column); + else if constexpr (std::is_same_v) return drop_column_sqlite(x.table, x.column); + else if constexpr (std::is_same_v) return create_index_sqlite(x.table, x.index); + else if constexpr (std::is_same_v) return drop_index_sqlite(x.index); }, op); + } + + static std::string render_down(const Op &op) + { + return std::visit([](auto &&x) -> std::string + { + using T = std::decay_t; + if constexpr (std::is_same_v) return drop_table_sqlite(x.table); + else if constexpr (std::is_same_v) return create_table_sqlite(x.table); + else if constexpr (std::is_same_v) return drop_column_sqlite(x.table, x.column); + else if constexpr (std::is_same_v) return add_column_sqlite(x.table, x.column); + else if constexpr (std::is_same_v) return drop_index_sqlite(x.index); + else if constexpr (std::is_same_v) return create_index_sqlite(x.table, x.index); }, op); + } + + static std::string render_script(const char *header, + const std::vector &ops, + bool down) + { + std::vector statements; + statements.reserve(ops.size()); + + if (down) + { + for (auto it = ops.rbegin(); it != ops.rend(); ++it) + statements.push_back(render_down(*it)); + } + else + { + for (const auto &op : ops) + statements.push_back(render_up(op)); + } + + std::ostringstream o; + o << header << "\n"; + for (const auto &statement : statements) + o << statement << "\n"; + return o.str(); + } + + std::string to_sqlite_up(const std::vector &ops) + { + return render_script("-- Generated by Vix ORM (SQLite)", ops, false); + } + + std::string to_sqlite_down(const std::vector &ops) + { + return render_script("-- Generated by Vix ORM (SQLite) [DOWN]", ops, true); + } + +} // namespace vix::db::mig::sql diff --git a/tests/sql_generator_tests.cpp b/tests/sql_generator_tests.cpp new file mode 100644 index 0000000..83cf5b3 --- /dev/null +++ b/tests/sql_generator_tests.cpp @@ -0,0 +1,400 @@ +#include +#include +#include +#include +#include + +#include "../tools/migrator/MakeMigrations.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + using namespace vix::db::schema; + using namespace vix::db::mig::diff; + + void require(bool condition, const std::string &message) + { + if (!condition) + throw std::runtime_error(message); + } + + void expect_eq(const std::string &actual, const std::string &expected, const std::string &label) + { + if (actual != expected) + { + std::cerr << "FAILED: " << label << "\nExpected:\n" + << expected << "\nActual:\n" + << actual << "\n"; + throw std::runtime_error(label); + } + } + + Column column(std::string name, + Type type, + bool nullable = true, + bool primaryKey = false, + bool autoIncrement = false, + bool unique = false, + std::string def = {}) + { + Column c; + c.name = std::move(name); + c.type = type; + c.nullable = nullable; + c.primary_key = primaryKey; + c.auto_increment = autoIncrement; + c.unique = unique; + if (!def.empty()) + c.def = DefaultValue{std::move(def)}; + return c; + } + + Index index(std::string name, std::vector columns, bool unique = false) + { + Index i; + i.name = std::move(name); + i.columns = std::move(columns); + i.unique = unique; + return i; + } + + Table users_table() + { + Table t; + t.name = "user table"; + t.columns = { + column("id", Type::BigInt(), false, true, true), + column("order", Type::Text(), false, false, false, false, "'new'"), + column("name\"with\"quote", Type::VarChar(64)), + column("active", Type::Bool(), false, false, false, false, "1"), + column("score", Type::Double()), + column("created_at", Type::DateTime()), + }; + t.indexes = { + index("idx user order", {"order"}), + index("uniq user quoted", {"name\"with\"quote"}, true), + }; + return t; + } + + void exec(sqlite3 *db, const std::string &sql) + { + char *err = nullptr; + const int rc = sqlite3_exec(db, sql.c_str(), nullptr, nullptr, &err); + if (rc != SQLITE_OK) + { + std::string msg = err != nullptr ? err : sqlite3_errmsg(db); + sqlite3_free(err); + throw std::runtime_error("sqlite exec failed: " + msg + "\nSQL:\n" + sql); + } + } + + bool table_exists(sqlite3 *db, const std::string &name) + { + sqlite3_stmt *stmt = nullptr; + const char *sql = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?"; + if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) != SQLITE_OK) + throw std::runtime_error(sqlite3_errmsg(db)); + sqlite3_bind_text(stmt, 1, name.c_str(), -1, nullptr); + const bool exists = sqlite3_step(stmt) == SQLITE_ROW; + sqlite3_finalize(stmt); + return exists; + } + + bool index_exists(sqlite3 *db, const std::string &name) + { + sqlite3_stmt *stmt = nullptr; + const char *sql = "SELECT 1 FROM sqlite_master WHERE type='index' AND name=?"; + if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) != SQLITE_OK) + throw std::runtime_error(sqlite3_errmsg(db)); + sqlite3_bind_text(stmt, 1, name.c_str(), -1, nullptr); + const bool exists = sqlite3_step(stmt) == SQLITE_ROW; + sqlite3_finalize(stmt); + return exists; + } + + bool column_exists(sqlite3 *db, const std::string &table, const std::string &name) + { + sqlite3_stmt *stmt = nullptr; + const std::string sql = "PRAGMA table_info(\"" + table + "\")"; + if (sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK) + throw std::runtime_error(sqlite3_errmsg(db)); + bool found = false; + while (sqlite3_step(stmt) == SQLITE_ROW) + { + const auto *text = sqlite3_column_text(stmt, 1); + if (text != nullptr && name == reinterpret_cast(text)) + { + found = true; + break; + } + } + sqlite3_finalize(stmt); + return found; + } + + bool sqlite_supports_drop_column() + { + return sqlite3_libversion_number() >= 3035000; + } + + void expect_throws_contains(void (*fn)(), const std::string &needle) + { + try + { + fn(); + } + catch (const std::exception &e) + { + require(std::string(e.what()).find(needle) != std::string::npos, + "exception did not contain expected text: " + needle); + return; + } + throw std::runtime_error("expected exception containing: " + needle); + } + + void test_sqlite_golden_create_table_and_types() + { + const std::vector ops{CreateTable{users_table()}}; + const std::string expected = + "-- Generated by Vix ORM (SQLite)\n" + "CREATE TABLE IF NOT EXISTS \"user table\" (\n" + " \"id\" INTEGER PRIMARY KEY AUTOINCREMENT,\n" + " \"order\" TEXT NOT NULL DEFAULT 'new',\n" + " \"name\"\"with\"\"quote\" TEXT,\n" + " \"active\" INTEGER NOT NULL DEFAULT 1,\n" + " \"score\" REAL,\n" + " \"created_at\" TEXT\n" + ");\n"; + expect_eq(vix::db::mig::sql::to_sqlite_up(ops), expected, "SQLite CREATE TABLE golden"); + } + + void test_sqlite_exec_create_add_index_drop_round_trip() + { + sqlite3 *db = nullptr; + require(sqlite3_open(":memory:", &db) == SQLITE_OK, "open sqlite memory db"); + + Table t = users_table(); + std::vector createOps{CreateTable{t}, CreateIndex{t.name, t.indexes[0]}, CreateIndex{t.name, t.indexes[1]}}; + exec(db, vix::db::mig::sql::to_sqlite_up(createOps)); + require(table_exists(db, "user table"), "created table should exist"); + require(column_exists(db, "user table", "name\"with\"quote"), "quoted column should exist"); + require(index_exists(db, "idx user order"), "normal index should exist"); + require(index_exists(db, "uniq user quoted"), "unique index should exist"); + + exec(db, "INSERT INTO \"user table\" (\"order\", \"name\"\"with\"\"quote\", \"active\") VALUES ('a', 'same', 1);"); + char *err = nullptr; + const int duplicate = sqlite3_exec( + db, + "INSERT INTO \"user table\" (\"order\", \"name\"\"with\"\"quote\", \"active\") VALUES ('b', 'same', 1);", + nullptr, + nullptr, + &err); + if (err != nullptr) + sqlite3_free(err); + require(duplicate != SQLITE_OK, "unique index should reject duplicate values"); + + const Column added = column("added", Type::Int(), false, false, false, false, "0"); + exec(db, vix::db::mig::sql::to_sqlite_up({AddColumn{t.name, added}})); + require(column_exists(db, "user table", "added"), "added column should exist"); + + if (sqlite_supports_drop_column()) + { + exec(db, vix::db::mig::sql::to_sqlite_down({AddColumn{t.name, added}})); + require(!column_exists(db, "user table", "added"), "down migration should drop added column"); + } + + exec(db, vix::db::mig::sql::to_sqlite_down(createOps)); + require(!table_exists(db, "user table"), "down migration should drop created table"); + sqlite3_close(db); + } + + void test_sqlite_drop_table_drop_index_and_drop_column() + { + sqlite3 *db = nullptr; + require(sqlite3_open(":memory:", &db) == SQLITE_OK, "open sqlite memory db"); + exec(db, "CREATE TABLE \"items\" (\"id\" INTEGER PRIMARY KEY, \"gone\" TEXT, \"kept\" TEXT);"); + exec(db, "CREATE INDEX \"idx_items_kept\" ON \"items\" (\"kept\");"); + + exec(db, vix::db::mig::sql::to_sqlite_up({DropIndex{"items", index("idx_items_kept", {"kept"})}})); + require(!index_exists(db, "idx_items_kept"), "DROP INDEX should remove index"); + + if (sqlite_supports_drop_column()) + { + exec(db, vix::db::mig::sql::to_sqlite_up({DropColumn{"items", column("gone", Type::Text())}})); + require(!column_exists(db, "items", "gone"), "DROP COLUMN should remove column"); + } + + Table t; + t.name = "items"; + exec(db, vix::db::mig::sql::to_sqlite_up({DropTable{t}})); + require(!table_exists(db, "items"), "DROP TABLE should remove table"); + sqlite3_close(db); + } + + void test_sqlite_unsupported_add_column_diagnostics() + { + expect_throws_contains( + []() + { + (void)vix::db::mig::sql::to_sqlite_up({AddColumn{"t", column("id", Type::Int(), false, true)}}); + }, + "PRIMARY KEY"); + expect_throws_contains( + []() + { + (void)vix::db::mig::sql::to_sqlite_up({AddColumn{"t", column("email", Type::Text(), true, false, false, true)}}); + }, + "UNIQUE"); + expect_throws_contains( + []() + { + (void)vix::db::mig::sql::to_sqlite_up({AddColumn{"t", column("required", Type::Text(), false)}}); + }, + "NOT NULL"); + } + + void test_deterministic_diff_ordering() + { + Schema from; + Table z; + z.name = "z_old"; + z.columns = {column("id", Type::Int(), false, true)}; + Table existing; + existing.name = "existing"; + existing.columns = {column("id", Type::Int(), false, true), column("old_col", Type::Text())}; + existing.indexes = {index("idx_old", {"old_col"})}; + from.tables = {z, existing}; + + Schema to; + Table changed; + changed.name = "existing"; + changed.columns = {column("id", Type::Int(), false, true), column("new_col", Type::Text())}; + changed.indexes = {index("idx_new", {"new_col"})}; + Table a; + a.name = "a_new"; + a.columns = {column("id", Type::Int(), false, true)}; + to.tables = {changed, a}; + + const auto ops = vix::db::mig::diff::diff_or_throw(from, to); + require(ops.size() == 6, "expected six diff operations"); + require(std::holds_alternative(ops[0]), "drop missing table first in source order"); + require(std::holds_alternative(ops[1]), "drop old column before adding new column"); + require(std::holds_alternative(ops[2]), "add new column after drops"); + require(std::holds_alternative(ops[3]), "drop old index before creating new index"); + require(std::holds_alternative(ops[4]), "create new index after dropping old index"); + require(std::holds_alternative(ops[5]), "create new table in target order"); + } + + void test_make_migrations_routes_sqlite() + { + namespace fs = std::filesystem; + const auto root = fs::temp_directory_path() / fs::path("vix-db-sqlite-generator-test"); + fs::remove_all(root); + fs::create_directories(root); + + Schema s; + s.tables = {users_table()}; + const auto schemaPath = root / "schema.new.json"; + { + std::ofstream out(schemaPath); + out << vix::db::schema::to_json_string(s, true); + } + + vix::db::tools::MigratorCLI::Options opt; + opt.command = "makemigrations"; + opt.newSchemaPath = schemaPath.string(); + opt.snapshotPath = (root / "schema.json").string(); + opt.migrationsDir = (root / "migrations").string(); + opt.name = "create users"; + opt.dialect = "sqlite"; + + require(vix::db::tools::run_make_migrations(opt) == 0, "makemigrations sqlite should succeed"); + + bool sawUp = false; + bool sawDown = false; + for (const auto &entry : fs::directory_iterator(root / "migrations")) + { + const auto path = entry.path(); + const auto filename = path.filename().string(); + std::ifstream in(path); + const std::string text((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + if (filename.find(".up.sql") != std::string::npos) + { + sawUp = true; + require(text.find("-- Generated by Vix ORM (SQLite)") != std::string::npos, "up migration should be SQLite"); + require(text.find("\"user table\"") != std::string::npos, "up migration should quote identifiers"); + } + if (filename.find(".down.sql") != std::string::npos) + { + sawDown = true; + require(text.find("-- Generated by Vix ORM (SQLite) [DOWN]") != std::string::npos, "down migration should be SQLite"); + } + } + + require(sawUp && sawDown, "makemigrations should write up and down SQL files"); + fs::remove_all(root); + } + + void test_mysql_generator_regression_golden() + { + Table t; + t.name = "users"; + t.columns = { + column("id", Type::BigInt(), false, true, true), + column("email", Type::VarChar(255), false, false, false, true), + }; + t.indexes = {index("idx_users_email", {"email"})}; + const std::vector ops{CreateTable{t}, CreateIndex{t.name, t.indexes[0]}}; + + const std::string expected = + "-- Generated by Vix ORM (MySQL)\n" + "CREATE TABLE IF NOT EXISTS `users` (\n" + " `id` BIGINT NOT NULL AUTO_INCREMENT,\n" + " `email` VARCHAR(255) NOT NULL UNIQUE\n" + ", PRIMARY KEY (`id`)\n" + ") ENGINE=InnoDB;\n" + "CREATE INDEX `idx_users_email` ON `users` (`email`);\n"; + expect_eq(vix::db::mig::sql::to_mysql_up(ops), expected, "MySQL generator regression"); + } + + void test_empty_sqlite_migration_set() + { + expect_eq(vix::db::mig::sql::to_sqlite_up({}), "-- Generated by Vix ORM (SQLite)\n", "empty sqlite up"); + expect_eq(vix::db::mig::sql::to_sqlite_down({}), "-- Generated by Vix ORM (SQLite) [DOWN]\n", "empty sqlite down"); + } +} + +int main() +{ + try + { + test_sqlite_golden_create_table_and_types(); + test_sqlite_exec_create_add_index_drop_round_trip(); + test_sqlite_drop_table_drop_index_and_drop_column(); + test_sqlite_unsupported_add_column_diagnostics(); + test_deterministic_diff_ordering(); + test_make_migrations_routes_sqlite(); + test_mysql_generator_regression_golden(); + test_empty_sqlite_migration_set(); + } + catch (const std::exception &e) + { + std::cerr << e.what() << "\n"; + return 1; + } + + std::cout << "vix_db_sql_generator_tests passed\n"; + return 0; +} diff --git a/tools/migrator/MakeMigrations.cpp b/tools/migrator/MakeMigrations.cpp index c517b38..8f95293 100644 --- a/tools/migrator/MakeMigrations.cpp +++ b/tools/migrator/MakeMigrations.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -87,17 +88,33 @@ namespace vix::db::tools auto ops = vix::db::mig::diff::diff_or_throw(oldS, newS); - // Always write snapshot (so formatting/version stays stable) - write_text(snapshot_path, vix::db::schema::to_json_string(newS, true)); - if (ops.empty()) + { + // Always write snapshot (so formatting/version stays stable) + write_text(snapshot_path, vix::db::schema::to_json_string(newS, true)); return 0; + } - if (opt.dialect != "mysql") - throw std::runtime_error("Only --dialect mysql is implemented for now"); + std::string up_sql; + std::string down_sql; + if (opt.dialect == "mysql") + { + up_sql = vix::db::mig::sql::to_mysql_up(ops); + down_sql = vix::db::mig::sql::to_mysql_down(ops); + } + else if (opt.dialect == "sqlite") + { + up_sql = vix::db::mig::sql::to_sqlite_up(ops); + down_sql = vix::db::mig::sql::to_sqlite_down(ops); + } + else + { + throw std::runtime_error("unsupported --dialect: " + opt.dialect); + } - const std::string up_sql = vix::db::mig::sql::to_mysql_up(ops); - const std::string down_sql = vix::db::mig::sql::to_mysql_down(ops); + // Write the snapshot only after generation succeeds. Unsupported + // dialect operations must not advance the schema snapshot. + write_text(snapshot_path, vix::db::schema::to_json_string(newS, true)); const std::string id = timestamp_id(); const std::string label = sanitize(opt.name); diff --git a/tools/migrator/MigratorCLI.cpp b/tools/migrator/MigratorCLI.cpp index 4a714ed..6b0b94a 100644 --- a/tools/migrator/MigratorCLI.cpp +++ b/tools/migrator/MigratorCLI.cpp @@ -50,7 +50,7 @@ namespace vix::db::tools << " " << prog << " tcp://127.0.0.1:3306 root '' mydb migrate --dir ./migrations\n" << " " << prog << " tcp://127.0.0.1:3306 root '' mydb rollback --steps 1\n" << " " << prog << " tcp://127.0.0.1:3306 root '' mydb status --dir db/migrations\n" - << " " << prog << " makemigrations --new ./schema.new.json --snapshot ./schema.json --dir ./migrations --name create_users\n"; + << " " << prog << " makemigrations --new ./schema.new.json --snapshot ./schema.json --dir ./migrations --name create_users --dialect sqlite\n"; } static bool has_flag(const std::vector &args, const std::string &key) diff --git a/tools/migrator/MigratorCLI.hpp b/tools/migrator/MigratorCLI.hpp index f92bbbf..0a4c3f7 100644 --- a/tools/migrator/MigratorCLI.hpp +++ b/tools/migrator/MigratorCLI.hpp @@ -38,7 +38,7 @@ namespace vix::db::tools std::string snapshotPath = "schema.json"; // schema snapshot (old -> updated) std::string newSchemaPath; // required: schema.new.json std::string name = "auto"; // label in filename - std::string dialect = "mysql"; // mysql | sqlite (mysql only for now) + std::string dialect = "mysql"; // mysql | sqlite }; static int run(int argc, char **argv);