Header-only C++26 module library for typed, file-backed tables with schema reflection, migrations, queries, and crash-safe commits. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
236 lines
9 KiB
C++
236 lines
9 KiB
C++
// SPDX-License-Identifier: LGPL-3.0-only
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
// Migrations: a V1 file opened by the V3 struct is converted in one pass
|
|
// through an additive by-name step (V1 -> V2) and an explicit Migrate
|
|
// (V2 -> V3); a V2 file skips the first step; the backup is kept; the
|
|
// refusal paths (read-only, migrate = false) throw MigrationError; the
|
|
// explicit Migrate<T>() entry point works without keeping the table open.
|
|
|
|
import Crafter.DB;
|
|
import std;
|
|
|
|
using namespace Crafter;
|
|
namespace fs = std::filesystem;
|
|
using TimePoint = std::chrono::system_clock::time_point;
|
|
|
|
namespace {
|
|
int failures = 0;
|
|
|
|
void Check(bool condition, std::string_view what) {
|
|
if (!condition) {
|
|
std::println("FAIL: {}", what);
|
|
++failures;
|
|
}
|
|
}
|
|
|
|
template<typename T>
|
|
void CheckEqual(const T& actual, const T& expected, std::string_view what) {
|
|
if (!(actual == expected)) {
|
|
std::println("FAIL: {} — expected '{}', got '{}'", what, expected, actual);
|
|
++failures;
|
|
}
|
|
}
|
|
|
|
enum class Currency : std::uint16_t { Euro, Dollar };
|
|
|
|
struct TransactionV1 {
|
|
std::string name;
|
|
std::string bic;
|
|
std::string iban;
|
|
std::string description;
|
|
Currency currency;
|
|
std::int64_t amount;
|
|
TimePoint timePoint;
|
|
|
|
static constexpr std::uint32_t version = 1;
|
|
static constexpr auto orderedBy = &TransactionV1::timePoint;
|
|
};
|
|
|
|
// Additive: `reference` appended, nothing else changed -> no Migrate.
|
|
struct TransactionV2 {
|
|
std::string name;
|
|
std::string bic;
|
|
std::string iban;
|
|
std::string description;
|
|
std::string reference;
|
|
Currency currency;
|
|
std::int64_t amount;
|
|
TimePoint timePoint;
|
|
|
|
static constexpr std::uint32_t version = 2;
|
|
static constexpr auto orderedBy = &TransactionV2::timePoint;
|
|
using Previous = TransactionV1;
|
|
};
|
|
|
|
struct Money {
|
|
std::int64_t minor;
|
|
Currency currency;
|
|
friend bool operator==(const Money&, const Money&) = default;
|
|
};
|
|
|
|
// description -> memo, amount + currency -> money: needs Migrate.
|
|
struct Transaction {
|
|
std::string name;
|
|
std::string bic;
|
|
std::string iban;
|
|
std::string memo;
|
|
std::string reference;
|
|
Money money;
|
|
TimePoint timePoint;
|
|
|
|
static constexpr std::uint32_t version = 3;
|
|
static constexpr auto orderedBy = &Transaction::timePoint;
|
|
using Previous = TransactionV2;
|
|
|
|
static Transaction Migrate(TransactionV2&& old) {
|
|
return {
|
|
.name = std::move(old.name),
|
|
.bic = std::move(old.bic),
|
|
.iban = std::move(old.iban),
|
|
.memo = std::move(old.description),
|
|
.reference = std::move(old.reference),
|
|
.money = { old.amount, old.currency },
|
|
.timePoint = old.timePoint,
|
|
};
|
|
}
|
|
};
|
|
|
|
struct EventV1 {
|
|
std::string name;
|
|
std::int64_t when;
|
|
static constexpr std::uint32_t version = 1;
|
|
};
|
|
|
|
struct EventV2 {
|
|
std::string name;
|
|
std::int64_t when;
|
|
static constexpr std::uint32_t version = 2;
|
|
static constexpr auto orderedBy = &EventV2::when;
|
|
using Previous = EventV1;
|
|
};
|
|
|
|
TimePoint At(std::int64_t seconds) {
|
|
return TimePoint(std::chrono::duration_cast<TimePoint::duration>(std::chrono::seconds(seconds)));
|
|
}
|
|
|
|
void WriteV1(const fs::path& path) {
|
|
DB::Table<TransactionV1> table(path);
|
|
table.Append({ "Albert Heijn", "ABNANL2A", "NL91ABNA0417164300", "groceries", Currency::Euro, -2345, At(1) });
|
|
table.Append({ "Employer", "RABONL2U", "NL20RABO0123456789", "salary for a long month of work", Currency::Dollar, 300000, At(2) });
|
|
table.Append({ "Gone", "", "", "to be erased", Currency::Euro, 1, At(3) });
|
|
table.Append({ "Landlord", "INGBNL2A", "NL69INGB0123456789", "rent", Currency::Euro, -120000, At(4) });
|
|
table.Erase(2);
|
|
}
|
|
}
|
|
|
|
int main() {
|
|
const fs::path dir = fs::temp_directory_path() / "crafter-db-migrate";
|
|
fs::remove_all(dir);
|
|
const fs::path path = dir / "tx.cdb";
|
|
WriteV1(path);
|
|
|
|
{
|
|
// Same struct, same version: no migration.
|
|
const DB::Table<TransactionV1> same(path);
|
|
CheckEqual<std::uint64_t>(same.Size(), 3, "V1 reopens as V1");
|
|
}
|
|
|
|
{
|
|
bool threw = false;
|
|
try {
|
|
DB::Table<Transaction> refused(path, { .readOnly = true });
|
|
} catch (const DB::MigrationError&) {
|
|
threw = true;
|
|
}
|
|
Check(threw, "read-only open of an older file throws MigrationError");
|
|
threw = false;
|
|
try {
|
|
DB::Table<Transaction> refused(path, { .migrate = false });
|
|
} catch (const DB::MigrationError&) {
|
|
threw = true;
|
|
}
|
|
Check(threw, "migrate = false throws MigrationError");
|
|
CheckEqual<std::uint32_t>(DB::PeekVersion(path), 1, "file untouched by refused migrations");
|
|
}
|
|
|
|
{
|
|
DB::Table<Transaction> table(path);
|
|
CheckEqual<std::uint64_t>(table.Size(), 3, "live rows carried over (tombstone dropped)");
|
|
CheckEqual<std::uint64_t>(table.SlotCount(), 3, "migration compacts");
|
|
const std::vector<Transaction> rows = table.Materialize();
|
|
CheckEqual<std::string>(rows[0].memo, "groceries", "description -> memo");
|
|
Check(rows[1].money == Money{ 300000, Currency::Dollar }, "amount + currency -> money");
|
|
CheckEqual<std::string>(rows[1].reference, "", "new V2 column value-initialised");
|
|
CheckEqual<std::string>(rows[2].iban, "NL69INGB0123456789", "strings carried");
|
|
Check(rows[2].timePoint == At(4), "time carried");
|
|
Check(table.Back()[&Transaction::name] == "Landlord", "view after migration");
|
|
const DB::Inspection info = DB::TableFile::Inspect(path);
|
|
CheckEqual<std::uint32_t>(info.header.schemaVersion, 3, "file now at version 3");
|
|
CheckEqual<std::uint32_t>(info.header.migratedFromVersion, 1, "migratedFromVersion recorded");
|
|
Check(fs::exists(DB::TableFile::BackupPath(path, 1)), "backup of the V1 file kept");
|
|
CheckEqual<std::uint32_t>(DB::PeekVersion(DB::TableFile::BackupPath(path, 1)), 1, "backup is the V1 file");
|
|
table.Append({ "After", "", "", "", "ref", { 5, Currency::Euro }, At(5) });
|
|
CheckEqual<std::uint64_t>(table.Size(), 4, "append after migration");
|
|
}
|
|
|
|
{
|
|
// A V2 file skips the by-name step.
|
|
const fs::path path2 = dir / "tx2.cdb";
|
|
{
|
|
DB::Table<TransactionV2> v2(path2);
|
|
v2.Append({ "A", "B", "C", "D", "REF-1", Currency::Euro, 7, At(1) });
|
|
}
|
|
DB::Table<Transaction> table(path2, { .backupBeforeMigrate = false });
|
|
CheckEqual<std::uint64_t>(table.Size(), 1, "V2 -> V3 row count");
|
|
Check(table[0][&Transaction::reference] == "REF-1", "V2 reference carried through Migrate");
|
|
Check(table[0][&Transaction::memo] == "D", "V2 description -> memo");
|
|
Check(!fs::exists(DB::TableFile::BackupPath(path2, 2)), "no backup when disabled");
|
|
}
|
|
|
|
{
|
|
// Explicit entry point and the V1 -> V2 by-name path on its own.
|
|
const fs::path path3 = dir / "tx3.cdb";
|
|
WriteV1(path3);
|
|
DB::Migrate<TransactionV2>(path3);
|
|
CheckEqual<std::uint32_t>(DB::PeekVersion(path3), 2, "explicit Migrate<V2>");
|
|
const DB::Table<TransactionV2> v2(path3, { .readOnly = true });
|
|
CheckEqual<std::uint64_t>(v2.Size(), 3, "by-name migration keeps rows");
|
|
Check(v2[1][&TransactionV2::description] == "salary for a long month of work" && v2[1][&TransactionV2::reference] == "", "by-name copies and value-initialises");
|
|
}
|
|
|
|
{
|
|
// Old data that is not in the order the new struct declares is sorted
|
|
// during the migration instead of failing it.
|
|
const fs::path events = dir / "events.cdb";
|
|
{
|
|
DB::Table<EventV1> v1(events);
|
|
v1.Append({ "late", 20 });
|
|
v1.Append({ "early", 10 });
|
|
v1.Append({ "middle", 15 });
|
|
v1.Append({ "also early", 10 });
|
|
}
|
|
DB::Table<EventV2> v2(events, { .backupBeforeMigrate = false });
|
|
CheckEqual<std::uint64_t>(v2.Size(), 4, "all rows migrated");
|
|
std::vector<std::int64_t> order;
|
|
for (DB::Row<EventV2> row : v2) order.push_back(row[&EventV2::when]);
|
|
CheckEqual(order, std::vector<std::int64_t>{ 10, 10, 15, 20 }, "rows sorted by the new orderedBy");
|
|
Check(v2[0][&EventV2::name] == "early" && v2[1][&EventV2::name] == "also early", "sort is stable");
|
|
Check(v2.Verify().Ok(), "migrated table verifies");
|
|
}
|
|
|
|
{
|
|
// A file newer than the code is refused.
|
|
bool threw = false;
|
|
try {
|
|
DB::Table<TransactionV1> older(path);
|
|
} catch (const DB::SchemaError&) {
|
|
threw = true;
|
|
}
|
|
Check(threw, "opening a V3 file with the V1 struct throws SchemaError");
|
|
}
|
|
|
|
fs::remove_all(dir);
|
|
if (failures == 0) std::println("ok");
|
|
return failures == 0 ? 0 : 1;
|
|
}
|