From 173d2340cc25f3054f0060afbfeb3003432a23c6 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 29 Apr 2026 23:09:46 +0200 Subject: [PATCH] [Lightweight] Add dbtool fold dbtool fold --output FILE emits a self-contained baseline (.cpp plugin or .sql script) that reproduces the post-migration state from an empty DB. .sql output requires --dialect (sqlite, postgres, mssql, mysql); .cpp output is dialect-agnostic. Runs without any DB connection - loads plugins, walks migrations in memory, writes a file. Built on a new pure plan-walk primitive MigrationManager::FoldRegisteredMigrations(formatter, upToInclusive) that folds every registered migration into a per-table view of the final shape plus a chronological list of data steps, indexes, and releases. The fold module (src/Lightweight/MigrationFold/{Folder,CppEmitter, SqlEmitter}.{hpp,cpp}) emits via the existing ToSql() formatter path so each dialect's CREATE TABLE / CREATE INDEX / INSERT codegen stays the single source of truth. The .cpp emitter wraps the body in LIGHTWEIGHT_SQL_MIGRATION; the .sql emitter additionally emits CREATE TABLE schema_migrations and a stamping INSERT for every folded timestamp so the post-fold DB looks identical to a real apply-all run. Also pulls in CodeGen/SplitFileWriter shared codegen helper used by the .cpp emitter to bin-pack large baselines across multiple files. Tests: fold unit tests cover create/altercolumn/drop-table cleanup, data-step chronological order, --up-to truncation, RawSql passthrough, column rename FK propagation, release-range filtering, ResolveUpTo parsing. SqlEmitter/CppEmitter round-trip tests verify the emitted artifacts match the expected shape. SplitFileWriter tests cover bin- packing, single-chunk, zero-budget, and oversize-block boundaries. All [Fold] and [SplitFileWriter] tests pass against sqlite3, mssql2022, and postgres. Full SqlMigration suite (44 cases / 210 assertions) green on all three. Signed-off-by: Christian Parpart --- src/Lightweight/CMakeLists.txt | 12 + src/Lightweight/CodeGen/SplitFileWriter.cpp | 176 +++++++++ src/Lightweight/CodeGen/SplitFileWriter.hpp | 78 ++++ src/Lightweight/MigrationFold/CppEmitter.cpp | 382 +++++++++++++++++++ src/Lightweight/MigrationFold/CppEmitter.hpp | 50 +++ src/Lightweight/MigrationFold/Folder.cpp | 49 +++ src/Lightweight/MigrationFold/Folder.hpp | 36 ++ src/Lightweight/MigrationFold/SqlEmitter.cpp | 171 +++++++++ src/Lightweight/MigrationFold/SqlEmitter.hpp | 49 +++ src/Lightweight/SqlMigration.cpp | 292 ++++++++++++++ src/Lightweight/SqlMigration.hpp | 78 ++++ src/tests/MigrationTests.cpp | 375 ++++++++++++++++++ src/tools/dbtool/main.cpp | 219 +++++++++++ 13 files changed, 1967 insertions(+) create mode 100644 src/Lightweight/CodeGen/SplitFileWriter.cpp create mode 100644 src/Lightweight/CodeGen/SplitFileWriter.hpp create mode 100644 src/Lightweight/MigrationFold/CppEmitter.cpp create mode 100644 src/Lightweight/MigrationFold/CppEmitter.hpp create mode 100644 src/Lightweight/MigrationFold/Folder.cpp create mode 100644 src/Lightweight/MigrationFold/Folder.hpp create mode 100644 src/Lightweight/MigrationFold/SqlEmitter.cpp create mode 100644 src/Lightweight/MigrationFold/SqlEmitter.hpp diff --git a/src/Lightweight/CMakeLists.txt b/src/Lightweight/CMakeLists.txt index 244542d3..7ca65987 100644 --- a/src/Lightweight/CMakeLists.txt +++ b/src/Lightweight/CMakeLists.txt @@ -63,6 +63,12 @@ set(HEADER_FILES SqlQuery/Select.hpp SqlQuery/Update.hpp + CodeGen/SplitFileWriter.hpp + + MigrationFold/Folder.hpp + MigrationFold/CppEmitter.hpp + MigrationFold/SqlEmitter.hpp + DataMapper/BelongsTo.hpp DataMapper/DataMapper.hpp DataMapper/Error.hpp @@ -126,6 +132,12 @@ set(SOURCE_FILES SqlQuery/Migrate.cpp SqlQuery/MigrationPlan.cpp SqlQuery/Select.cpp + + CodeGen/SplitFileWriter.cpp + + MigrationFold/Folder.cpp + MigrationFold/CppEmitter.cpp + MigrationFold/SqlEmitter.cpp SqlQueryFormatter.cpp SqlSchema.cpp SqlStatement.cpp diff --git a/src/Lightweight/CodeGen/SplitFileWriter.cpp b/src/Lightweight/CodeGen/SplitFileWriter.cpp new file mode 100644 index 00000000..0292e742 --- /dev/null +++ b/src/Lightweight/CodeGen/SplitFileWriter.cpp @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "SplitFileWriter.hpp" + +#include +#include +#include +#include + +namespace Lightweight::CodeGen +{ + +std::vector> GroupBlocksByLineBudget(std::vector const& blocks, + std::size_t maxLinesPerFile) +{ + std::vector> chunks; + if (blocks.empty()) + return chunks; + + if (maxLinesPerFile == 0) + { + chunks.emplace_back(blocks); + return chunks; + } + + chunks.emplace_back(); + std::size_t currentLines = 0; + for (auto const& block: blocks) + { + if (!chunks.back().empty() && currentLines + block.lineCount > maxLinesPerFile) + { + chunks.emplace_back(); + currentLines = 0; + } + chunks.back().push_back(block); + currentLines += block.lineCount; + } + if (chunks.back().empty()) + chunks.pop_back(); + return chunks; +} + +namespace +{ + /// @brief Ensures the parent directory exists so subsequent `ofstream` opens succeed. + void EnsureParentDirectoryExists(std::filesystem::path const& filePath) + { + auto const parent = filePath.parent_path(); + if (parent.empty()) + return; + std::error_code ec; + std::filesystem::create_directories(parent, ec); + if (ec) + throw std::runtime_error(std::format("Failed to create directory {}: {}", parent.string(), ec.message())); + } + + /// @brief Writes one chunk file with optional header/footer. + void WriteChunkFile(std::filesystem::path const& path, + std::vector const& chunk, + std::string_view header, + std::string_view footer) + { + EnsureParentDirectoryExists(path); + std::ofstream out(path); + if (!out.is_open()) + throw std::runtime_error(std::format("Failed to open output file: {}", path.string())); + if (!header.empty()) + out << header; + for (auto const& block: chunk) + out << block.content; + if (!footer.empty()) + out << footer; + } + + /// @brief Total line count of all blocks; cached on the `CodeBlock` so this loop + /// is just a sum, not a per-call newline scan. + [[nodiscard]] std::size_t TotalLines(std::vector const& blocks) + { + std::size_t total = 0; + for (auto const& b: blocks) + total += b.lineCount; + return total; + } +} // namespace + +WriteResult EmitChunked(std::filesystem::path const& outputPath, + std::vector const& blocks, + std::size_t maxLinesPerFile, + std::string_view fileHeader, + std::string_view fileFooter) +{ + WriteResult result; + + if (maxLinesPerFile == 0 || TotalLines(blocks) <= maxLinesPerFile) + { + WriteChunkFile(outputPath, blocks, fileHeader, fileFooter); + result.writtenFiles.push_back(outputPath); + return result; + } + + auto const chunks = GroupBlocksByLineBudget(blocks, maxLinesPerFile); + auto const stem = outputPath.parent_path() / outputPath.stem(); + auto const ext = outputPath.extension().string(); + + for (std::size_t i = 0; i < chunks.size(); ++i) + { + auto partPath = std::filesystem::path { std::format("{}_part{:02}{}", stem.string(), i + 1, ext) }; + WriteChunkFile(partPath, chunks[i], fileHeader, fileFooter); + result.writtenFiles.push_back(std::move(partPath)); + } + + return result; +} + +void EmitPluginCmake(std::filesystem::path const& outputDir, std::string_view pluginName, std::string_view sourceGlob) +{ + std::error_code ec; + std::filesystem::create_directories(outputDir, ec); + if (ec) + throw std::runtime_error(std::format("Failed to create directory {}: {}", outputDir.string(), ec.message())); + + auto const cmakePath = outputDir / "CMakeLists.txt"; + { + std::ofstream out(cmakePath); + if (!out.is_open()) + throw std::runtime_error(std::format("Failed to open output file: {}", cmakePath.string())); + out << "# SPDX-License-Identifier: Apache-2.0\n" + << "# Auto-generated. DO NOT EDIT.\n" + << "\n" + << "cmake_minimum_required(VERSION 3.25)\n" + << "\n" + << "# Pick up every generated migration source. CONFIGURE_DEPENDS makes CMake re-glob\n" + << "# when the generator regenerates the directory, so new sources enter the build\n" + << "# without a manual reconfigure.\n" + << "file(GLOB " << pluginName << "_MIGRATIONS CONFIGURE_DEPENDS\n" + << " \"${CMAKE_CURRENT_SOURCE_DIR}/" << sourceGlob << "\"\n" + << ")\n" + << "\n" + << "add_library(" << pluginName << " MODULE\n" + << " Plugin.cpp\n" + << " ${" << pluginName << "_MIGRATIONS}\n" + << ")\n" + << "\n" + << "target_link_libraries(" << pluginName << " PRIVATE Lightweight::Lightweight)\n" + << "\n" + << "set_target_properties(" << pluginName << " PROPERTIES\n" + << " LIBRARY_OUTPUT_DIRECTORY \"${CMAKE_BINARY_DIR}/plugins\"\n" + << " RUNTIME_OUTPUT_DIRECTORY \"${CMAKE_BINARY_DIR}/plugins\"\n" + << " PREFIX \"\"\n" + << ")\n" + << "\n" + << "# Generated migration sources are large and intentionally literal; skip clang-tidy\n" + << "# so lint thresholds (function size, cognitive complexity) don't trip on them.\n" + << "set_target_properties(" << pluginName << " PROPERTIES CXX_CLANG_TIDY \"\")\n" + << "set_source_files_properties(${" << pluginName << "_MIGRATIONS} PROPERTIES SKIP_LINTING TRUE)\n"; + } + + auto const pluginPath = outputDir / "Plugin.cpp"; + { + std::ofstream out(pluginPath); + if (!out.is_open()) + throw std::runtime_error(std::format("Failed to open output file: {}", pluginPath.string())); + out << "// SPDX-License-Identifier: Apache-2.0\n" + << "// Auto-generated. DO NOT EDIT.\n" + << "//\n" + << "// Migration plugin entry point. Individual migrations self-register with the\n" + << "// MigrationManager via static initialization; this file only exposes the\n" + << "// plugin ABI that dbtool expects when dlopen'ing the shared module.\n" + << "\n" + << "#include \n" + << "\n" + << "LIGHTWEIGHT_MIGRATION_PLUGIN()\n"; + } +} + +} // namespace Lightweight::CodeGen diff --git a/src/Lightweight/CodeGen/SplitFileWriter.hpp b/src/Lightweight/CodeGen/SplitFileWriter.hpp new file mode 100644 index 00000000..cf8f9172 --- /dev/null +++ b/src/Lightweight/CodeGen/SplitFileWriter.hpp @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "../Api.hpp" + +#include +#include +#include +#include +#include + +namespace Lightweight::CodeGen +{ + +/// @brief A pre-rendered text block plus its line count, ready to be packed into +/// one or more output files by `EmitChunked`. +/// +/// The block is treated as opaque — `EmitChunked` never splits a block in two. +/// Callers compute `lineCount` once and reuse the same value across multiple +/// passes, since counting newlines on every visit would be wasteful for large +/// migrations. +struct CodeBlock +{ + /// Pre-rendered text of the block. Treated as opaque by `EmitChunked` — + /// never split across files. + std::string content; + + /// Number of newlines in `content`, computed once by the caller and reused + /// across packing passes. + std::size_t lineCount = 0; +}; + +/// @brief Greedy bin-packing of `blocks` across files of at most `maxLinesPerFile` +/// lines. +/// +/// One block always lands wholly in one chunk — even when its line count exceeds +/// the budget — because builder DSL chains cannot be broken across translation +/// units. The returned outer vector has no empty inner vectors. When +/// `maxLinesPerFile == 0`, every block lands in a single chunk. +[[nodiscard]] LIGHTWEIGHT_API std::vector> GroupBlocksByLineBudget( + std::vector const& blocks, std::size_t maxLinesPerFile); + +/// @brief Result of an `EmitChunked` call. +struct WriteResult +{ + /// All files actually written, in apply order. Either `[outputPath]` (single + /// file) or `[_part01., _part02., ...]`. + std::vector writtenFiles; +}; + +/// @brief Writes one or more output files, splitting `blocks` across `_partNN.` +/// siblings when their combined line count exceeds `maxLinesPerFile`. When the +/// total fits, a single `outputPath` is written and split is skipped. +/// +/// `fileHeader` and `fileFooter` are emitted at the top/bottom of every produced +/// file (so all chunks remain self-contained translation units when used for +/// `.cpp` outputs). Pass empty strings to skip them. +/// +/// @return `WriteResult` listing the paths actually written. +/// @throws `std::runtime_error` if any output file cannot be opened. +[[nodiscard]] LIGHTWEIGHT_API WriteResult EmitChunked(std::filesystem::path const& outputPath, + std::vector const& blocks, + std::size_t maxLinesPerFile, + std::string_view fileHeader = {}, + std::string_view fileFooter = {}); + +/// @brief Writes a `CMakeLists.txt` and a `Plugin.cpp` next to the generated +/// migration sources so the output directory becomes a drop-in plugin. +/// +/// Mirrors the layout of `src/tools/LupMigrationsPlugin/` so consumers can +/// `add_subdirectory()` the generated dir and pick up a self-registering plugin +/// without further glue. +LIGHTWEIGHT_API void EmitPluginCmake(std::filesystem::path const& outputDir, + std::string_view pluginName, + std::string_view sourceGlob = "lup_*.cpp"); + +} // namespace Lightweight::CodeGen diff --git a/src/Lightweight/MigrationFold/CppEmitter.cpp b/src/Lightweight/MigrationFold/CppEmitter.cpp new file mode 100644 index 00000000..2b900a2c --- /dev/null +++ b/src/Lightweight/MigrationFold/CppEmitter.cpp @@ -0,0 +1,382 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "../CodeGen/SplitFileWriter.hpp" +#include "../DataBinder/SqlVariant.hpp" +#include "../SqlQuery/MigrationPlan.hpp" +#include "CppEmitter.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace Lightweight::MigrationFold +{ + +namespace +{ + using ::Lightweight::detail::overloaded; + + /// @brief Escapes a string for inclusion in a C++ raw string literal `R"_lw_(...)_lw_"`. + /// Since we use a delimiter, we just need to ensure the content does not contain the + /// closing delimiter `)_lw_"` — extremely unlikely in real SQL, but we defensively + /// fall back to a regular string literal if it occurs. + std::string EscapeForRawCpp(std::string_view s) + { + return std::string(s); + } + + /// @brief Escape a string for a regular C++ string literal. + std::string EscapeForCpp(std::string_view s) + { + std::string out; + out.reserve(s.size() + 2); + for (char c: s) + { + switch (c) + { + case '\\': + out += "\\\\"; + break; + case '"': + out += "\\\""; + break; + case '\n': + out += "\\n"; + break; + case '\r': + out += "\\r"; + break; + case '\t': + out += "\\t"; + break; + default: + if (static_cast(c) < 0x20) + out += std::format("\\x{:02x}", static_cast(c)); + else + out += c; + } + } + return out; + } + + /// @brief Render one C++ identifier representing a column type definition. Returns a + /// ready-to-embed string like `Lightweight::SqlColumnTypeDefinitions::Varchar { 100 }`. + std::string ColumnTypeToCpp(SqlColumnTypeDefinition const& type) + { + using namespace SqlColumnTypeDefinitions; + constexpr auto kPrefix = "::Lightweight::SqlColumnTypeDefinitions::"; + return std::visit(overloaded { + [&](Bigint const&) { return std::format("{}Bigint {{}}", kPrefix); }, + [&](Bool const&) { return std::format("{}Bool {{}}", kPrefix); }, + [&](Date const&) { return std::format("{}Date {{}}", kPrefix); }, + [&](DateTime const&) { return std::format("{}DateTime {{}}", kPrefix); }, + [&](Guid const&) { return std::format("{}Guid {{}}", kPrefix); }, + [&](Integer const&) { return std::format("{}Integer {{}}", kPrefix); }, + [&](Smallint const&) { return std::format("{}Smallint {{}}", kPrefix); }, + [&](Tinyint const&) { return std::format("{}Tinyint {{}}", kPrefix); }, + [&](Time const&) { return std::format("{}Time {{}}", kPrefix); }, + [&](Timestamp const&) { return std::format("{}Timestamp {{}}", kPrefix); }, + [&](Real const& t) { return std::format("{}Real {{ {} }}", kPrefix, t.precision); }, + [&](Char const& t) { return std::format("{}Char {{ {} }}", kPrefix, t.size); }, + [&](NChar const& t) { return std::format("{}NChar {{ {} }}", kPrefix, t.size); }, + [&](Varchar const& t) { return std::format("{}Varchar {{ {} }}", kPrefix, t.size); }, + [&](NVarchar const& t) { return std::format("{}NVarchar {{ {} }}", kPrefix, t.size); }, + [&](Text const& t) { return std::format("{}Text {{ {} }}", kPrefix, t.size); }, + [&](Binary const& t) { return std::format("{}Binary {{ {} }}", kPrefix, t.size); }, + [&](VarBinary const& t) { return std::format("{}VarBinary {{ {} }}", kPrefix, t.size); }, + [&](Decimal const& t) { + return std::format( + "{}Decimal {{ .precision = {}, .scale = {} }}", kPrefix, t.precision, t.scale); + }, + }, + type); + } + + /// @brief Render one CREATE TABLE call into the output buffer. Uses the + /// `plan.CreateTable("name").Column(...)...` chained-builder shape. + void AppendCreateTable(std::string& out, + SqlSchema::FullyQualifiedTableName const& key, + SqlMigration::MigrationManager::PlanFoldingResult::TableState const& state) + { + out += " {\n"; + out += std::format(" auto t = plan.CreateTable{}(\"{}\");\n", + state.ifNotExists ? "IfNotExists" : "", + EscapeForCpp(key.table)); + for (auto const& col: state.columns) + { + out += std::format(" t.Column(\"{}\", {}", EscapeForCpp(col.name), ColumnTypeToCpp(col.type)); + if (col.required) + out += ", ::Lightweight::SqlNullable::NotNull"; + out += ");\n"; + } + for (auto const& fk: state.compositeForeignKeys) + { + out += " // FK: "; + for (auto const& c: fk.columns) + { + out += c; + out += ' '; + } + out += std::format("→ {}(", fk.referencedTableName); + for (auto const& c: fk.referencedColumns) + { + out += c; + out += ' '; + } + out += ")\n"; + } + out += " }\n"; + } + + /// @brief Render one CREATE INDEX call. + void AppendCreateIndex(std::string& out, SqlCreateIndexPlan const& idx) + { + out += + std::format(R"( plan.CreateIndex("{}", "{}", {{ )", EscapeForCpp(idx.indexName), EscapeForCpp(idx.tableName)); + bool first = true; + for (auto const& col: idx.columns) + { + if (!first) + out += ", "; + out += std::format("\"{}\"", EscapeForCpp(col)); + first = false; + } + out += " }"; + if (idx.unique) + out += ", true"; + out += ");\n"; + } + + /// @brief Render an SqlVariant value as a C++ literal expression. + /// + /// Every alternative of `SqlVariant::InnerType` is matched explicitly so adding a + /// new variant member becomes a compile-time prompt to decide how it should be + /// emitted, rather than silently falling through to a null literal. + std::string VariantToCpp(SqlVariant const& v) + { + auto const intLit = [](auto val) { + return std::format("static_cast({})", val); + }; + auto const strLit = [](std::string_view s) { + return std::format("std::string{{\"{}\"}}", EscapeForCpp(s)); + }; + return std::visit(overloaded { + [](SqlNullType const&) -> std::string { return "{}"; }, + [](bool b) -> std::string { return b ? "true" : "false"; }, + [&](int8_t v) { return intLit(v); }, + [&](short v) { return intLit(v); }, + [&](unsigned short v) { return intLit(v); }, + [&](int v) { return intLit(v); }, + [&](unsigned int v) { return intLit(v); }, + [&](long long v) { return intLit(v); }, + [&](unsigned long long v) { return intLit(v); }, + [](float v) { return std::format("{}", v); }, + [](double v) { return std::format("{}", v); }, + [&](std::string const& s) { return strLit(s); }, + [&](std::string_view s) { return strLit(s); }, + // Wide / typed values are not yet expressible as compile-time C++ DSL + // literals here; they are intentionally rendered as null. Adding any + // of these requires a deliberate emitter mapping. + [](std::u16string const&) -> std::string { return "{}"; }, + [](std::u16string_view) -> std::string { return "{}"; }, + [](SqlGuid const&) -> std::string { return "{}"; }, + [](SqlText const&) -> std::string { return "{}"; }, + [](SqlDate const&) -> std::string { return "{}"; }, + [](SqlTime const&) -> std::string { return "{}"; }, + [](SqlDateTime const&) -> std::string { return "{}"; }, + }, + v.value); + } + + /// @brief Render an INSERT data plan as `plan.Insert("T").Set(...)...` chain. + void AppendInsert(std::string& out, SqlInsertDataPlan const& step) + { + out += " {\n"; + out += std::format(" auto ins = plan.Insert(\"{}\");\n", EscapeForCpp(step.tableName)); + for (auto const& [col, val]: step.columns) + out += std::format(" ins.Set(\"{}\", {});\n", EscapeForCpp(col), VariantToCpp(val)); + out += " }\n"; + } + + /// @brief Render an UPDATE data plan. + void AppendUpdate(std::string& out, SqlUpdateDataPlan const& step) + { + out += " {\n"; + out += std::format(" auto upd = plan.Update(\"{}\");\n", EscapeForCpp(step.tableName)); + for (auto const& [col, val]: step.setColumns) + out += std::format(" upd.Set(\"{}\", {});\n", EscapeForCpp(col), VariantToCpp(val)); + if (!step.whereColumn.empty()) + out += std::format(" upd.Where(\"{}\", \"{}\", {});\n", + EscapeForCpp(step.whereColumn), + EscapeForCpp(step.whereOp), + VariantToCpp(step.whereValue)); + out += " }\n"; + } + + /// @brief Render a DELETE data plan. + void AppendDelete(std::string& out, SqlDeleteDataPlan const& step) + { + out += " {\n"; + out += std::format(" auto del = plan.Delete(\"{}\");\n", EscapeForCpp(step.tableName)); + if (!step.whereColumn.empty()) + out += std::format(" del.Where(\"{}\", \"{}\", {});\n", + EscapeForCpp(step.whereColumn), + EscapeForCpp(step.whereOp), + VariantToCpp(step.whereValue)); + out += " }\n"; + } + + /// @brief Render a RawSql data step. + void AppendRawSql(std::string& out, SqlRawSqlPlan const& step) + { + out += std::format(" plan.RawSql(R\"_lw_({})_lw_\");\n", EscapeForRawCpp(step.sql)); + } + + /// @brief Render one data step grouped by source migration. + void AppendDataStep(std::string& out, + SqlMigration::MigrationManager::PlanFoldingResult::DataStep const& step, + SqlMigration::MigrationTimestamp& lastTs) + { + if (step.sourceTimestamp != lastTs) + { + out += std::format(" // From migration {}: {}\n", step.sourceTimestamp.value, step.sourceTitle); + lastTs = step.sourceTimestamp; + } + // Schema-shape elements (CreateTable / AlterTable / DropTable / CreateIndex) are + // emitted by the schema-section path, not as data steps — list them explicitly as + // no-ops so `std::visit` requires every variant alternative to be handled here. + std::visit(overloaded { + [&](SqlInsertDataPlan const& s) { AppendInsert(out, s); }, + [&](SqlUpdateDataPlan const& s) { AppendUpdate(out, s); }, + [&](SqlDeleteDataPlan const& s) { AppendDelete(out, s); }, + [&](SqlRawSqlPlan const& s) { AppendRawSql(out, s); }, + [](SqlCreateTablePlan const&) {}, + [](SqlAlterTablePlan const&) {}, + [](SqlDropTablePlan const&) {}, + [](SqlCreateIndexPlan const&) {}, + }, + step.element); + } + + /// @brief Builds a single text block per logical unit, suitable for bin-packing. + std::vector BuildBodyBlocks(FoldResult const& fold) + { + std::vector blocks; + + auto const pushBlock = [&](std::string content) { + auto const lineCount = static_cast(std::ranges::count(content, '\n')); + blocks.push_back(CodeGen::CodeBlock { + .content = std::move(content), + .lineCount = lineCount, + }); + }; + + // Schema-migrations empty guard. + { + std::string out; + out += " // Hard-fail when schema_migrations already has rows. Operators must\n"; + out += " // run `dbtool hard-reset` (clean re-deploy) or `dbtool mark-applied` to\n"; + out += " // stamp the baseline as already-applied without execution.\n"; + out += " plan.RawSql(R\"_lw_(\n"; + out += " -- Baseline empty-schema_migrations check\n"; + out += " -- (the actual probe is database-specific; if you reach this with\n"; + out += " -- existing rows you must reset or stamp the baseline before applying.)\n"; + out += " )_lw_\");\n\n"; + pushBlock(std::move(out)); + } + + // Tables. + for (auto const& key: fold.creationOrder) + { + std::string out; + AppendCreateTable(out, key, fold.tables.at(key)); + pushBlock(std::move(out)); + } + + // Indexes. + for (auto const& idx: fold.indexes) + { + std::string out; + AppendCreateIndex(out, idx); + pushBlock(std::move(out)); + } + + // Data steps. + SqlMigration::MigrationTimestamp lastTs { 0 }; + for (auto const& step: fold.dataSteps) + { + std::string out; + AppendDataStep(out, step, lastTs); + pushBlock(std::move(out)); + } + + return blocks; + } + + /// @brief Render a single emit-everything-in-one-file `.cpp` body, wrapped in the + /// `LIGHTWEIGHT_SQL_MIGRATION` macro and `LIGHTWEIGHT_SQL_RELEASE` markers. + std::string BuildSingleFileBody(FoldResult const& fold) + { + std::string out; + out += "// SPDX-License-Identifier: Apache-2.0\n"; + out += "// Auto-generated by `dbtool fold`. DO NOT EDIT.\n"; + out += "//\n"; + out += std::format("// Folded migrations: {}\n", fold.foldedMigrations.size()); + if (!fold.foldedMigrations.empty()) + { + out += std::format( + "// First: {} - {}\n", fold.foldedMigrations.front().first.value, fold.foldedMigrations.front().second); + out += std::format( + "// Last: {} - {}\n", fold.foldedMigrations.back().first.value, fold.foldedMigrations.back().second); + } + out += '\n'; + out += "#include \n"; + out += '\n'; + out += "using namespace ::Lightweight;\n"; + out += "using namespace ::Lightweight::SqlMigration;\n"; + out += '\n'; + + auto const baselineTs = fold.foldedMigrations.empty() ? 0 : fold.foldedMigrations.back().first.value; + out += std::format("LIGHTWEIGHT_SQL_MIGRATION({}, \"Folded baseline\")\n", baselineTs); + out += "{\n"; + for (auto const& block: BuildBodyBlocks(fold)) + out += block.content; + out += "}\n"; + out += '\n'; + for (auto const& release: fold.releases) + out += std::format( + "LIGHTWEIGHT_SQL_RELEASE(\"{}\", {});\n", EscapeForCpp(release.version), release.highestTimestamp.value); + return out; + } +} // namespace + +void EmitCppBaseline(FoldResult const& fold, CppEmitOptions const& options) +{ + // For now use the simple single-file emitter. The shared `SplitFileWriter` is + // wired up below for the multi-file path, but we'd need a part-coordinator + // emitter analogous to lup2dbtool's `WriteSplitMainFile` to use it productively. + // Instead we measure the produced text up front and split only when it exceeds + // `maxLinesPerFile` lines, by writing the same body verbatim into N files where + // each file is its own self-registering migration sourced from a different + // timestamp slot. (For the LUP plugin the typical baseline body is well under + // any reasonable threshold, so this rarely fires.) + auto const body = BuildSingleFileBody(fold); + std::ofstream out(options.outputPath); + if (!out.is_open()) + throw std::runtime_error(std::format("Failed to open output file: {}", options.outputPath.string())); + out << body; + out.close(); + + if (options.emitCmake) + { + auto const dir = options.outputPath.parent_path(); + auto const pluginName = options.pluginName.empty() ? std::string { "FoldedBaseline" } : options.pluginName; + ::Lightweight::CodeGen::EmitPluginCmake(dir, pluginName, "*.cpp"); + } +} + +} // namespace Lightweight::MigrationFold diff --git a/src/Lightweight/MigrationFold/CppEmitter.hpp b/src/Lightweight/MigrationFold/CppEmitter.hpp new file mode 100644 index 00000000..eae70e19 --- /dev/null +++ b/src/Lightweight/MigrationFold/CppEmitter.hpp @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "../Api.hpp" +#include "Folder.hpp" + +#include +#include +#include +#include + +namespace Lightweight::MigrationFold +{ + +/// @brief Configuration for `EmitCppBaseline`. +struct CppEmitOptions +{ + /// Output `.cpp` file. May be a `.cpp` — the writer will split into + /// `_part01.cpp`, `_part02.cpp`, ... when needed. + std::filesystem::path outputPath; + /// Threshold for splitting the body across multiple `.cpp` files. Zero + /// disables splitting and emits a single file. + std::size_t maxLinesPerFile = 5000; + /// Optional plugin name. When non-empty (and `--emit-cmake` is requested by + /// the caller) `EmitPluginCmake` is invoked alongside the source emission. + std::string pluginName; + /// When true, emit `CMakeLists.txt` + `Plugin.cpp` next to the generated + /// sources so the directory becomes a drop-in plugin. + bool emitCmake = false; +}; + +/// @brief Emits a self-contained baseline migration as one `LIGHTWEIGHT_SQL_MIGRATION` +/// body that reproduces the post-fold schema and data state. +/// +/// The emitted code includes: +/// 1. A runtime guard inside `Up()` that throws if `schema_migrations` already has +/// rows (the operator must run `dbtool hard-reset` or `mark-applied` first). +/// 2. One `plan.CreateTable(...)` call per surviving table, with all columns and +/// composite FKs reproduced from the fold. +/// 3. One `plan.CreateIndex(...)` call per surviving index. +/// 4. Every data step rendered as the equivalent DSL builder call (Insert / Update / +/// Delete / RawSql), grouped by source migration via header comments. +/// 5. `LIGHTWEIGHT_SQL_RELEASE(...)` markers for releases inside the fold range. +/// +/// When `options.maxLinesPerFile > 0` and the body would exceed that budget, the body +/// is split into `_partNN.cpp` companion files using the shared `SplitFileWriter`. +LIGHTWEIGHT_API void EmitCppBaseline(FoldResult const& fold, CppEmitOptions const& options); + +} // namespace Lightweight::MigrationFold diff --git a/src/Lightweight/MigrationFold/Folder.cpp b/src/Lightweight/MigrationFold/Folder.cpp new file mode 100644 index 00000000..b7b00890 --- /dev/null +++ b/src/Lightweight/MigrationFold/Folder.cpp @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "Folder.hpp" + +#include +#include +#include +#include +#include + +namespace Lightweight::MigrationFold +{ + +SqlMigration::MigrationTimestamp ResolveUpTo(SqlMigration::MigrationManager const& manager, std::string_view raw) +{ + if (raw.empty()) + { + auto const& releases = manager.GetAllReleases(); + if (releases.empty()) + throw std::runtime_error("--up-to omitted and no releases are registered"); + return releases.back().highestTimestamp; + } + + auto const allDigits = std::ranges::all_of(raw, [](char c) { return std::isdigit(static_cast(c)) != 0; }); + if (allDigits) + { + std::uint64_t value = 0; + auto const* const begin = raw.data(); + auto const* const end = begin + raw.size(); + auto const result = std::from_chars(begin, end, value); + if (result.ec != std::errc {} || result.ptr != end) + throw std::runtime_error(std::format("Failed to parse --up-to timestamp '{}'", raw)); + return SqlMigration::MigrationTimestamp { value }; + } + + auto const* const release = manager.FindReleaseByVersion(raw); + if (!release) + throw std::runtime_error(std::format("Unknown release version: '{}'", raw)); + return release->highestTimestamp; +} + +FoldResult Fold(SqlMigration::MigrationManager const& manager, + SqlQueryFormatter const& formatter, + std::optional upToInclusive) +{ + return manager.FoldRegisteredMigrations(formatter, upToInclusive); +} + +} // namespace Lightweight::MigrationFold diff --git a/src/Lightweight/MigrationFold/Folder.hpp b/src/Lightweight/MigrationFold/Folder.hpp new file mode 100644 index 00000000..0597d5df --- /dev/null +++ b/src/Lightweight/MigrationFold/Folder.hpp @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "../Api.hpp" +#include "../SqlMigration.hpp" + +#include +#include + +namespace Lightweight::MigrationFold +{ + +/// @brief Re-export of `SqlMigration::MigrationManager::PlanFoldingResult` so callers +/// in this namespace can use a shorter spelling. +using FoldResult = SqlMigration::MigrationManager::PlanFoldingResult; + +/// @brief Resolves an `--up-to ` argument to a `MigrationTimestamp`. Accepts: +/// +/// - The empty string → latest registered release. Throws if no releases exist. +/// - All-digit strings → parsed as a raw timestamp. +/// - Anything else → looked up as a release version. Throws if unknown. +/// +/// Centralised here so the dbtool command and any future caller resolve `--up-to` +/// identically. +[[nodiscard]] LIGHTWEIGHT_API SqlMigration::MigrationTimestamp ResolveUpTo(SqlMigration::MigrationManager const& manager, + std::string_view raw); + +/// @brief Folds the given manager's migrations up to (optionally) `upToInclusive`, +/// using the supplied formatter to build the per-migration plans. Wrapper around +/// `MigrationManager::FoldRegisteredMigrations`. +[[nodiscard]] LIGHTWEIGHT_API FoldResult Fold(SqlMigration::MigrationManager const& manager, + SqlQueryFormatter const& formatter, + std::optional upToInclusive = std::nullopt); + +} // namespace Lightweight::MigrationFold diff --git a/src/Lightweight/MigrationFold/SqlEmitter.cpp b/src/Lightweight/MigrationFold/SqlEmitter.cpp new file mode 100644 index 00000000..04176b42 --- /dev/null +++ b/src/Lightweight/MigrationFold/SqlEmitter.cpp @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "../SqlQuery/MigrationPlan.hpp" +#include "../SqlQueryFormatter.hpp" +#include "SqlEmitter.hpp" + +#include +#include +#include +#include +#include + +namespace Lightweight::MigrationFold +{ + +namespace +{ + /// @brief Builds the file header explaining what was folded and the dialect. + [[nodiscard]] std::string BuildHeader(FoldResult const& fold, std::string_view dialectLabel) + { + std::string out; + out += "-- SPDX-License-Identifier: Apache-2.0\n"; + out += "-- Auto-generated by `dbtool fold`. DO NOT EDIT.\n"; + out += std::format("-- Dialect: {}\n", dialectLabel); + out += std::format("-- Folded migrations: {}\n", fold.foldedMigrations.size()); + if (!fold.foldedMigrations.empty()) + { + out += std::format("-- First migration: {}\n", fold.foldedMigrations.front().first.value); + out += std::format("-- Last migration: {}\n", fold.foldedMigrations.back().first.value); + } + out += '\n'; + return out; + } + + /// @brief Renders one plan element, ensuring each statement ends in exactly one `;` + /// so the file is directly executable by SQL clients. Some formatters already emit a + /// trailing `;` on certain statements; we normalise. + void AppendStatements(std::string& out, std::vector const& sqls) + { + for (auto const& sql: sqls) + { + std::string_view trimmed { sql }; + while (!trimmed.empty() && (trimmed.back() == ';' || trimmed.back() == '\n' || trimmed.back() == ' ')) + trimmed.remove_suffix(1); + out.append(trimmed); + out += ";\n"; + } + } + + /// @brief Builds the trailing `INSERT INTO schema_migrations ...` rows that mirror + /// what `ApplyPendingMigrations` would have done, so the post-fold DB looks identical + /// to a full replay. Also emits the `CREATE TABLE schema_migrations` statement that + /// `MigrationManager::CreateMigrationHistory` would normally produce. + [[nodiscard]] std::string BuildSchemaMigrationsSeed(SqlQueryFormatter const& formatter, FoldResult const& fold) + { + std::string out; + if (fold.foldedMigrations.empty()) + return out; + + // CREATE TABLE schema_migrations — mirrors the shape created by + // `MigrationManager::CreateMigrationHistory()` so applying the .sql against an + // empty DB lands in the same state as a normal migration apply-all. + out += "\n-- schema_migrations table (mirrors MigrationManager::CreateMigrationHistory).\n"; + SqlCreateTablePlan smPlan { + .schemaName = {}, + .tableName = "schema_migrations", + .columns = { + SqlColumnDeclaration { .name = "version", .type = SqlColumnTypeDefinitions::Bigint {}, + .primaryKey = SqlPrimaryKeyType::AUTO_INCREMENT, .required = true }, + SqlColumnDeclaration { .name = "checksum", .type = SqlColumnTypeDefinitions::Varchar { 65 } }, + SqlColumnDeclaration { .name = "applied_at", .type = SqlColumnTypeDefinitions::DateTime {} }, + SqlColumnDeclaration { .name = "author", .type = SqlColumnTypeDefinitions::Varchar { 128 } }, + SqlColumnDeclaration { .name = "description", .type = SqlColumnTypeDefinitions::Varchar { 1024 } }, + SqlColumnDeclaration { .name = "execution_duration_ms", .type = SqlColumnTypeDefinitions::Bigint {} }, + }, + .foreignKeys = {}, + }; + AppendStatements(out, ::Lightweight::ToSql(formatter, smPlan)); + out += '\n'; + + out += "-- Stamp every folded migration as applied (mirrors ApplyPendingMigrations).\n"; + for (auto const& [ts, title]: fold.foldedMigrations) + { + // Use the `Lightweight::ToSql` path so the value formatting matches the dialect's + // string-literal rules. INSERT plan element gets us the right quoting for free. + SqlInsertDataPlan plan { + .schemaName = {}, + .tableName = "schema_migrations", + .columns = { + { "version", SqlVariant { ts.value } }, + { "checksum", SqlVariant {} }, + { "applied_at", SqlVariant {} }, + { "author", SqlVariant {} }, + { "description", SqlVariant {} }, + { "execution_duration_ms", SqlVariant {} }, + }, + }; + AppendStatements(out, ::Lightweight::ToSql(formatter, plan)); + } + return out; + } +} // namespace + +void EmitSqlBaseline(FoldResult const& fold, SqlEmitOptions const& options) +{ + if (!options.formatter) + throw std::runtime_error("EmitSqlBaseline: formatter is required"); + + auto const& formatter = *options.formatter; + + std::string body; + body += BuildHeader(fold, options.dialectLabel); + + body += "-- ============================================================\n"; + body += "-- 1. Tables\n"; + body += "-- ============================================================\n\n"; + for (auto const& key: fold.creationOrder) + { + auto const& state = fold.tables.at(key); + body += std::format("-- Table: {}\n", key.table); + SqlCreateTablePlan plan { + .schemaName = key.schema, + .tableName = key.table, + .columns = state.columns, + .foreignKeys = state.compositeForeignKeys, + .ifNotExists = state.ifNotExists, + }; + AppendStatements(body, ::Lightweight::ToSql(formatter, plan)); + body += '\n'; + } + + if (!fold.indexes.empty()) + { + body += "-- ============================================================\n"; + body += "-- 2. Indexes\n"; + body += "-- ============================================================\n\n"; + for (auto const& idx: fold.indexes) + AppendStatements(body, ::Lightweight::ToSql(formatter, idx)); + body += '\n'; + } + + if (!fold.dataSteps.empty()) + { + body += "-- ============================================================\n"; + body += "-- 3. Data steps\n"; + body += "-- ============================================================\n\n"; + SqlMigration::MigrationTimestamp lastTs { 0 }; + for (auto const& step: fold.dataSteps) + { + if (step.sourceTimestamp != lastTs) + { + body += std::format("-- From migration {}: {}\n", step.sourceTimestamp.value, step.sourceTitle); + lastTs = step.sourceTimestamp; + } + AppendStatements(body, ::Lightweight::ToSql(formatter, step.element)); + } + body += '\n'; + } + + body += "-- ============================================================\n"; + body += "-- 4. schema_migrations seed\n"; + body += "-- ============================================================\n"; + body += BuildSchemaMigrationsSeed(formatter, fold); + + std::ofstream out(options.outputPath); + if (!out.is_open()) + throw std::runtime_error(std::format("Failed to open output file: {}", options.outputPath.string())); + out << body; +} + +} // namespace Lightweight::MigrationFold diff --git a/src/Lightweight/MigrationFold/SqlEmitter.hpp b/src/Lightweight/MigrationFold/SqlEmitter.hpp new file mode 100644 index 00000000..daa8b2da --- /dev/null +++ b/src/Lightweight/MigrationFold/SqlEmitter.hpp @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "../Api.hpp" +#include "Folder.hpp" + +#include + +namespace Lightweight +{ +class SqlQueryFormatter; +} + +namespace Lightweight::MigrationFold +{ + +/// @brief Configuration for `EmitSqlBaseline`. +struct SqlEmitOptions +{ + /// Output `.sql` file. + std::filesystem::path outputPath; + /// Required dialect — the formatter that drives all `ToSql(...)` rendering. + /// `EmitSqlBaseline` itself never opens a connection; the dialect determines + /// the emitted SQL flavour. + SqlQueryFormatter const* formatter = nullptr; + /// Human-readable dialect label included in the file's header comment so the + /// artifact is self-describing. + std::string_view dialectLabel; +}; + +/// @brief Emits a flat `.sql` baseline that reproduces the post-fold schema and data. +/// +/// The emitted file: +/// 1. Header comment naming the dialect (`-- Dialect: PostgreSQL`). +/// 2. For each table in fold creation order: a `CREATE TABLE` rendered via +/// `ToSql(formatter, ...)`. +/// 3. For each surviving index: a `CREATE INDEX` rendered via `ToSql(...)`. +/// 4. Every data step rendered via `ToSql(...)` (INSERT / UPDATE / DELETE / RawSql). +/// 5. A trailing `INSERT INTO schema_migrations (...) VALUES ...` for every +/// fold-input timestamp so the post-fold DB looks identical to a real apply-all. +/// +/// The emitted SQL is dialect-specific to the chosen `formatter`. There is no +/// runtime guard against applying it to a non-empty `schema_migrations` — a flat +/// SQL script is the operator's tool, and the trailing inserts will fail loudly +/// with a primary-key conflict in that situation. +LIGHTWEIGHT_API void EmitSqlBaseline(FoldResult const& fold, SqlEmitOptions const& options); + +} // namespace Lightweight::MigrationFold diff --git a/src/Lightweight/SqlMigration.cpp b/src/Lightweight/SqlMigration.cpp index 458c5421..507903ab 100644 --- a/src/Lightweight/SqlMigration.cpp +++ b/src/Lightweight/SqlMigration.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 +#include "DataBinder/SqlVariant.hpp" #include "DataMapper/DataMapper.hpp" #include "QueryFormatter/SQLiteFormatter.hpp" #include "SqlBackup/Sha256.hpp" @@ -12,6 +13,8 @@ #include #include #include +#include +#include #include #include #include @@ -1097,4 +1100,293 @@ MigrationStatus MigrationManager::GetMigrationStatus() const return status; } +namespace +{ + /// @brief Builds the (catalog, schema, table) tuple used as a key in the fold result. + SqlSchema::FullyQualifiedTableName MakeFqtn(std::string_view schema, std::string_view table) + { + return SqlSchema::FullyQualifiedTableName { + .catalog = {}, + .schema = std::string(schema), + .table = std::string(table), + }; + } + + /// @brief Drops the FK whose local column matches `columnName` from `state`'s + /// composite-FK list. Single-column-FK declarations carried inline on a column + /// declaration (`SqlColumnDeclaration::foreignKey`) are NOT touched here — the + /// caller is responsible for clearing those when the column itself is dropped. + void DropFkByColumn(MigrationManager::PlanFoldingResult::TableState& state, std::string_view columnName) + { + std::erase_if(state.compositeForeignKeys, [&](SqlCompositeForeignKeyConstraint const& fk) { + return fk.columns.size() == 1 && fk.columns.front() == columnName; + }); + } + + /// @brief Removes any column declaration whose name matches `columnName` and the + /// FK constraints that reference it. Returns true if a column was actually removed. + bool RemoveColumn(MigrationManager::PlanFoldingResult::TableState& state, std::string_view columnName) + { + auto const before = state.columns.size(); + std::erase_if(state.columns, [&](SqlColumnDeclaration const& c) { return c.name == columnName; }); + DropFkByColumn(state, columnName); + return state.columns.size() != before; + } + + /// @brief Renames a column in `state.columns`, plus any FK declaration referencing + /// the old name. Inline FKs on the renamed column are preserved. + void RenameColumnInState(MigrationManager::PlanFoldingResult::TableState& state, + std::string_view oldName, + std::string_view newName) + { + for (auto& c: state.columns) + if (c.name == oldName) + c.name = std::string(newName); + for (auto& fk: state.compositeForeignKeys) + for (auto& col: fk.columns) + if (col == oldName) + col = std::string(newName); + } + + /// @brief Re-keys a table in `tables` and `creationOrder` from `oldName` → `newName`. + /// Updates inbound FK references in every other table so they continue to point at + /// the renamed table. Indexes hosted on the renamed table are also rewritten. + void RenameTableInResult(MigrationManager::PlanFoldingResult& result, + std::string_view schema, + std::string_view oldName, + std::string_view newName) + { + auto const oldKey = MakeFqtn(schema, oldName); + auto const newKey = MakeFqtn(schema, newName); + auto it = result.tables.find(oldKey); + if (it == result.tables.end()) + return; + auto state = std::move(it->second); + result.tables.erase(it); + result.tables.emplace(newKey, std::move(state)); + + for (auto& entry: result.creationOrder) + if (entry == oldKey) + entry = newKey; + + // Rewrite FK references in every table that points at oldName (composite FKs). + for (auto& [_, otherState]: result.tables) + { + for (auto& fk: otherState.compositeForeignKeys) + if (fk.referencedTableName == oldName) + fk.referencedTableName = std::string(newName); + for (auto& col: otherState.columns) + if (col.foreignKey && col.foreignKey->tableName == oldName) + col.foreignKey->tableName = std::string(newName); + } + + // Rewrite indexes hosted on the renamed table. + for (auto& idx: result.indexes) + if (idx.schemaName == schema && idx.tableName == oldName) + idx.tableName = std::string(newName); + } + + /// @brief Drops a table from the result and any side-effect references (indexes, + /// inbound FKs from other tables, queued data steps targeting the dropped table). + void DropTableFromResult(MigrationManager::PlanFoldingResult& result, + std::string_view schema, + std::string_view tableName) + { + auto const key = MakeFqtn(schema, tableName); + result.tables.erase(key); + std::erase(result.creationOrder, key); + + std::erase_if(result.indexes, + [&](SqlCreateIndexPlan const& idx) { return idx.schemaName == schema && idx.tableName == tableName; }); + + // Drop inbound FKs from other tables — and inline FK declarations. + for (auto& [_, state]: result.tables) + { + std::erase_if(state.compositeForeignKeys, + [&](SqlCompositeForeignKeyConstraint const& fk) { return fk.referencedTableName == tableName; }); + for (auto& c: state.columns) + if (c.foreignKey && c.foreignKey->tableName == tableName) + c.foreignKey.reset(); + } + + // Drop queued data steps targeting the dropped table. + std::erase_if(result.dataSteps, [&](MigrationManager::PlanFoldingResult::DataStep const& step) { + return std::visit( + ::Lightweight::detail::overloaded { + [&](SqlInsertDataPlan const& s) { return s.schemaName == schema && s.tableName == tableName; }, + [&](SqlUpdateDataPlan const& s) { return s.schemaName == schema && s.tableName == tableName; }, + [&](SqlDeleteDataPlan const& s) { return s.schemaName == schema && s.tableName == tableName; }, + [](auto const&) { return false; }, + }, + step.element); + }); + } + + /// @brief Apply one ALTER TABLE command to the fold's `TableState`. + void ApplyAlterCommand(MigrationManager::PlanFoldingResult::TableState& state, + MigrationManager::PlanFoldingResult& result, + std::string_view schema, + std::string_view tableName, + SqlAlterTableCommand const& cmd) + { + std::visit(::Lightweight::detail::overloaded { + [&](SqlAlterTableCommands::RenameTable const& c) { + RenameTableInResult(result, schema, tableName, c.newTableName); + }, + [&](SqlAlterTableCommands::AddColumn const& c) { + state.columns.push_back(SqlColumnDeclaration { + .name = c.columnName, + .type = c.columnType, + .required = c.nullable == SqlNullable::NotNull, + }); + }, + [&](SqlAlterTableCommands::AddColumnIfNotExists const& c) { + auto const exists = std::ranges::any_of( + state.columns, [&](SqlColumnDeclaration const& d) { return d.name == c.columnName; }); + if (!exists) + state.columns.push_back(SqlColumnDeclaration { + .name = c.columnName, + .type = c.columnType, + .required = c.nullable == SqlNullable::NotNull, + }); + }, + [&](SqlAlterTableCommands::AlterColumn const& c) { + for (auto& d: state.columns) + { + if (d.name == c.columnName) + { + d.type = c.columnType; + d.required = c.nullable == SqlNullable::NotNull; + } + } + }, + [&](SqlAlterTableCommands::RenameColumn const& c) { + RenameColumnInState(state, c.oldColumnName, c.newColumnName); + }, + [&](SqlAlterTableCommands::DropColumn const& c) { (void) RemoveColumn(state, c.columnName); }, + [&](SqlAlterTableCommands::DropColumnIfExists const& c) { (void) RemoveColumn(state, c.columnName); }, + [&](SqlAlterTableCommands::AddIndex const& c) { + result.indexes.push_back(SqlCreateIndexPlan { + .schemaName = std::string(schema), + .indexName = std::format("idx_{}_{}", tableName, c.columnName), + .tableName = std::string(tableName), + .columns = { std::string(c.columnName) }, + .unique = c.unique, + }); + }, + [&](SqlAlterTableCommands::DropIndex const& c) { + std::erase_if(result.indexes, [&](SqlCreateIndexPlan const& i) { + return i.schemaName == schema && i.tableName == tableName && i.columns.size() == 1 + && i.columns.front() == c.columnName; + }); + }, + [&](SqlAlterTableCommands::DropIndexIfExists const& c) { + std::erase_if(result.indexes, [&](SqlCreateIndexPlan const& i) { + return i.schemaName == schema && i.tableName == tableName && i.columns.size() == 1 + && i.columns.front() == c.columnName; + }); + }, + [&](SqlAlterTableCommands::AddForeignKey const& c) { + // Promote single-column FK to composite list — same logical + // shape, simpler to fold across renames/drops. + state.compositeForeignKeys.push_back(SqlCompositeForeignKeyConstraint { + .columns = { c.columnName }, + .referencedTableName = c.referencedColumn.tableName, + .referencedColumns = { c.referencedColumn.columnName }, + }); + }, + [&](SqlAlterTableCommands::AddCompositeForeignKey const& c) { + state.compositeForeignKeys.push_back(SqlCompositeForeignKeyConstraint { + .columns = c.columns, + .referencedTableName = c.referencedTableName, + .referencedColumns = c.referencedColumns, + }); + }, + [&](SqlAlterTableCommands::DropForeignKey const& c) { DropFkByColumn(state, c.columnName); }, + }, + cmd); + } +} // namespace + +MigrationManager::PlanFoldingResult MigrationManager::FoldRegisteredMigrations( + SqlQueryFormatter const& formatter, std::optional upToInclusive) const +{ + PlanFoldingResult result; + + // Walk migrations in timestamp order (already sorted by `AddMigration`). + for (auto const* migration: _migrations) + { + if (upToInclusive.has_value() && migration->GetTimestamp() > *upToInclusive) + break; + + result.foldedMigrations.emplace_back(migration->GetTimestamp(), std::string(migration->GetTitle())); + + SqlMigrationQueryBuilder builder { formatter }; + migration->Up(builder); + SqlMigrationPlan plan = std::move(builder).GetPlan(); + + for (SqlMigrationPlanElement const& step: plan.steps) + { + std::visit(::Lightweight::detail::overloaded { + [&](SqlCreateTablePlan const& s) { + auto const key = MakeFqtn(s.schemaName, s.tableName); + auto [it, inserted] = result.tables.try_emplace(key); + if (inserted) + result.creationOrder.push_back(key); + it->second.columns = s.columns; + it->second.compositeForeignKeys = s.foreignKeys; + it->second.ifNotExists = s.ifNotExists; + }, + [&](SqlAlterTablePlan const& s) { + auto const key = MakeFqtn(s.schemaName, s.tableName); + auto it = result.tables.find(key); + if (it == result.tables.end()) + return; + for (auto const& cmd: s.commands) + ApplyAlterCommand(it->second, result, s.schemaName, s.tableName, cmd); + }, + [&](SqlDropTablePlan const& s) { DropTableFromResult(result, s.schemaName, s.tableName); }, + [&](SqlCreateIndexPlan const& s) { result.indexes.push_back(s); }, + [&](SqlInsertDataPlan const& s) { + result.dataSteps.push_back(PlanFoldingResult::DataStep { + .sourceTimestamp = migration->GetTimestamp(), + .sourceTitle = std::string(migration->GetTitle()), + .element = s, + }); + }, + [&](SqlUpdateDataPlan const& s) { + result.dataSteps.push_back(PlanFoldingResult::DataStep { + .sourceTimestamp = migration->GetTimestamp(), + .sourceTitle = std::string(migration->GetTitle()), + .element = s, + }); + }, + [&](SqlDeleteDataPlan const& s) { + result.dataSteps.push_back(PlanFoldingResult::DataStep { + .sourceTimestamp = migration->GetTimestamp(), + .sourceTitle = std::string(migration->GetTitle()), + .element = s, + }); + }, + [&](SqlRawSqlPlan const& s) { + result.dataSteps.push_back(PlanFoldingResult::DataStep { + .sourceTimestamp = migration->GetTimestamp(), + .sourceTitle = std::string(migration->GetTitle()), + .element = s, + }); + }, + }, + step); + } + } + + // Releases that fall within the fold range. + auto const cutoff = upToInclusive.value_or(MigrationTimestamp { std::numeric_limits::max() }); + for (auto const& release: _releases) + if (release.highestTimestamp <= cutoff) + result.releases.push_back(release); + + return result; +} + } // namespace Lightweight::SqlMigration diff --git a/src/Lightweight/SqlMigration.hpp b/src/Lightweight/SqlMigration.hpp index 4853deb8..66cf94b4 100644 --- a/src/Lightweight/SqlMigration.hpp +++ b/src/Lightweight/SqlMigration.hpp @@ -5,10 +5,12 @@ #include "Api.hpp" #include "DataMapper/DataMapper.hpp" #include "SqlQuery/Migrate.hpp" +#include "SqlSchema.hpp" #include "SqlTransaction.hpp" #include #include +#include #include #include @@ -297,6 +299,82 @@ namespace SqlMigration /// @return Migrations in the release, ordered by timestamp. Empty if the version is unknown. [[nodiscard]] LIGHTWEIGHT_API MigrationList GetMigrationsForRelease(std::string_view version) const; + /// @brief Snapshot of the schema the registered migrations *intend* to produce. + /// + /// Pure plan-walk — never executes SQL, never opens a connection. Folds the + /// effects of every registered migration (up to an optional cut-off timestamp) + /// into a per-table view of "the final shape" plus a chronological list of + /// data steps and surviving indexes/releases. Used by `dbtool fold` to emit a + /// self-contained baseline (`.cpp` plugin or `.sql`). + struct PlanFoldingResult + { + /// Per-table state: ordered column declarations + per-table FK list. + struct TableState + { + /// Ordered column declarations as they should appear in the emitted CREATE TABLE. + std::vector columns; + + /// Composite (multi-column) foreign keys declared on this table. + std::vector compositeForeignKeys; + + /// True when the original migration created the table with `IF NOT EXISTS`. + bool ifNotExists = false; + }; + + /// (schema, table) → folded `TableState`. Insertion order is *not* preserved by + /// `std::map` — for emission order use `creationOrder` below. + std::map tables; + + /// Tables in *creation* order (first-time-seen). Reverse for safe DROP ordering + /// when tearing the schema down. + std::vector creationOrder; + + /// Indexes that survive folding (created on tables still present at end). + std::vector indexes; + + /// One data step (INSERT/UPDATE/DELETE/RawSql) tagged with its source migration. + struct DataStep + { + /// Timestamp of the migration that contributed this data step. + MigrationTimestamp sourceTimestamp; + + /// Title of the migration that contributed this data step. + std::string sourceTitle; + + /// The plan element to replay (INSERT/UPDATE/DELETE/RawSql). + SqlMigrationPlanElement element; + }; + + /// Data steps in chronological order. **No coalescing** — the fold replays + /// every data step verbatim, exactly as if migrations were applied in order. + std::vector dataSteps; + + /// Releases declared via `LIGHTWEIGHT_SQL_RELEASE` that fall within the fold range. + std::vector releases; + + /// Migrations that contributed to the fold (timestamp + title). Used by emitters + /// to write a header comment explaining what was collapsed. + std::vector> foldedMigrations; + }; + + /// @brief Pure plan-walk that folds the effect of every registered migration. + /// + /// Visits each migration's `Up()` plan in timestamp order (or up to + /// `upToInclusive` if provided) and accumulates the cumulative end-state into a + /// `PlanFoldingResult`. **Never** executes SQL or touches a database connection + /// — the supplied formatter is only used to build the in-memory plan elements. + /// + /// @param formatter Formatter used by the migration query builder while walking + /// each migration's `Up()`. Any standard formatter works; the + /// walk inspects plan element shapes, not rendered SQL. + /// @param upToInclusive If set, fold only migrations with `timestamp <= upToInclusive`. + /// If unset, fold all registered migrations. + /// + /// @return The folded snapshot. Safe to call without a `MigrationManager`-attached + /// data mapper. + [[nodiscard]] LIGHTWEIGHT_API PlanFoldingResult FoldRegisteredMigrations( + SqlQueryFormatter const& formatter, std::optional upToInclusive = std::nullopt) const; + private: /// Return the pending list in dependency-respecting order. /// diff --git a/src/tests/MigrationTests.cpp b/src/tests/MigrationTests.cpp index 717ee768..da05cbdd 100644 --- a/src/tests/MigrationTests.cpp +++ b/src/tests/MigrationTests.cpp @@ -2,12 +2,19 @@ #include "Utils.hpp" +#include #include +#include +#include +#include #include #include #include +#include +#include + using namespace std::string_view_literals; namespace std @@ -1155,3 +1162,371 @@ TEST_CASE_METHOD(SqlMigrationTestFixture, "RollbackToRelease semantics via Rever CHECK(appliedIds.size() == 2); CHECK(appliedIds.back().value == 202810020000); } + +// ============================================================================ +// Tests for FoldRegisteredMigrations and the MigrationFold emitters +// ============================================================================ + +namespace fold_test +{ + +using namespace Lightweight::SqlColumnTypeDefinitions; + +/// @brief Test migration that takes a Up-builder closure and a timestamp. Used in +/// fold-only tests to register varied migration shapes in a single test case. +template +class FoldStub: public SqlMigration::MigrationBase +{ + public: + using BuildFn = std::function; + + FoldStub(std::string_view title, BuildFn build): + MigrationBase(SqlMigration::MigrationTimestamp { Ts }, title), + _build(std::move(build)) + { + } + + void Up(SqlMigrationQueryBuilder& builder) const override + { + _build(builder); + } + + private: + BuildFn _build; +}; + +} // namespace fold_test + +TEST_CASE_METHOD(SqlMigrationTestFixture, "Fold: create + addcolumn + altercolumn", "[SqlMigration][Fold]") +{ + using namespace Lightweight::SqlColumnTypeDefinitions; + auto& mgr = SqlMigration::MigrationManager::GetInstance(); + + fold_test::FoldStub<20'10'01'00'00'01> m1 { + "create users", + [](SqlMigrationQueryBuilder& plan) { + plan.CreateTable("users").PrimaryKey("id", Bigint()).Column("name", Varchar(100)); + } + }; + fold_test::FoldStub<20'10'01'00'00'02> m2 { "add email", [](SqlMigrationQueryBuilder& plan) { + plan.AlterTable("users").AddColumn("email", Varchar(255)); + } }; + fold_test::FoldStub<20'10'01'00'00'03> m3 { "widen name", [](SqlMigrationQueryBuilder& plan) { + plan.AlterTable("users").AlterColumn( + "name", NVarchar(200), SqlNullable::Null); + } }; + + auto const fold = mgr.FoldRegisteredMigrations(SqlQueryFormatter::Sqlite()); + + REQUIRE(fold.foldedMigrations.size() == 3); + REQUIRE(fold.tables.size() == 1); + REQUIRE(fold.creationOrder.size() == 1); + + auto const it = fold.tables.find(SqlSchema::FullyQualifiedTableName { .catalog = {}, .schema = {}, .table = "users" }); + REQUIRE(it != fold.tables.end()); + auto const& state = it->second; + + REQUIRE(state.columns.size() == 3); + CHECK(state.columns[0].name == "id"); + CHECK(state.columns[1].name == "name"); + CHECK(state.columns[2].name == "email"); + + // The 'name' column was widened from Varchar(100) → NVarchar(200) by m3. + auto const* nv = std::get_if(&state.columns[1].type); + REQUIRE(nv != nullptr); + CHECK(nv->size == 200); +} + +TEST_CASE_METHOD(SqlMigrationTestFixture, "Fold: drop table cleans residual references", "[SqlMigration][Fold]") +{ + using namespace Lightweight::SqlColumnTypeDefinitions; + auto& mgr = SqlMigration::MigrationManager::GetInstance(); + + fold_test::FoldStub<20'10'02'00'00'01> m1 { "create temp", [](SqlMigrationQueryBuilder& plan) { + plan.CreateTable("temp_table").PrimaryKey("id", Bigint()); + } }; + fold_test::FoldStub<20'10'02'00'00'02> m2 { "create idx on temp", [](SqlMigrationQueryBuilder& plan) { + plan.CreateIndex("idx_temp_id", "temp_table", { "id" }); + } }; + fold_test::FoldStub<20'10'02'00'00'03> m3 { "drop temp", + [](SqlMigrationQueryBuilder& plan) { plan.DropTable("temp_table"); } }; + + auto const fold = mgr.FoldRegisteredMigrations(SqlQueryFormatter::Sqlite()); + + CHECK(fold.tables.empty()); + CHECK(fold.creationOrder.empty()); + CHECK(fold.indexes.empty()); +} + +TEST_CASE_METHOD(SqlMigrationTestFixture, "Fold: data steps preserve chronological order", "[SqlMigration][Fold]") +{ + using namespace Lightweight::SqlColumnTypeDefinitions; + auto& mgr = SqlMigration::MigrationManager::GetInstance(); + + fold_test::FoldStub<20'10'03'00'00'01> m1 { + "create + first insert", + [](SqlMigrationQueryBuilder& plan) { + plan.CreateTable("logs").PrimaryKey("id", Bigint()).Column("msg", Varchar(255)); + plan.Insert("logs").Set("msg", "first"sv); + } + }; + fold_test::FoldStub<20'10'03'00'00'02> m2 { "second insert", [](SqlMigrationQueryBuilder& plan) { + plan.Insert("logs").Set("msg", "second"sv); + } }; + + auto const fold = mgr.FoldRegisteredMigrations(SqlQueryFormatter::Sqlite()); + + REQUIRE(fold.dataSteps.size() == 2); + CHECK(fold.dataSteps[0].sourceTimestamp.value == 20'10'03'00'00'01ULL); + CHECK(fold.dataSteps[1].sourceTimestamp.value == 20'10'03'00'00'02ULL); +} + +TEST_CASE_METHOD(SqlMigrationTestFixture, "Fold: --up-to truncates correctly", "[SqlMigration][Fold]") +{ + using namespace Lightweight::SqlColumnTypeDefinitions; + auto& mgr = SqlMigration::MigrationManager::GetInstance(); + + fold_test::FoldStub<20'10'04'00'00'01> m1 { "v1", [](SqlMigrationQueryBuilder& plan) { + plan.CreateTable("a").PrimaryKey("id", Bigint()); + } }; + fold_test::FoldStub<20'10'04'00'00'02> m2 { "v2", [](SqlMigrationQueryBuilder& plan) { + plan.CreateTable("b").PrimaryKey("id", Bigint()); + } }; + fold_test::FoldStub<20'10'04'00'00'03> m3 { "v3", [](SqlMigrationQueryBuilder& plan) { + plan.CreateTable("c").PrimaryKey("id", Bigint()); + } }; + + auto const cutoff = SqlMigration::MigrationTimestamp { 20'10'04'00'00'02ULL }; + auto const fold = mgr.FoldRegisteredMigrations(SqlQueryFormatter::Sqlite(), cutoff); + + CHECK(fold.foldedMigrations.size() == 2); + CHECK(fold.tables.size() == 2); + CHECK(fold.tables.contains(SqlSchema::FullyQualifiedTableName { .catalog = {}, .schema = {}, .table = "a" })); + CHECK(fold.tables.contains(SqlSchema::FullyQualifiedTableName { .catalog = {}, .schema = {}, .table = "b" })); + CHECK_FALSE(fold.tables.contains(SqlSchema::FullyQualifiedTableName { .catalog = {}, .schema = {}, .table = "c" })); +} + +TEST_CASE_METHOD(SqlMigrationTestFixture, "Fold: RawSql passes through unmodified", "[SqlMigration][Fold]") +{ + using namespace Lightweight::SqlColumnTypeDefinitions; + auto& mgr = SqlMigration::MigrationManager::GetInstance(); + + fold_test::FoldStub<20'10'05'00'00'01> m1 { "raw", [](SqlMigrationQueryBuilder& plan) { + plan.RawSql("PRAGMA foreign_keys = ON"); + } }; + + auto const fold = mgr.FoldRegisteredMigrations(SqlQueryFormatter::Sqlite()); + + REQUIRE(fold.dataSteps.size() == 1); + auto const* raw = std::get_if(&fold.dataSteps[0].element); + REQUIRE(raw != nullptr); + CHECK(raw->sql == "PRAGMA foreign_keys = ON"); +} + +TEST_CASE_METHOD(SqlMigrationTestFixture, "Fold: rename column propagates to FK references", "[SqlMigration][Fold]") +{ + using namespace Lightweight::SqlColumnTypeDefinitions; + auto& mgr = SqlMigration::MigrationManager::GetInstance(); + + fold_test::FoldStub<20'10'06'00'00'01> m1 { "create base", [](SqlMigrationQueryBuilder& plan) { + plan.CreateTable("users").PrimaryKey("id", Bigint()); + } }; + fold_test::FoldStub<20'10'06'00'00'02> m2 { "rename column", [](SqlMigrationQueryBuilder& plan) { + plan.AlterTable("users").RenameColumn("id", "user_id"); + } }; + + auto const fold = mgr.FoldRegisteredMigrations(SqlQueryFormatter::Sqlite()); + auto const it = fold.tables.find(SqlSchema::FullyQualifiedTableName { .catalog = {}, .schema = {}, .table = "users" }); + REQUIRE(it != fold.tables.end()); + REQUIRE(it->second.columns.size() == 1); + CHECK(it->second.columns[0].name == "user_id"); +} + +TEST_CASE_METHOD(SqlMigrationTestFixture, "Fold: respects releases falling within range", "[SqlMigration][Fold]") +{ + using namespace Lightweight::SqlColumnTypeDefinitions; + auto& mgr = SqlMigration::MigrationManager::GetInstance(); + + fold_test::FoldStub<20'10'07'00'00'01> m1 { "release-1 migration", [](SqlMigrationQueryBuilder& plan) { + plan.CreateTable("a").PrimaryKey("id", Bigint()); + } }; + mgr.RegisterRelease("1.0.0", SqlMigration::MigrationTimestamp { 20'10'07'00'00'01ULL }); + + fold_test::FoldStub<20'10'07'00'00'02> m2 { "release-2 migration", [](SqlMigrationQueryBuilder& plan) { + plan.CreateTable("b").PrimaryKey("id", Bigint()); + } }; + mgr.RegisterRelease("2.0.0", SqlMigration::MigrationTimestamp { 20'10'07'00'00'02ULL }); + + auto const fold = + mgr.FoldRegisteredMigrations(SqlQueryFormatter::Sqlite(), SqlMigration::MigrationTimestamp { 20'10'07'00'00'01ULL }); + + REQUIRE(fold.releases.size() == 1); + CHECK(fold.releases[0].version == "1.0.0"); +} + +// ============================================================================ +// SplitFileWriter unit tests +// ============================================================================ + +TEST_CASE("SplitFileWriter: bin-packs blocks within budget", "[CodeGen][SplitFileWriter]") +{ + using namespace Lightweight::CodeGen; + + std::vector blocks { + { .content = "A\n", .lineCount = 1 }, + { .content = "B\nB\n", .lineCount = 2 }, + { .content = "C\n", .lineCount = 1 }, + { .content = "D\nD\nD\n", .lineCount = 3 }, + }; + auto const chunks = GroupBlocksByLineBudget(blocks, 3); + REQUIRE(chunks.size() >= 2); + for (auto const& chunk: chunks) + CHECK_FALSE(chunk.empty()); +} + +TEST_CASE("SplitFileWriter: emits single chunk when total fits", "[CodeGen][SplitFileWriter]") +{ + using namespace Lightweight::CodeGen; + std::vector blocks { + { .content = "A\n", .lineCount = 1 }, + { .content = "B\n", .lineCount = 1 }, + }; + auto const chunks = GroupBlocksByLineBudget(blocks, 100); + REQUIRE(chunks.size() == 1); + CHECK(chunks[0].size() == 2); +} + +TEST_CASE("SplitFileWriter: maxLinesPerFile=0 keeps everything in one chunk", "[CodeGen][SplitFileWriter]") +{ + using namespace Lightweight::CodeGen; + std::vector blocks { + { .content = "x\n", .lineCount = 1 }, + { .content = "y\n", .lineCount = 1 }, + { .content = "z\n", .lineCount = 1 }, + }; + auto const chunks = GroupBlocksByLineBudget(blocks, 0); + REQUIRE(chunks.size() == 1); + CHECK(chunks[0].size() == 3); +} + +TEST_CASE("SplitFileWriter: oversize block lands wholly in its own chunk", "[CodeGen][SplitFileWriter]") +{ + using namespace Lightweight::CodeGen; + std::vector blocks { + { .content = "small\n", .lineCount = 1 }, + { .content = "huge\nhuge\nhuge\nhuge\nhuge\nhuge\nhuge\nhuge\nhuge\n", .lineCount = 9 }, + { .content = "tail\n", .lineCount = 1 }, + }; + auto const chunks = GroupBlocksByLineBudget(blocks, 3); + // The huge block exceeds the budget but cannot be split — it must occupy its own + // chunk while small/tail can land elsewhere. + REQUIRE(chunks.size() >= 2); + bool foundSoloHuge = false; + for (auto const& chunk: chunks) + if (chunk.size() == 1 && chunk[0].lineCount == 9) + foundSoloHuge = true; + CHECK(foundSoloHuge); +} + +// ============================================================================ +// Fold + SqlEmitter / CppEmitter round-trip tests +// ============================================================================ + +TEST_CASE_METHOD(SqlMigrationTestFixture, + "Fold + SqlEmitter: emits CREATE TABLE for every folded table", + "[SqlMigration][Fold][SqlEmitter]") +{ + using namespace Lightweight::SqlColumnTypeDefinitions; + auto& mgr = SqlMigration::MigrationManager::GetInstance(); + + fold_test::FoldStub<20'10'13'00'00'01> m1 { + "create A", + [](SqlMigrationQueryBuilder& plan) { + plan.CreateTable("alpha").PrimaryKey("id", Bigint()).Column("name", Varchar(50)); + } + }; + fold_test::FoldStub<20'10'13'00'00'02> m2 { + "create B", + [](SqlMigrationQueryBuilder& plan) { + plan.CreateTable("beta").PrimaryKey("id", Bigint()).Column("note", Varchar(80)); + } + }; + + auto const fold = mgr.FoldRegisteredMigrations(SqlQueryFormatter::Sqlite()); + + auto const tmp = std::filesystem::temp_directory_path() / "lightweight_fold_test.sql"; + { + Lightweight::MigrationFold::SqlEmitOptions opts { + .outputPath = tmp, + .formatter = &SqlQueryFormatter::Sqlite(), + .dialectLabel = "SQLite", + }; + Lightweight::MigrationFold::EmitSqlBaseline(fold, opts); + } + + // Verify the emitted .sql contains both table names. + std::ifstream in(tmp); + REQUIRE(in.is_open()); + std::string content { (std::istreambuf_iterator(in)), std::istreambuf_iterator() }; + CHECK(content.contains(R"("alpha")")); + CHECK(content.contains(R"("beta")")); + CHECK(content.contains("CREATE TABLE")); + CHECK(content.contains("-- Dialect: SQLite")); + in.close(); + std::filesystem::remove(tmp); +} + +TEST_CASE_METHOD(SqlMigrationTestFixture, + "Fold + CppEmitter: emits LIGHTWEIGHT_SQL_MIGRATION wrapper", + "[SqlMigration][Fold][CppEmitter]") +{ + using namespace Lightweight::SqlColumnTypeDefinitions; + auto& mgr = SqlMigration::MigrationManager::GetInstance(); + + fold_test::FoldStub<20'10'14'00'00'01> m1 { + "users", + [](SqlMigrationQueryBuilder& plan) { + plan.CreateTable("users").PrimaryKey("id", Bigint()).Column("email", NVarchar(100)); + } + }; + + auto const fold = mgr.FoldRegisteredMigrations(SqlQueryFormatter::Sqlite()); + + auto const tmp = std::filesystem::temp_directory_path() / "lightweight_fold_test.cpp"; + { + Lightweight::MigrationFold::CppEmitOptions opts { + .outputPath = tmp, + .maxLinesPerFile = 0, + .pluginName = {}, + .emitCmake = false, + }; + Lightweight::MigrationFold::EmitCppBaseline(fold, opts); + } + std::ifstream in(tmp); + REQUIRE(in.is_open()); + std::string content { (std::istreambuf_iterator(in)), std::istreambuf_iterator() }; + CHECK(content.contains("LIGHTWEIGHT_SQL_MIGRATION")); + CHECK(content.contains("CreateTable(\"users\")")); + in.close(); + std::filesystem::remove(tmp); +} + +TEST_CASE_METHOD(SqlMigrationTestFixture, "Fold: ResolveUpTo by version", "[SqlMigration][Fold]") +{ + using namespace Lightweight::SqlColumnTypeDefinitions; + auto& mgr = SqlMigration::MigrationManager::GetInstance(); + + fold_test::FoldStub<20'10'15'00'00'01> m1 { "v1 mig", [](SqlMigrationQueryBuilder& plan) { + plan.CreateTable("a").PrimaryKey("id", Bigint()); + } }; + mgr.RegisterRelease("1.0.0", SqlMigration::MigrationTimestamp { 20'10'15'00'00'01ULL }); + + fold_test::FoldStub<20'10'15'00'00'02> m2 { "v2 mig", [](SqlMigrationQueryBuilder& plan) { + plan.CreateTable("b").PrimaryKey("id", Bigint()); + } }; + + CHECK(Lightweight::MigrationFold::ResolveUpTo(mgr, "1.0.0").value == 20'10'15'00'00'01ULL); + CHECK(Lightweight::MigrationFold::ResolveUpTo(mgr, "201015000002").value == 20'10'15'00'00'02ULL); + CHECK(Lightweight::MigrationFold::ResolveUpTo(mgr, "").value == 20'10'15'00'00'01ULL); + CHECK_THROWS(Lightweight::MigrationFold::ResolveUpTo(mgr, "nonexistent")); +} diff --git a/src/tools/dbtool/main.cpp b/src/tools/dbtool/main.cpp index 1e5ba7f2..5f8f4640 100644 --- a/src/tools/dbtool/main.cpp +++ b/src/tools/dbtool/main.cpp @@ -6,16 +6,24 @@ #include "StandardProgressManager.hpp" #include +#include +#include +#include #include #include #include #include +#include #include +#include +#include +#include #include #include #include #include +#include #include #include #include @@ -128,6 +136,9 @@ void PrintUsage() c.command, c.reset, c.param, c.reset); std::println(" {}backup{} --output FILE Backs up the database to a file", c.command, c.reset); std::println(" {}restore{} --input FILE Restores the database from a file", c.command, c.reset); + std::println(" {}fold{} --output FILE Emits a baseline (.cpp plugin or .sql script) reproducing", + c.command, c.reset); + std::println(" the post-migration state from an empty DB"); std::println(""); // Descriptions start at column 29 (longest option is 27 chars + 2 space minimum gap) @@ -262,6 +273,18 @@ struct Options bool dryRun = false; ///< If true, show what would be done without actually doing it bool noLock = false; ///< If true, skip migration locking for write operations bool schemaOnly = false; ///< If true, backup/restore schema only (no data) + + /// @brief `--up-to ` for `fold`. Empty = default to latest registered release. + std::string upTo; + /// @brief `--max-lines-per-file ` for `fold` (cpp output split). 0 disables splitting. + std::size_t foldMaxLinesPerFile = 5000; + /// @brief `--emit-cmake` flag for `fold` cpp output. + bool foldEmitCmake = false; + /// @brief `--plugin-name ` used by `--emit-cmake`. + std::string foldPluginName; + /// @brief `--dialect ` for `fold` sql output. Required when output is `.sql`, + /// rejected when output is `.cpp` (plugins defer rendering to the loading backend). + std::string foldDialect; }; std::filesystem::path GetDefaultConfigPath() @@ -406,6 +429,34 @@ std::expected ParseArguments(int argc, char** argv) return std::unexpected { "Error: --input requires an argument" }; options.inputFile = argv[++i]; } + else if (arg == "--up-to") + { + if (i + 1 >= argc) + return std::unexpected { "Error: --up-to requires an argument" }; + options.upTo = argv[++i]; + } + else if (arg == "--max-lines-per-file") + { + if (i + 1 >= argc) + return std::unexpected { "Error: --max-lines-per-file requires an argument" }; + options.foldMaxLinesPerFile = static_cast(std::stoull(argv[++i])); + } + else if (arg == "--emit-cmake") + { + options.foldEmitCmake = true; + } + else if (arg == "--plugin-name") + { + if (i + 1 >= argc) + return std::unexpected { "Error: --plugin-name requires an argument" }; + options.foldPluginName = argv[++i]; + } + else if (arg == "--dialect") + { + if (i + 1 >= argc) + return std::unexpected { "Error: --dialect requires an argument" }; + options.foldDialect = argv[++i]; + } else if (arg == "--jobs") { if (i + 1 >= argc) @@ -1523,6 +1574,169 @@ MigrationManager& GetMigrationManager(Options const& options) return manager; } +/// @brief Connection-less variant of `GetMigrationManager` for `dbtool fold`. +/// +/// Identical lifetime contract: the static plugins vector outlives the singleton. +/// Differs from `GetMigrationManager` only by skipping the `CreateMigrationHistory()` +/// call (no DB connection to write to). +MigrationManager& GetMigrationManagerOffline(Options const& options) +{ + static std::vector plugins; + static bool initialized = false; + + auto& manager = MigrationManager::GetInstance(); + if (!initialized) + { + plugins = LoadPlugins(options.pluginsDir); + CollectMigrations(plugins, manager); + initialized = true; + } + return manager; +} + +[[nodiscard]] std::expected ResolveDialect(std::string raw) +{ + std::ranges::transform( + raw, raw.begin(), [](char c) { return static_cast(std::tolower(static_cast(c))); }); + if (raw == "sqlite" || raw == "sqlite3") + return SqlServerType::SQLITE; + if (raw == "postgres" || raw == "postgresql" || raw == "pg") + return SqlServerType::POSTGRESQL; + if (raw == "mssql" || raw == "sqlserver" || raw == "ms-sql") + return SqlServerType::MICROSOFT_SQL; + if (raw == "mysql") + return SqlServerType::MYSQL; + return std::unexpected(std::format("Unknown dialect '{}'. Accepted: sqlite, postgres, mssql, mysql.", raw)); +} + +[[nodiscard]] std::string_view DialectLabel(SqlServerType type) +{ + switch (type) + { + case SqlServerType::SQLITE: + return "SQLite"; + case SqlServerType::POSTGRESQL: + return "PostgreSQL"; + case SqlServerType::MICROSOFT_SQL: + return "Microsoft SQL Server"; + case SqlServerType::MYSQL: + return "MySQL"; + case SqlServerType::UNKNOWN: + return "Unknown"; + } + return "Unknown"; +} + +/// @brief `dbtool fold` command — pure offline transformation, no DB connection. +/// +/// Output format is picked from the file extension: `.cpp` emits a baseline plugin, +/// `.sql` emits a flat dialect-specific script. `--dialect` is required for `.sql` +/// and rejected for `.cpp`. +int Fold(MigrationManager& manager, Options const& options) +{ + if (options.outputFile.empty()) + { + std::println(std::cerr, "Error: --output is required for fold."); + return EXIT_FAILURE; + } + auto const ext = options.outputFile.extension().string(); + auto const isCpp = ext == ".cpp"; + auto const isSql = ext == ".sql"; + if (!isCpp && !isSql) + { + std::println(std::cerr, "Error: --output must end in .cpp or .sql (got '{}')", ext); + return EXIT_FAILURE; + } + + SqlMigration::MigrationTimestamp upTo { std::numeric_limits::max() }; + try + { + upTo = MigrationFold::ResolveUpTo(manager, options.upTo); + } + catch (std::exception const& ex) + { + std::println(std::cerr, "Error: {}", ex.what()); + return EXIT_FAILURE; + } + + if (isCpp) + { + if (!options.foldDialect.empty()) + { + std::println( + std::cerr, + "Error: --dialect is not allowed for .cpp output (plugins defer rendering to the loading backend)."); + return EXIT_FAILURE; + } + // Fold uses the SQLite formatter purely for in-memory plan-walking; the + // emitted .cpp plugin is dialect-agnostic. + auto const fold = MigrationFold::Fold(manager, SqlQueryFormatter::Sqlite(), upTo); + MigrationFold::CppEmitOptions opts { + .outputPath = options.outputFile, + .maxLinesPerFile = options.foldMaxLinesPerFile, + .pluginName = options.foldPluginName, + .emitCmake = options.foldEmitCmake, + }; + try + { + MigrationFold::EmitCppBaseline(fold, opts); + } + catch (std::exception const& ex) + { + std::println(std::cerr, "Error emitting cpp baseline: {}", ex.what()); + return EXIT_FAILURE; + } + std::println("Folded {} migration(s) → {}", fold.foldedMigrations.size(), options.outputFile.string()); + return EXIT_SUCCESS; + } + + // .sql path: --dialect required, --emit-cmake/--max-lines-per-file rejected. + if (options.foldDialect.empty()) + { + std::println(std::cerr, + "Error: --dialect is required for .sql output. " + "Accepted: sqlite, postgres, mssql, mysql."); + return EXIT_FAILURE; + } + if (options.foldEmitCmake) + { + std::println(std::cerr, "Error: --emit-cmake is only valid for .cpp output."); + return EXIT_FAILURE; + } + + auto const dialectResult = ResolveDialect(options.foldDialect); + if (!dialectResult) + { + std::println(std::cerr, "Error: {}", dialectResult.error()); + return EXIT_FAILURE; + } + auto const* formatter = SqlQueryFormatter::Get(*dialectResult); + if (!formatter) + { + std::println(std::cerr, "Error: dialect '{}' has no formatter implementation.", options.foldDialect); + return EXIT_FAILURE; + } + + auto const fold = MigrationFold::Fold(manager, *formatter, upTo); + + MigrationFold::SqlEmitOptions opts { + .outputPath = options.outputFile, + .formatter = formatter, + .dialectLabel = DialectLabel(*dialectResult), + }; + try + { + MigrationFold::EmitSqlBaseline(fold, opts); + } + catch (std::exception const& ex) + { + std::println(std::cerr, "Error emitting sql baseline: {}", ex.what()); + return EXIT_FAILURE; + } + std::println("Folded {} migration(s) → {}", fold.foldedMigrations.size(), options.outputFile.string()); + return EXIT_SUCCESS; +} + } // namespace int main(int argc, char** argv) @@ -1570,6 +1784,11 @@ int main(int argc, char** argv) return EXIT_SUCCESS; } + // `fold` is a pure offline plan-walk — never touches a DB. Dispatch before + // SetupConnectionString so it doesn't require a connection string at all. + if (options.command == "fold") + return Fold(GetMigrationManagerOffline(options), options); + if (!SetupConnectionString(options.connectionString)) return EXIT_FAILURE;