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>
This commit is contained in:
Jorijn van der Graaf 2026-10-17 04:16:51 +02:00
commit bfd0cc261b
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
34 changed files with 7506 additions and 0 deletions

View file

@ -0,0 +1,187 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Every built-in member shape and both user formatter shapes round-trip:
// fixed (a class stored as basis points), variable-length inheriting from the
// std::string formatter, optionals of scalars and strings, nested aggregates
// with strings, scalar arrays, byte arrays, FixedString, chrono durations,
// bool, double, std::byte and blobs.
import Crafter.DB;
import std;
using namespace Crafter;
namespace fs = std::filesystem;
namespace {
int failures = 0;
void Check(bool condition, std::string_view what) {
if (!condition) {
std::println("FAIL: {}", what);
++failures;
}
}
class Percent {
public:
Percent() = default;
explicit Percent(double value) : basisPoints(static_cast<std::int32_t>(std::lround(value * 100))) {}
double Value() const noexcept { return basisPoints / 100.0; }
std::int32_t BasisPoints() const noexcept { return basisPoints; }
friend bool operator==(const Percent&, const Percent&) = default;
private:
std::int32_t basisPoints = 0;
};
class Tags {
public:
Tags() = default;
explicit Tags(std::vector<std::string> values) : values(std::move(values)) {}
const std::vector<std::string>& Values() const noexcept { return values; }
std::string Joined() const {
std::string out;
for (const std::string& v : values) {
if (!out.empty()) out += ',';
out += v;
}
return out;
}
static Tags Split(std::string_view text) {
std::vector<std::string> values;
for (const auto part : std::views::split(text, ',')) {
if (!part.empty()) values.emplace_back(part.begin(), part.end());
}
return Tags(std::move(values));
}
friend bool operator==(const Tags&, const Tags&) = default;
private:
std::vector<std::string> values;
};
}
template<>
struct Crafter::DB::Formatter<Percent> {
using Stored = std::int32_t;
static Stored Encode(const Percent& value) noexcept { return value.BasisPoints(); }
static Percent Decode(const Stored& stored) noexcept { return Percent(stored / 100.0); }
};
template<>
struct Crafter::DB::Formatter<Tags> : Crafter::DB::Formatter<std::string> {
static Var Encode(const Tags& value, HeapWriter& heap) { return Formatter<std::string>::Encode(value.Joined(), heap); }
static Tags Materialize(StringRef view) { return Tags::Split(view.View()); }
};
namespace {
enum class Currency : std::uint16_t { Euro, Dollar };
struct Money {
std::int64_t minor;
Currency currency;
friend bool operator==(const Money&, const Money&) = default;
};
struct Party {
std::string name;
DB::FixedString<11> bic;
std::optional<std::string> note;
friend bool operator==(const Party&, const Party&) = default;
};
struct Everything {
bool flag;
std::byte raw;
double ratio;
std::chrono::milliseconds delay;
std::chrono::duration<double> seconds;
Percent fee;
Tags tags;
std::optional<std::int32_t> maybe;
std::optional<std::string> maybeText;
Money money;
Party party;
std::array<std::uint16_t, 3> triple;
std::array<char, 4> code;
DB::FixedString<34> iban;
std::vector<std::uint32_t> ids;
static constexpr std::uint32_t version = 7;
friend bool operator==(const Everything&, const Everything&) = default;
};
}
int main() {
const fs::path dir = fs::temp_directory_path() / "crafter-db-formatters";
fs::remove_all(dir);
const fs::path path = dir / "e.cdb";
const Everything a{ true, std::byte{ 7 }, 0.25, std::chrono::milliseconds(1500), std::chrono::duration<double>(2.5), Percent(12.5), Tags({ "food", "monthly" }), 42, std::string("present"), { -1234, Currency::Dollar }, { "Shop", DB::FixedString<11>("ABNANL2AXXX"), std::string("vip customer with a long note") }, { 1, 2, 3 }, { 'E', 'U', 'R', '\0' }, DB::FixedString<34>("NL91ABNA0417164300"), { 10, 20, 30, 40, 50 } };
const Everything b{ false, std::byte{ 0 }, -1.5, std::chrono::milliseconds(0), std::chrono::duration<double>(0), Percent(0), Tags(std::vector<std::string>{}), std::nullopt, std::nullopt, { 0, Currency::Euro }, { "", DB::FixedString<11>(""), std::nullopt }, { 0, 0, 0 }, { 'U', 'S', 'D', '\0' }, DB::FixedString<34>(""), {} };
{
DB::Table<Everything> table(path);
table.Append(a);
table.Append(b);
const DB::Row<Everything> ra = table[0];
Check(ra[&Everything::flag] == true, "bool");
Check(ra[&Everything::raw] == std::byte{ 7 }, "std::byte");
Check(ra[&Everything::ratio] == 0.25, "double");
Check(ra[&Everything::delay] == std::chrono::milliseconds(1500), "duration");
Check(ra[&Everything::seconds] == std::chrono::duration<double>(2.5), "floating duration");
static_assert(DB::Layout<Everything>::columns[DB::Layout<Everything>::Column(DB::LeafStart<Everything, 4>())].kind == DB::Kind::Float64, "a floating chrono rep keeps its Float kind");
static_assert(DB::Layout<Everything>::columns[DB::Layout<Everything>::Column(DB::LeafStart<Everything, 4>())].paramDen == 1, "but records its period");
Check(ra[&Everything::fee] == Percent(12.5), "fixed user formatter decodes by value");
Check(ra[&Everything::tags] == "food,monthly", "var user formatter view is the raw encoding");
Check(ra.Get<&Everything::maybe>() == std::optional<std::int32_t>(42), "optional scalar engaged");
Check(ra[&Everything::maybeText].has_value() && *ra[&Everything::maybeText] == "present", "optional string engaged (view)");
Check(ra[&Everything::money] == Money{ -1234, Currency::Dollar }, "nested aggregate by value");
const Party party = ra[&Everything::party];
Check(party == a.party, "nested aggregate with strings, FixedString and optional");
Check(ra[&Everything::triple] == std::array<std::uint16_t, 3>{ 1, 2, 3 }, "scalar array");
Check(ra[&Everything::code] == std::array<char, 4>{ 'E', 'U', 'R', '\0' }, "char array");
Check(ra[&Everything::iban].View() == "NL91ABNA0417164300", "FixedString view");
Check(std::span<const std::uint32_t>(ra[&Everything::ids]).size() == 5, "blob");
const DB::Row<Everything> rb = table[1];
Check(!rb[&Everything::maybe].has_value(), "optional scalar empty");
Check(!rb[&Everything::maybeText].has_value(), "optional string empty");
Check(rb[&Everything::party].note == std::nullopt, "nested optional empty");
Check(rb[&Everything::iban].View().empty(), "empty FixedString");
Check(table.Materialize() == std::vector<Everything>{ a, b }, "Materialize round trip of everything");
table.Set<&Everything::maybe>(1, std::optional<std::int32_t>(-9));
Check(rb.Get<&Everything::maybe>() == std::optional<std::int32_t>(-9), "Set an optional");
table.Set<&Everything::fee>(1, Percent(1.0));
Check(rb[&Everything::fee] == Percent(1.0), "Set through a user formatter");
table.Set<&Everything::party>(1, Party{ "Changed", DB::FixedString<11>("RABONL2U"), std::nullopt });
Check(rb[&Everything::party].name == "Changed", "Set a nested aggregate");
const std::string shown = std::format("{}", ra);
Check(shown.find("fee=") != std::string::npos && shown.find("money={minor=-1234, currency=1}") != std::string::npos && shown.find("maybeText=\"present\"") != std::string::npos, std::format("formatter: {}", shown));
}
{
const DB::Table<Everything> table(path, { .readOnly = true });
Check(table[0].Materialize() == a, "row a survives reopen");
const Everything bb = table[1].Materialize();
Check(bb.maybe == -9 && bb.fee == Percent(1.0) && bb.party.name == "Changed", "updated row b survives reopen");
const DB::Inspection info = DB::TableFile::Inspect(path);
bool found = false;
for (std::size_t i = 0; i < info.schema.columns.size(); i++) {
if (info.schema.Name(i) == "party.bic") found = info.schema.columns[i].kind == DB::Kind::FixedBytes && info.schema.columns[i].size == 11;
}
Check(found, "nested column 'party.bic' described as 11 fixed bytes");
}
static_assert(DB::Layout<Everything>::columns[1].offset == 1 || DB::Layout<Everything>::columns[1].size == 1, "1-byte columns follow the flags byte");
static_assert(DB::Layout<Everything>::rowAlign == 8);
fs::remove_all(dir);
if (failures == 0) std::println("ok");
return failures == 0 ? 0 : 1;
}