Crafter.DB/tests/ShouldMigrate/main.cpp

236 lines
9 KiB
C++
Raw Normal View History

// 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;
}