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>
182 lines
8.8 KiB
C++
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;
|
|
}
|