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,372 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// The customisation point for member types, in the spirit of std::formatter:
// specialise Crafter::DB::Formatter<T> to say how a T is stored. Fixed-width
// formatters map T to a storable `Stored` type; variable-length formatters
// map it to the 16-byte Var handle and write the bytes into the heap. This
// partition also holds the reader-side handle views (StringRef, BlobRef), the
// pre-encoded search Key, and the built-in formatters for std::string,
// std::vector<Trivial> and std::chrono types.
export module Crafter.DB:Formatter;
import std;
import :Format;
import :Error;
import :Reflect;
export namespace Crafter::DB {
template<class T>
concept Scalar = (std::is_arithmetic_v<T> && !std::same_as<T, long double>) || std::is_enum_v<T> || std::same_as<T, std::byte>;
template<class T>
concept ByteLike = std::same_as<T, char> || std::same_as<T, unsigned char> || std::same_as<T, signed char> || std::same_as<T, std::byte> || std::same_as<T, char8_t>;
// The column kind of a scalar. `char` is Int8 whatever the platform's
// signedness, so a file's fingerprint does not depend on the ABI.
template<Scalar T>
consteval Kind ScalarKind() {
if constexpr (std::same_as<T, bool>) return Kind::Bool;
else if constexpr (std::is_enum_v<T>) return ScalarKind<std::underlying_type_t<T>>();
else if constexpr (std::same_as<T, std::byte>) return Kind::UInt8;
else if constexpr (std::same_as<T, char>) return Kind::Int8;
else if constexpr (std::same_as<T, wchar_t>) return sizeof(wchar_t) == 2 ? Kind::UInt16 : Kind::Int32;
else if constexpr (std::is_floating_point_v<T>) return sizeof(T) == 4 ? Kind::Float32 : Kind::Float64;
else if constexpr (std::is_signed_v<T>) return sizeof(T) == 1 ? Kind::Int8 : sizeof(T) == 2 ? Kind::Int16 : sizeof(T) == 4 ? Kind::Int32 : Kind::Int64;
else return sizeof(T) == 1 ? Kind::UInt8 : sizeof(T) == 2 ? Kind::UInt16 : sizeof(T) == 4 ? Kind::UInt32 : Kind::UInt64;
}
// A 32-bit code that identifies a type in the schema: the scalar kind
// for scalars, a hash of the compiler's spelling otherwise.
template<class T>
consteval std::uint32_t TypeCode() {
if constexpr (Scalar<T>) return static_cast<std::uint32_t>(ScalarKind<T>());
else return static_cast<std::uint32_t>(Fnv1a(fnvOffset, TypeName<T>()) & 0xFFFFFFFF) | 0x80000000u;
}
// Reader-side view of a table's heap region. `used` is the published
// length, so a handle pointing past it (a corrupt file) is caught before
// the bytes are touched.
struct Heap {
const std::byte* base = nullptr;
const std::atomic<std::uint64_t>* used = nullptr;
std::span<const std::byte> Read(std::uint64_t offset, std::uint64_t length) const {
const std::uint64_t limit = used->load(std::memory_order_acquire);
if (offset > limit || length > limit - offset) throw CorruptError(std::format("heap reference [{}, +{}) is outside the committed heap of {} bytes", offset, length, limit));
return { base + offset, static_cast<std::size_t>(length) };
}
};
// Writer-side cursor into the heap region. Formatters append through it;
// the table decides when the bytes become visible. Grow() is only reached
// when an extent fills up, so the virtual call never sits on the fast path.
class HeapWriter {
public:
virtual ~HeapWriter() = default;
std::uint64_t Write(std::span<const std::byte> bytes, std::uint32_t align = 1) {
std::uint64_t at = cursor;
if (align > 1) at = (at + align - 1) & ~std::uint64_t(align - 1);
if (at + bytes.size() > capacity) Grow(at + bytes.size());
if (!bytes.empty()) std::memcpy(base + at, bytes.data(), bytes.size());
cursor = at + bytes.size();
return at;
}
std::uint64_t Cursor() const noexcept { return cursor; }
void Rewind(std::uint64_t to) noexcept { cursor = to; }
protected:
virtual void Grow(std::uint64_t neededCapacity) = 0;
std::byte* base = nullptr;
std::uint64_t cursor = 0;
std::uint64_t capacity = 0;
};
// Whether a value of this length and element alignment is stored inside
// the handle. Alignments above 4 cannot be inline because the inline bytes
// start at handle offset 4.
constexpr bool VarInline(std::uint64_t length, std::uint32_t align) noexcept {
return length <= inlineVarBytes && align <= 4;
}
inline Var EncodeVar(std::span<const std::byte> bytes, HeapWriter& heap, std::uint32_t align = 1) {
if (bytes.size() >= std::uint64_t(1) << 32) throw Error(std::format("a value of {} bytes does not fit a Crafter.DB column (limit 4 GiB)", bytes.size()));
Var var{};
var.length = static_cast<std::uint32_t>(bytes.size());
if (VarInline(bytes.size(), align)) {
if (!bytes.empty()) std::memcpy(reinterpret_cast<std::byte*>(&var) + 4, bytes.data(), bytes.size());
} else {
// An empty blob can land here when its element alignment forbids
// inline storage; it has no prefix bytes to copy.
if (!bytes.empty()) std::memcpy(&var.prefix, bytes.data(), std::min<std::size_t>(bytes.size(), 4));
var.tailOrOffset = heap.Write(bytes, align);
}
return var;
}
// The bytes a handle refers to, inline or in the heap.
template<std::uint32_t Align = 1>
std::span<const std::byte> VarBytes(const std::byte* handle, const Heap& heap) {
Var var;
std::memcpy(&var, handle, sizeof(var));
if (VarInline(var.length, Align)) return { handle + 4, var.length };
return heap.Read(var.tailOrOffset, var.length);
}
// A search key encoded once, so a scan compares one 8-byte word per row.
// `head` is the first 8 bytes of a matching handle (length | prefix << 32)
// and `tail` the second 8 bytes when the value is inline.
class Key {
public:
explicit Key(std::string_view value) : text(value) {
head = static_cast<std::uint64_t>(static_cast<std::uint32_t>(text.size()));
std::uint32_t prefix = 0;
if (!text.empty()) std::memcpy(&prefix, text.data(), std::min<std::size_t>(text.size(), 4));
head |= std::uint64_t(prefix) << 32;
tail = 0;
if (text.size() > 4 && text.size() <= inlineVarBytes) std::memcpy(&tail, text.data() + 4, text.size() - 4);
}
std::uint64_t head;
std::uint64_t tail;
std::string text;
};
// Handle view of a string column. Points into the mapped row (and, for
// long values, the mapped heap); valid for as long as the table and that
// row are. Converts to std::string_view on demand; its comparison operators
// avoid that conversion and reject almost every non-match on the first
// 8-byte compare.
class StringRef {
public:
StringRef() = default;
StringRef(const std::byte* handle, const Heap* heap) noexcept : handle(handle), heap(heap) {}
std::uint32_t Length() const noexcept {
std::uint32_t length;
std::memcpy(&length, handle, sizeof(length));
return length;
}
bool Empty() const noexcept { return Length() == 0; }
std::string_view View() const {
const std::span<const std::byte> bytes = VarBytes<1>(handle, *heap);
return { reinterpret_cast<const char*>(bytes.data()), bytes.size() };
}
operator std::string_view() const { return View(); }
std::uint64_t Head() const noexcept {
std::uint64_t head;
std::memcpy(&head, handle, sizeof(head));
return head;
}
std::uint64_t Tail() const noexcept {
std::uint64_t tail;
std::memcpy(&tail, handle + 8, sizeof(tail));
return tail;
}
friend bool operator==(StringRef ref, const Key& key) {
if (ref.Head() != key.head) return false;
if (key.text.size() <= inlineVarBytes) return ref.Tail() == key.tail;
const std::span<const std::byte> bytes = ref.heap->Read(ref.Tail(), key.text.size());
return std::memcmp(bytes.data() + 4, key.text.data() + 4, key.text.size() - 4) == 0;
}
friend bool operator==(StringRef ref, std::string_view value) {
std::uint64_t head = static_cast<std::uint64_t>(static_cast<std::uint32_t>(value.size()));
std::uint32_t prefix = 0;
if (!value.empty()) std::memcpy(&prefix, value.data(), std::min<std::size_t>(value.size(), 4));
head |= std::uint64_t(prefix) << 32;
if (ref.Head() != head) return false;
if (value.size() <= inlineVarBytes) {
std::uint64_t tail = 0;
if (value.size() > 4) std::memcpy(&tail, value.data() + 4, value.size() - 4);
return ref.Tail() == tail;
}
const std::span<const std::byte> bytes = ref.heap->Read(ref.Tail(), value.size());
return std::memcmp(bytes.data() + 4, value.data() + 4, value.size() - 4) == 0;
}
friend bool operator==(StringRef a, StringRef b) {
if (a.Head() != b.Head()) return false;
if (a.Length() <= inlineVarBytes) return a.Tail() == b.Tail();
return a.View() == b.View();
}
friend std::strong_ordering operator<=>(StringRef ref, std::string_view value) {
const std::int32_t c = ref.View().compare(value);
return c < 0 ? std::strong_ordering::less : c > 0 ? std::strong_ordering::greater : std::strong_ordering::equal;
}
friend std::strong_ordering operator<=>(StringRef a, StringRef b) { return a <=> b.View(); }
private:
const std::byte* handle = nullptr;
const Heap* heap = nullptr;
};
// Handle view of a blob column holding elements of E.
template<class E>
class BlobRef {
public:
BlobRef() = default;
BlobRef(const std::byte* handle, const Heap* heap) noexcept : handle(handle), heap(heap) {}
std::size_t Size() const noexcept {
std::uint32_t length;
std::memcpy(&length, handle, sizeof(length));
return length / sizeof(E);
}
std::span<const E> View() const {
const std::span<const std::byte> bytes = VarBytes<alignof(E)>(handle, *heap);
return { reinterpret_cast<const E*>(bytes.data()), bytes.size() / sizeof(E) };
}
operator std::span<const E>() const { return View(); }
friend bool operator==(BlobRef ref, std::span<const E> value) {
const std::span<const E> mine = ref.View();
return mine.size() == value.size() && std::memcmp(mine.data(), value.data(), value.size_bytes()) == 0;
}
private:
const std::byte* handle = nullptr;
const Heap* heap = nullptr;
};
// Specialise this for your own member types. Left undefined on purpose:
// "no Formatter" is then a clean concept failure with a readable message.
template<class T>
struct Formatter;
// A fixed-width formatter maps T to a `Stored` type that is itself storable
// (scalar, enum, std::array, aggregate of storables). Optional members:
// `kind` (overrides the column kind), `paramNum`/`paramDen` (chrono period).
template<class T>
concept FixedFormatter = requires { typename Formatter<T>::Stored; }
&& !std::same_as<typename Formatter<T>::Stored, Var>
&& requires(const T& value, const typename Formatter<T>::Stored& stored) {
{ Formatter<T>::Encode(value) } -> std::same_as<typename Formatter<T>::Stored>;
{ Formatter<T>::Decode(stored) } -> std::convertible_to<T>;
};
// A variable-length formatter maps T to a Var handle. `View` is what a Row
// hands out (zero-copy); Materialize(View) -> T is optional and defaults to
// T(view). Optional: `kind` (String or Blob), `align` for blob elements.
template<class T>
concept VarFormatter = requires { typename Formatter<T>::Stored; typename Formatter<T>::View; }
&& std::same_as<typename Formatter<T>::Stored, Var>
&& requires(const T& value, HeapWriter& writer, const std::byte* handle, const Heap& heap) {
{ Formatter<T>::Encode(value, writer) } -> std::same_as<Var>;
{ Formatter<T>::Decode(handle, heap) } -> std::same_as<typename Formatter<T>::View>;
};
template<class T>
concept HasFormatterKind = requires { { Formatter<T>::kind } -> std::convertible_to<Kind>; };
template<class T>
concept HasFormatterPeriod = requires { { Formatter<T>::paramNum } -> std::convertible_to<std::uint32_t>; { Formatter<T>::paramDen } -> std::convertible_to<std::uint32_t>; };
template<class T>
concept HasFormatterVariant = requires { { Formatter<T>::variant } -> std::convertible_to<std::uint32_t>; };
template<class T>
concept HasMaterialize = VarFormatter<T> && requires(typename Formatter<T>::View view) { { Formatter<T>::Materialize(view) } -> std::same_as<T>; };
template<VarFormatter T>
T MaterializeVar(typename Formatter<T>::View view) {
if constexpr (HasMaterialize<T>) return Formatter<T>::Materialize(view);
else return T(view);
}
template<>
struct Formatter<std::string> {
using Stored = Var;
using View = StringRef;
static constexpr Kind kind = Kind::String;
static Var Encode(std::string_view value, HeapWriter& heap) { return EncodeVar(std::as_bytes(std::span(value)), heap, 1); }
static StringRef Decode(const std::byte* handle, const Heap& heap) noexcept { return { handle, &heap }; }
static std::string Materialize(StringRef view) { return std::string(view.View()); }
};
template<class E> requires (std::is_trivially_copyable_v<E> && !std::is_pointer_v<E> && !std::is_member_pointer_v<E>)
struct Formatter<std::vector<E>> {
using Stored = Var;
using View = BlobRef<E>;
static constexpr Kind kind = Kind::Blob;
static constexpr std::uint32_t paramNum = alignof(E);
static constexpr std::uint32_t paramDen = sizeof(E);
static constexpr std::uint32_t variant = TypeCode<E>(); // the element type is part of the schema
static Var Encode(std::span<const E> value, HeapWriter& heap) { return EncodeVar(std::as_bytes(value), heap, alignof(E)); }
static BlobRef<E> Decode(const std::byte* handle, const Heap& heap) noexcept { return { handle, &heap }; }
static std::vector<E> Materialize(BlobRef<E> view) {
const std::span<const E> elements = view.View();
return std::vector<E>(elements.begin(), elements.end());
}
};
template<class Rep, class Period>
struct Formatter<std::chrono::duration<Rep, Period>> {
static_assert(Period::num > 0 && Period::num <= 0xFFFFFFFF && Period::den > 0 && Period::den <= 0xFFFFFFFF, "chrono period does not fit the column descriptor");
using Stored = Rep;
static constexpr Kind kind = Kind::Duration;
static constexpr std::uint32_t paramNum = static_cast<std::uint32_t>(Period::num);
static constexpr std::uint32_t paramDen = static_cast<std::uint32_t>(Period::den);
static constexpr Rep Encode(const std::chrono::duration<Rep, Period>& value) noexcept { return value.count(); }
static constexpr std::chrono::duration<Rep, Period> Decode(const Rep& stored) noexcept { return std::chrono::duration<Rep, Period>(stored); }
};
template<class Clock, class Duration>
struct Formatter<std::chrono::time_point<Clock, Duration>> {
using Period = typename Duration::period;
static_assert(Period::num > 0 && Period::num <= 0xFFFFFFFF && Period::den > 0 && Period::den <= 0xFFFFFFFF, "chrono period does not fit the column descriptor");
using Stored = typename Duration::rep;
static constexpr Kind kind = Kind::TimePoint;
static constexpr std::uint32_t paramNum = static_cast<std::uint32_t>(Period::num);
static constexpr std::uint32_t paramDen = static_cast<std::uint32_t>(Period::den);
static constexpr std::uint32_t variant = TypeCode<Clock>(); // which clock the epoch belongs to
static constexpr Stored Encode(const std::chrono::time_point<Clock, Duration>& value) noexcept { return value.time_since_epoch().count(); }
static constexpr std::chrono::time_point<Clock, Duration> Decode(const Stored& stored) noexcept { return std::chrono::time_point<Clock, Duration>(Duration(stored)); }
};
// An inline, fixed-capacity string column: N bytes in the row, NUL padded,
// no heap. For codes with a known maximum length (BIC, currency, ISIN).
template<std::size_t N>
struct FixedString {
std::array<char, N> chars{};
constexpr FixedString() = default;
constexpr FixedString(std::string_view value) {
if (value.size() > N) throw std::length_error(std::format("'{}' does not fit a FixedString<{}>", value, N));
std::ranges::copy(value, chars.begin());
}
constexpr std::string_view View() const noexcept {
const auto end = std::ranges::find(chars, '\0');
return { chars.data(), static_cast<std::size_t>(end - chars.begin()) };
}
constexpr operator std::string_view() const noexcept { return View(); }
friend constexpr bool operator==(const FixedString&, const FixedString&) = default;
friend constexpr auto operator<=>(const FixedString&, const FixedString&) = default;
friend constexpr bool operator==(const FixedString& a, std::string_view b) noexcept { return a.View() == b; }
};
static_assert(VarFormatter<std::string>);
static_assert(VarFormatter<std::vector<std::byte>>);
static_assert(VarFormatter<std::vector<std::uint32_t>>);
static_assert(FixedFormatter<std::chrono::system_clock::time_point>);
static_assert(FixedFormatter<std::chrono::milliseconds>);
static_assert(HasMaterialize<std::string>);
}