Skip to content

Commit d8bf4e2

Browse files
[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 <christian@parpart.family>
1 parent b2ac956 commit d8bf4e2

35 files changed

Lines changed: 2134 additions & 163 deletions

src/Lightweight/CMakeLists.txt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ set(HEADER_FILES
6363
SqlQuery/Select.hpp
6464
SqlQuery/Update.hpp
6565

66+
CodeGen/SplitFileWriter.hpp
67+
68+
MigrationFold/Folder.hpp
69+
MigrationFold/CppEmitter.hpp
70+
MigrationFold/SqlEmitter.hpp
71+
6672
DataMapper/BelongsTo.hpp
6773
DataMapper/DataMapper.hpp
6874
DataMapper/Error.hpp
@@ -125,6 +131,12 @@ set(SOURCE_FILES
125131
SqlQuery/Migrate.cpp
126132
SqlQuery/MigrationPlan.cpp
127133
SqlQuery/Select.cpp
134+
135+
CodeGen/SplitFileWriter.cpp
136+
137+
MigrationFold/Folder.cpp
138+
MigrationFold/CppEmitter.cpp
139+
MigrationFold/SqlEmitter.cpp
128140
SqlQueryFormatter.cpp
129141
SqlSchema.cpp
130142
SqlStatement.cpp
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
#include "SplitFileWriter.hpp"
4+
5+
#include <format>
6+
#include <fstream>
7+
#include <stdexcept>
8+
#include <system_error>
9+
10+
namespace Lightweight::CodeGen
11+
{
12+
13+
std::vector<std::vector<CodeBlock>> GroupBlocksByLineBudget(std::vector<CodeBlock> const& blocks,
14+
std::size_t maxLinesPerFile)
15+
{
16+
std::vector<std::vector<CodeBlock>> chunks;
17+
if (blocks.empty())
18+
return chunks;
19+
20+
if (maxLinesPerFile == 0)
21+
{
22+
chunks.emplace_back(blocks);
23+
return chunks;
24+
}
25+
26+
chunks.emplace_back();
27+
std::size_t currentLines = 0;
28+
for (auto const& block: blocks)
29+
{
30+
if (!chunks.back().empty() && currentLines + block.lineCount > maxLinesPerFile)
31+
{
32+
chunks.emplace_back();
33+
currentLines = 0;
34+
}
35+
chunks.back().push_back(block);
36+
currentLines += block.lineCount;
37+
}
38+
if (chunks.back().empty())
39+
chunks.pop_back();
40+
return chunks;
41+
}
42+
43+
namespace
44+
{
45+
/// @brief Ensures the parent directory exists so subsequent `ofstream` opens succeed.
46+
void EnsureParentDirectoryExists(std::filesystem::path const& filePath)
47+
{
48+
auto const parent = filePath.parent_path();
49+
if (parent.empty())
50+
return;
51+
std::error_code ec;
52+
std::filesystem::create_directories(parent, ec);
53+
if (ec)
54+
throw std::runtime_error(std::format("Failed to create directory {}: {}", parent.string(), ec.message()));
55+
}
56+
57+
/// @brief Writes one chunk file with optional header/footer.
58+
void WriteChunkFile(std::filesystem::path const& path,
59+
std::vector<CodeBlock> const& chunk,
60+
std::string_view header,
61+
std::string_view footer)
62+
{
63+
EnsureParentDirectoryExists(path);
64+
std::ofstream out(path);
65+
if (!out.is_open())
66+
throw std::runtime_error(std::format("Failed to open output file: {}", path.string()));
67+
if (!header.empty())
68+
out << header;
69+
for (auto const& block: chunk)
70+
out << block.content;
71+
if (!footer.empty())
72+
out << footer;
73+
}
74+
75+
/// @brief Total line count of all blocks; cached on the `CodeBlock` so this loop
76+
/// is just a sum, not a per-call newline scan.
77+
[[nodiscard]] std::size_t TotalLines(std::vector<CodeBlock> const& blocks)
78+
{
79+
std::size_t total = 0;
80+
for (auto const& b: blocks)
81+
total += b.lineCount;
82+
return total;
83+
}
84+
} // namespace
85+
86+
WriteResult EmitChunked(std::filesystem::path const& outputPath,
87+
std::vector<CodeBlock> const& blocks,
88+
std::size_t maxLinesPerFile,
89+
std::string_view fileHeader,
90+
std::string_view fileFooter)
91+
{
92+
WriteResult result;
93+
94+
if (maxLinesPerFile == 0 || TotalLines(blocks) <= maxLinesPerFile)
95+
{
96+
WriteChunkFile(outputPath, blocks, fileHeader, fileFooter);
97+
result.writtenFiles.push_back(outputPath);
98+
return result;
99+
}
100+
101+
auto const chunks = GroupBlocksByLineBudget(blocks, maxLinesPerFile);
102+
auto const stem = outputPath.parent_path() / outputPath.stem();
103+
auto const ext = outputPath.extension().string();
104+
105+
for (std::size_t i = 0; i < chunks.size(); ++i)
106+
{
107+
auto partPath = std::filesystem::path { std::format("{}_part{:02}{}", stem.string(), i + 1, ext) };
108+
WriteChunkFile(partPath, chunks[i], fileHeader, fileFooter);
109+
result.writtenFiles.push_back(std::move(partPath));
110+
}
111+
112+
return result;
113+
}
114+
115+
void EmitPluginCmake(std::filesystem::path const& outputDir, std::string_view pluginName, std::string_view sourceGlob)
116+
{
117+
std::error_code ec;
118+
std::filesystem::create_directories(outputDir, ec);
119+
if (ec)
120+
throw std::runtime_error(std::format("Failed to create directory {}: {}", outputDir.string(), ec.message()));
121+
122+
auto const cmakePath = outputDir / "CMakeLists.txt";
123+
{
124+
std::ofstream out(cmakePath);
125+
if (!out.is_open())
126+
throw std::runtime_error(std::format("Failed to open output file: {}", cmakePath.string()));
127+
out << "# SPDX-License-Identifier: Apache-2.0\n"
128+
<< "# Auto-generated. DO NOT EDIT.\n"
129+
<< "\n"
130+
<< "cmake_minimum_required(VERSION 3.25)\n"
131+
<< "\n"
132+
<< "# Pick up every generated migration source. CONFIGURE_DEPENDS makes CMake re-glob\n"
133+
<< "# when the generator regenerates the directory, so new sources enter the build\n"
134+
<< "# without a manual reconfigure.\n"
135+
<< "file(GLOB " << pluginName << "_MIGRATIONS CONFIGURE_DEPENDS\n"
136+
<< " \"${CMAKE_CURRENT_SOURCE_DIR}/" << sourceGlob << "\"\n"
137+
<< ")\n"
138+
<< "\n"
139+
<< "add_library(" << pluginName << " MODULE\n"
140+
<< " Plugin.cpp\n"
141+
<< " ${" << pluginName << "_MIGRATIONS}\n"
142+
<< ")\n"
143+
<< "\n"
144+
<< "target_link_libraries(" << pluginName << " PRIVATE Lightweight::Lightweight)\n"
145+
<< "\n"
146+
<< "set_target_properties(" << pluginName << " PROPERTIES\n"
147+
<< " LIBRARY_OUTPUT_DIRECTORY \"${CMAKE_BINARY_DIR}/plugins\"\n"
148+
<< " RUNTIME_OUTPUT_DIRECTORY \"${CMAKE_BINARY_DIR}/plugins\"\n"
149+
<< " PREFIX \"\"\n"
150+
<< ")\n"
151+
<< "\n"
152+
<< "# Generated migration sources are large and intentionally literal; skip clang-tidy\n"
153+
<< "# so lint thresholds (function size, cognitive complexity) don't trip on them.\n"
154+
<< "set_target_properties(" << pluginName << " PROPERTIES CXX_CLANG_TIDY \"\")\n"
155+
<< "set_source_files_properties(${" << pluginName << "_MIGRATIONS} PROPERTIES SKIP_LINTING TRUE)\n";
156+
}
157+
158+
auto const pluginPath = outputDir / "Plugin.cpp";
159+
{
160+
std::ofstream out(pluginPath);
161+
if (!out.is_open())
162+
throw std::runtime_error(std::format("Failed to open output file: {}", pluginPath.string()));
163+
out << "// SPDX-License-Identifier: Apache-2.0\n"
164+
<< "// Auto-generated. DO NOT EDIT.\n"
165+
<< "//\n"
166+
<< "// Migration plugin entry point. Individual migrations self-register with the\n"
167+
<< "// MigrationManager via static initialization; this file only exposes the\n"
168+
<< "// plugin ABI that dbtool expects when dlopen'ing the shared module.\n"
169+
<< "\n"
170+
<< "#include <Lightweight/SqlMigration.hpp>\n"
171+
<< "\n"
172+
<< "LIGHTWEIGHT_MIGRATION_PLUGIN()\n";
173+
}
174+
}
175+
176+
} // namespace Lightweight::CodeGen
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
#pragma once
4+
5+
#include "../Api.hpp"
6+
7+
#include <cstddef>
8+
#include <filesystem>
9+
#include <string>
10+
#include <string_view>
11+
#include <vector>
12+
13+
namespace Lightweight::CodeGen
14+
{
15+
16+
/// @brief A pre-rendered text block plus its line count, ready to be packed into
17+
/// one or more output files by `EmitChunked`.
18+
///
19+
/// The block is treated as opaque — `EmitChunked` never splits a block in two.
20+
/// Callers compute `lineCount` once and reuse the same value across multiple
21+
/// passes, since counting newlines on every visit would be wasteful for large
22+
/// migrations.
23+
struct CodeBlock
24+
{
25+
/// Pre-rendered text of the block. Treated as opaque by `EmitChunked` —
26+
/// never split across files.
27+
std::string content;
28+
29+
/// Number of newlines in `content`, computed once by the caller and reused
30+
/// across packing passes.
31+
std::size_t lineCount = 0;
32+
};
33+
34+
/// @brief Greedy bin-packing of `blocks` across files of at most `maxLinesPerFile`
35+
/// lines.
36+
///
37+
/// One block always lands wholly in one chunk — even when its line count exceeds
38+
/// the budget — because builder DSL chains cannot be broken across translation
39+
/// units. The returned outer vector has no empty inner vectors. When
40+
/// `maxLinesPerFile == 0`, every block lands in a single chunk.
41+
[[nodiscard]] LIGHTWEIGHT_API std::vector<std::vector<CodeBlock>> GroupBlocksByLineBudget(
42+
std::vector<CodeBlock> const& blocks, std::size_t maxLinesPerFile);
43+
44+
/// @brief Result of an `EmitChunked` call.
45+
struct WriteResult
46+
{
47+
/// All files actually written, in apply order. Either `[outputPath]` (single
48+
/// file) or `[<stem>_part01.<ext>, <stem>_part02.<ext>, ...]`.
49+
std::vector<std::filesystem::path> writtenFiles;
50+
};
51+
52+
/// @brief Writes one or more output files, splitting `blocks` across `<stem>_partNN.<ext>`
53+
/// siblings when their combined line count exceeds `maxLinesPerFile`. When the
54+
/// total fits, a single `outputPath` is written and split is skipped.
55+
///
56+
/// `fileHeader` and `fileFooter` are emitted at the top/bottom of every produced
57+
/// file (so all chunks remain self-contained translation units when used for
58+
/// `.cpp` outputs). Pass empty strings to skip them.
59+
///
60+
/// @return `WriteResult` listing the paths actually written.
61+
/// @throws `std::runtime_error` if any output file cannot be opened.
62+
[[nodiscard]] LIGHTWEIGHT_API WriteResult EmitChunked(std::filesystem::path const& outputPath,
63+
std::vector<CodeBlock> const& blocks,
64+
std::size_t maxLinesPerFile,
65+
std::string_view fileHeader = {},
66+
std::string_view fileFooter = {});
67+
68+
/// @brief Writes a `CMakeLists.txt` and a `Plugin.cpp` next to the generated
69+
/// migration sources so the output directory becomes a drop-in plugin.
70+
///
71+
/// Mirrors the layout of `src/tools/LupMigrationsPlugin/` so consumers can
72+
/// `add_subdirectory()` the generated dir and pick up a self-registering plugin
73+
/// without further glue.
74+
LIGHTWEIGHT_API void EmitPluginCmake(std::filesystem::path const& outputDir,
75+
std::string_view pluginName,
76+
std::string_view sourceGlob = "lup_*.cpp");
77+
78+
} // namespace Lightweight::CodeGen

src/Lightweight/DataBinder/SqlGuid.cpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@ std::optional<SqlGuid> SqlGuid::TryParse(std::string_view const& text) noexcept
9292
// Variant nibble at position 19 must be a valid hex digit
9393
// We accept all variants (RFC 4122: 8-B, Microsoft: C-D, etc.)
9494
auto const variant = text[19];
95-
auto const isHexDigit = (variant >= '0' && variant <= '9') || (variant >= 'A' && variant <= 'F')
96-
|| (variant >= 'a' && variant <= 'f');
95+
auto const isHexDigit =
96+
(variant >= '0' && variant <= '9') || (variant >= 'A' && variant <= 'F') || (variant >= 'a' && variant <= 'f');
9797
if (!isHexDigit)
9898
return std::nullopt;
9999

src/Lightweight/DataBinder/SqlGuid.hpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,8 @@ constexpr SqlGuid SqlGuid::UnsafeParse(std::string_view const& text) noexcept
8181
// Variant nibble at position 19 must be a valid hex digit
8282
// We accept all variants (RFC 4122: 8-B, Microsoft: C-D, etc.)
8383
auto const variant = text[19];
84-
auto const isHexDigit = (variant >= '0' && variant <= '9') || (variant >= 'A' && variant <= 'F')
85-
|| (variant >= 'a' && variant <= 'f');
84+
auto const isHexDigit =
85+
(variant >= '0' && variant <= '9') || (variant >= 'A' && variant <= 'F') || (variant >= 'a' && variant <= 'f');
8686
if (!isHexDigit)
8787
return { "\x04" };
8888

src/Lightweight/DataBinder/SqlRawColumn.hpp

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,9 @@ struct SqlDataBinder<SqlRawColumn>
4848
// requires the ColumnSize to be the buffer length when not using data-at-execution.
4949
// A size of 0 causes HY104 "Invalid precision value" error.
5050
SQLULEN columnSize = value.metadata.size;
51-
if (columnSize == 0 && (value.metadata.sqlType == SQL_LONGVARCHAR || value.metadata.sqlType == SQL_LONGVARBINARY
52-
|| value.metadata.sqlType == SQL_WLONGVARCHAR))
51+
if (columnSize == 0
52+
&& (value.metadata.sqlType == SQL_LONGVARCHAR || value.metadata.sqlType == SQL_LONGVARBINARY
53+
|| value.metadata.sqlType == SQL_WLONGVARCHAR))
5354
{
5455
columnSize = value.metadata.bufferLength;
5556
}

0 commit comments

Comments
 (0)