Crafter.DB/tests/ShouldRoundTrip/main.cpp
Jorijn van der Graaf bfd0cc261b
Initial commit: Crafter.DB
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>
2026-10-17 04:16:51 +02:00

182 lines
8.8 KiB
C++

// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Append rows of every string length class (empty, inline, heap, huge,
// UTF-8), read them back through views and through Materialize, reopen the
// file and read them again. Guards the encode/decode symmetry and the file
// round trip that everything else builds on.
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 Transaction {
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 = &Transaction::timePoint;
friend bool operator==(const Transaction&, const Transaction&) = default;
};
TimePoint At(std::int64_t seconds) {
return TimePoint(std::chrono::duration_cast<TimePoint::duration>(std::chrono::seconds(seconds)));
}
std::vector<Transaction> Sample() {
std::vector<Transaction> rows;
rows.push_back({ "Albert Heijn", "ABNANL2A", "NL91ABNA0417164300", "groceries", Currency::Euro, -2345, At(100) });
rows.push_back({ "", "", "", "", Currency::Euro, 0, At(100) }); // all empty, equal key allowed
rows.push_back({ "Twelve chars", "ABCDEFGHIJKL", "ABCDEFGHIJKLM", "exactly twelve", Currency::Dollar, 1, At(101) }); // 12 inline, 13 heap
rows.push_back({ "Café Münster", "RABONL2U", "DE89370400440532013000", std::string(100 * 1024, 'x'), Currency::Euro, 99999999, At(102) });
rows.push_back({ "a", "b", "c", "d", Currency::Euro, -1, At(103) });
return rows;
}
}
int main() {
const fs::path dir = fs::temp_directory_path() / "crafter-db-roundtrip";
fs::remove_all(dir);
const fs::path path = dir / "sub" / "main.cdb";
const std::vector<Transaction> sample = Sample();
{
DB::Table<Transaction> table(path);
Check(table.Empty(), "new table is empty");
CheckEqual<std::uint64_t>(table.Size(), 0, "new table size");
Check(fs::exists(path), "table file created");
Check((fs::status(path).permissions() & fs::perms::others_all) == fs::perms::none, "file is private");
Check((fs::status(dir / "sub").permissions() & fs::perms::group_all) == fs::perms::none, "directory is private");
for (const Transaction& row : sample) table.Append(row);
CheckEqual<std::uint64_t>(table.Size(), sample.size(), "size after append");
CheckEqual<std::uint64_t>(table.SlotCount(), sample.size(), "slots after append");
const DB::Row<Transaction> last = table.Back();
Check(last[&Transaction::timePoint] == At(103), "Back() timePoint via member pointer");
Check(last.Get<&Transaction::amount>() == -1, "Back() amount via Get<>");
Check(last[&Transaction::iban] == "c", "Back() iban view compares to literal");
CheckEqual<std::uint64_t>(last.Id(), 4, "Back() id");
const DB::Row<Transaction> second = table[2];
Check(second[&Transaction::bic] == std::string_view("ABCDEFGHIJKL"), "12-char string inline round trip");
Check(second[&Transaction::iban] == std::string("ABCDEFGHIJKLM"), "13-char string heap round trip");
Check(second[&Transaction::iban] != std::string_view("ABCDEFGHIJKLX"), "same-length prefix-equal non-match");
Check(!(second[&Transaction::iban] == std::string_view("ABCDEFGHIJKL")), "shorter prefix-equal non-match");
Check(second[&Transaction::currency] == Currency::Dollar, "enum round trip");
CheckEqual<std::string>(std::string(second[&Transaction::description].View()), "exactly twelve", "StringRef::View");
const DB::Row<Transaction> empty = table[1];
Check(empty[&Transaction::name].Empty(), "empty string is empty");
Check(empty[&Transaction::name] == "", "empty string equals empty literal");
const DB::Row<Transaction> huge = table[3];
CheckEqual<std::uint32_t>(huge[&Transaction::description].Length(), 100 * 1024, "huge string length");
Check(huge[&Transaction::description] == sample[3].description, "huge string equals");
Check(huge[&Transaction::name] == "Café Münster", "utf-8 inline/heap string");
const std::vector<Transaction> all = table.Materialize();
Check(all == sample, "Materialize equals input");
CheckEqual<std::string>(std::string(DB::TypeName<Transaction>()).substr(0, 0), "", "noop");
const DB::RowId id = table.Emplace(std::string_view("Emplaced"), "BICBICBI", std::string("NL00EMPL0000000001"), std::string_view("via Emplace"), Currency::Euro, std::int64_t(42), At(104));
CheckEqual<std::uint64_t>(id, 5, "Emplace returns the new slot");
Check(table[id][&Transaction::name] == "Emplaced", "Emplace string_view argument");
Check(table[id][&Transaction::iban] == "NL00EMPL0000000001", "Emplace std::string argument");
Check(table[id][&Transaction::amount] == 42, "Emplace scalar argument");
const Transaction materialized = static_cast<Transaction>(table[id]);
CheckEqual<std::string>(materialized.description, "via Emplace", "explicit conversion to T");
const std::string described = std::format("{}", table[4]);
Check(described.find("name=\"a\"") != std::string::npos && described.find("amount=-1") != std::string::npos, std::format("row formatter output: {}", described));
std::uint64_t seen = 0;
for (DB::Row<Transaction> row : table) seen += row.Id() == seen ? 1 : 0;
CheckEqual<std::uint64_t>(seen, 6, "range-for visits every live row in order");
bool threw = false;
try {
table.Append(Transaction{ "late", "", "", "", Currency::Euro, 0, At(50) });
} catch (const DB::OrderError&) {
threw = true;
}
Check(threw, "out-of-order append throws OrderError");
CheckEqual<std::uint64_t>(table.Size(), 6, "failed append added nothing");
}
{
const DB::Table<Transaction> reopened(path, { .mode = DB::OpenMode::OpenExisting });
CheckEqual<std::uint64_t>(reopened.Size(), 6, "size after reopen");
const std::vector<Transaction> all = reopened.Materialize();
Check(all.size() == 6 && std::equal(sample.begin(), sample.end(), all.begin()), "rows survive reopen");
Check(reopened.Back()[&Transaction::name] == "Emplaced", "Emplaced row survives reopen");
const DB::Inspection info = DB::TableFile::Inspect(path);
CheckEqual<std::uint32_t>(info.header.schemaVersion, 1, "Inspect: schema version");
CheckEqual<std::size_t>(info.schema.columns.size(), 8, "Inspect: column count incl. flags");
CheckEqual<std::uint64_t>(info.commit.rowCount, 6, "Inspect: committed rows");
Check(info.header.schemaFingerprint == DB::Layout<Transaction>::fingerprint, "Inspect: fingerprint matches Layout");
Check(DB::MakeSchemaInfo<Transaction>().Fingerprint(DB::Layout<Transaction>::orderedColumn) == DB::Layout<Transaction>::fingerprint, "runtime and compile-time fingerprints agree");
CheckEqual<std::uint32_t>(DB::PeekVersion(path), 1, "PeekVersion");
}
{
const DB::Table<Transaction> frozen(path, { .readOnly = true });
CheckEqual<std::uint64_t>(frozen.Size(), 6, "read-only open sees rows");
bool threw = false;
try {
DB::Table<Transaction>(path, { .readOnly = true }); // second shared lock must coexist
} catch (const DB::Error&) {
threw = true;
}
Check(!threw, "two read-only opens coexist");
}
std::uint64_t temps = 0;
for (const fs::directory_entry& entry : fs::directory_iterator(dir / "sub")) {
if (entry.path().filename().string().find(".tmp-") != std::string::npos) temps++;
}
CheckEqual<std::uint64_t>(temps, 0, "no temp files left behind");
bool threwExisting = false;
try {
DB::Table<Transaction> missing(dir / "missing.cdb", { .mode = DB::OpenMode::OpenExisting });
} catch (const DB::IOError&) {
threwExisting = true;
}
Check(threwExisting, "OpenExisting on a missing file throws IOError");
fs::remove_all(dir);
if (failures == 0) std::println("ok");
return failures == 0 ? 0 : 1;
}