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>
372 lines
18 KiB
C++
372 lines
18 KiB
C++
// 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>);
|
|
}
|