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>
335 lines
16 KiB
C++
335 lines
16 KiB
C++
// SPDX-License-Identifier: LGPL-3.0-only
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
// Typed encode/decode against a Layout<T>. Every function here is a template
|
|
// over (table type, first leaf index, member type), so column offsets are
|
|
// compile-time constants and a scalar read is one load. Leaf indices follow
|
|
// declaration order, depth first, exactly as Schema enumerates them.
|
|
export module Crafter.DB:Codec;
|
|
import std;
|
|
import :Reflect;
|
|
import :Format;
|
|
import :Formatter;
|
|
import :Schema;
|
|
import :Storage;
|
|
import :Error;
|
|
|
|
export namespace Crafter::DB {
|
|
// What a Row hands out for a member of type M: the formatter's View for
|
|
// variable-length members, an optional of that for std::optional, and the
|
|
// value itself for everything else.
|
|
template<class M>
|
|
consteval auto ViewTypeOf() {
|
|
constexpr Shape shape = ShapeOf<M>();
|
|
if constexpr (shape == Shape::Var) return std::type_identity<typename Formatter<M>::View>{};
|
|
else if constexpr (shape == Shape::Optional) return std::type_identity<std::optional<typename decltype(ViewTypeOf<typename OptionalTraits<M>::Type>())::type>>{};
|
|
else return std::type_identity<M>{};
|
|
}
|
|
|
|
template<class M>
|
|
using View = typename decltype(ViewTypeOf<M>())::type;
|
|
|
|
// Zeroes the columns of leaves [First, First + Count): the payload of a
|
|
// disengaged std::optional must not keep stale bytes (or stale heap
|
|
// handles that Compact would then preserve).
|
|
template<class T, std::size_t First, std::size_t Count>
|
|
void ZeroLeaves(std::byte* row) noexcept {
|
|
[&]<std::size_t... I>(std::index_sequence<I...>) {
|
|
(std::memset(row + Layout<T>::Offset(First + I), 0, Layout<T>::columns[Layout<T>::Column(First + I)].size), ...);
|
|
}(std::make_index_sequence<Count>{});
|
|
}
|
|
|
|
// Copies the columns of leaves [First, First + Count) from one row image
|
|
// to another, leaving every other byte (the flags byte above all) alone.
|
|
template<class T, std::size_t First, std::size_t Count>
|
|
void CopyLeaves(std::byte* destination, const std::byte* source) noexcept {
|
|
[&]<std::size_t... I>(std::index_sequence<I...>) {
|
|
(std::memcpy(destination + Layout<T>::Offset(First + I), source + Layout<T>::Offset(First + I), Layout<T>::columns[Layout<T>::Column(First + I)].size), ...);
|
|
}(std::make_index_sequence<Count>{});
|
|
}
|
|
|
|
// Values are converted implicitly, never static_cast: an int does not
|
|
// silently become an enum, and swapped Emplace arguments do not compile.
|
|
template<class T, std::size_t Leaf, class M, class V>
|
|
void EncodeInto(std::byte* row, HeapWriter& heap, const V& value) {
|
|
constexpr Shape shape = ShapeOf<M>();
|
|
constexpr std::uint16_t offset = Layout<T>::Offset(Leaf);
|
|
if constexpr (shape == Shape::Scalar) {
|
|
const M scalar = value;
|
|
std::memcpy(row + offset, &scalar, sizeof(M));
|
|
} else if constexpr (shape == Shape::FixedBytes) {
|
|
if constexpr (FixedStringTraits<M>::value) {
|
|
const M fixed(value);
|
|
std::memcpy(row + offset, fixed.chars.data(), fixed.chars.size());
|
|
} else {
|
|
const M& array = value;
|
|
std::memcpy(row + offset, array.data(), sizeof(M));
|
|
}
|
|
} else if constexpr (shape == Shape::ScalarArray) {
|
|
using E = typename StdArrayTraits<M>::Element;
|
|
[&]<std::size_t... I>(std::index_sequence<I...>) {
|
|
(EncodeInto<T, Leaf + I, E>(row, heap, value[I]), ...);
|
|
}(std::make_index_sequence<StdArrayTraits<M>::size>{});
|
|
} else if constexpr (shape == Shape::Optional) {
|
|
using P = typename OptionalTraits<M>::Type;
|
|
const std::uint8_t present = value.has_value() ? 1 : 0;
|
|
std::memcpy(row + offset, &present, 1);
|
|
if (value.has_value()) EncodeInto<T, Leaf + 1, P>(row, heap, *value);
|
|
else ZeroLeaves<T, Leaf + 1, LeafCount<P>()>(row);
|
|
} else if constexpr (shape == Shape::Var) {
|
|
const Var var = Formatter<M>::Encode(value, heap);
|
|
std::memcpy(row + offset, &var, sizeof(var));
|
|
} else if constexpr (shape == Shape::Fixed) {
|
|
using S = typename Formatter<M>::Stored;
|
|
if constexpr (std::is_same_v<V, M>) {
|
|
const S stored = Formatter<M>::Encode(value);
|
|
EncodeInto<T, Leaf, S>(row, heap, stored);
|
|
} else {
|
|
const M converted = value;
|
|
const S stored = Formatter<M>::Encode(converted);
|
|
EncodeInto<T, Leaf, S>(row, heap, stored);
|
|
}
|
|
} else if constexpr (shape == Shape::Aggregate) {
|
|
if constexpr (std::is_same_v<V, M>) {
|
|
[&]<std::size_t... I>(std::index_sequence<I...>) {
|
|
(EncodeInto<T, Leaf + LeafStart<M, I>(), MemberType<I, M>>(row, heap, Member<I>(value)), ...);
|
|
}(std::make_index_sequence<memberCount<M>>{});
|
|
} else {
|
|
const M converted = value;
|
|
EncodeInto<T, Leaf, M>(row, heap, converted);
|
|
}
|
|
} else {
|
|
static_assert(shape == Shape::Scalar, "unsupported member shape reached the encoder; CheckTable should have rejected it");
|
|
}
|
|
}
|
|
|
|
template<class T, std::size_t Leaf, class M>
|
|
M DecodeValue(const std::byte* row, const Heap& heap);
|
|
|
|
template<class T, std::size_t Leaf, class M>
|
|
View<M> DecodeView(const std::byte* row, const Heap& heap) {
|
|
constexpr Shape shape = ShapeOf<M>();
|
|
constexpr std::uint16_t offset = Layout<T>::Offset(Leaf);
|
|
if constexpr (shape == Shape::Var) {
|
|
return Formatter<M>::Decode(row + offset, heap);
|
|
} else if constexpr (shape == Shape::Optional) {
|
|
using P = typename OptionalTraits<M>::Type;
|
|
std::uint8_t present;
|
|
std::memcpy(&present, row + offset, 1);
|
|
if (present == 0) return std::nullopt;
|
|
return View<M>(DecodeView<T, Leaf + 1, P>(row, heap));
|
|
} else {
|
|
return DecodeValue<T, Leaf, M>(row, heap);
|
|
}
|
|
}
|
|
|
|
template<class T, std::size_t Leaf, class M>
|
|
M DecodeValue(const std::byte* row, const Heap& heap) {
|
|
constexpr Shape shape = ShapeOf<M>();
|
|
constexpr std::uint16_t offset = Layout<T>::Offset(Leaf);
|
|
if constexpr (shape == Shape::Scalar) {
|
|
M scalar;
|
|
std::memcpy(&scalar, row + offset, sizeof(M));
|
|
return scalar;
|
|
} else if constexpr (shape == Shape::FixedBytes) {
|
|
M fixed{};
|
|
if constexpr (FixedStringTraits<M>::value) std::memcpy(fixed.chars.data(), row + offset, fixed.chars.size());
|
|
else std::memcpy(fixed.data(), row + offset, sizeof(M));
|
|
return fixed;
|
|
} else if constexpr (shape == Shape::ScalarArray) {
|
|
using E = typename StdArrayTraits<M>::Element;
|
|
M array{};
|
|
[&]<std::size_t... I>(std::index_sequence<I...>) {
|
|
((array[I] = DecodeValue<T, Leaf + I, E>(row, heap)), ...);
|
|
}(std::make_index_sequence<StdArrayTraits<M>::size>{});
|
|
return array;
|
|
} else if constexpr (shape == Shape::Optional) {
|
|
using P = typename OptionalTraits<M>::Type;
|
|
std::uint8_t present;
|
|
std::memcpy(&present, row + offset, 1);
|
|
if (present == 0) return std::nullopt;
|
|
return M(DecodeValue<T, Leaf + 1, P>(row, heap));
|
|
} else if constexpr (shape == Shape::Var) {
|
|
return MaterializeVar<M>(Formatter<M>::Decode(row + offset, heap));
|
|
} else if constexpr (shape == Shape::Fixed) {
|
|
using S = typename Formatter<M>::Stored;
|
|
return Formatter<M>::Decode(DecodeValue<T, Leaf, S>(row, heap));
|
|
} else if constexpr (shape == Shape::Aggregate) {
|
|
M aggregate{};
|
|
[&]<std::size_t... I>(std::index_sequence<I...>) {
|
|
((Member<I>(aggregate) = DecodeValue<T, Leaf + LeafStart<M, I>(), MemberType<I, M>>(row, heap)), ...);
|
|
}(std::make_index_sequence<memberCount<M>>{});
|
|
return aggregate;
|
|
} else {
|
|
static_assert(shape == Shape::Scalar, "unsupported member shape reached the decoder");
|
|
}
|
|
}
|
|
|
|
// --- row level -----------------------------------------------------------
|
|
|
|
template<Aggregate T, std::size_t I>
|
|
View<MemberType<I, T>> DecodeMember(const std::byte* row, const Heap& heap) {
|
|
return DecodeView<T, LeafStart<T, I>(), MemberType<I, T>>(row, heap);
|
|
}
|
|
|
|
template<Aggregate T, std::size_t I>
|
|
MemberType<I, T> DecodeMemberValue(const std::byte* row, const Heap& heap) {
|
|
return DecodeValue<T, LeafStart<T, I>(), MemberType<I, T>>(row, heap);
|
|
}
|
|
|
|
template<Aggregate T>
|
|
T DecodeRow(const std::byte* row, const Heap& heap) {
|
|
T value{};
|
|
[&]<std::size_t... I>(std::index_sequence<I...>) {
|
|
((Member<I>(value) = DecodeMemberValue<T, I>(row, heap)), ...);
|
|
}(std::make_index_sequence<memberCount<T>>{});
|
|
return value;
|
|
}
|
|
|
|
// Writes a live row (flags = 0) for `value` into `row`, which must be
|
|
// Layout<T>::rowSize bytes of writable memory.
|
|
template<Aggregate T>
|
|
void EncodeRow(const T& value, std::byte* row, HeapWriter& heap) {
|
|
std::memset(row, 0, Layout<T>::rowSize);
|
|
[&]<std::size_t... I>(std::index_sequence<I...>) {
|
|
(EncodeInto<T, LeafStart<T, I>(), MemberType<I, T>>(row, heap, Member<I>(value)), ...);
|
|
}(std::make_index_sequence<memberCount<T>>{});
|
|
}
|
|
|
|
// Whether an Emplace argument of type A is acceptable for a member of type
|
|
// M: what the formatter's Encode accepts for variable-length members, an
|
|
// implicit conversion for everything else.
|
|
template<class M, class A>
|
|
consteval bool EmplaceAccepts() {
|
|
constexpr Shape shape = ShapeOf<M>();
|
|
if constexpr (shape == Shape::Var) return requires(const A& a, HeapWriter& w) { Formatter<M>::Encode(a, w); };
|
|
else if constexpr (shape == Shape::FixedBytes && FixedStringTraits<M>::value) return std::is_constructible_v<M, const A&>;
|
|
else return std::is_convertible_v<const A&, M>;
|
|
}
|
|
|
|
template<Aggregate T, std::size_t I, class A>
|
|
consteval bool CheckEmplaceArgument() {
|
|
static_assert(EmplaceAccepts<MemberType<I, T>, A>(), Message({ "Crafter.DB: the Emplace argument for member '", MemberName<I, T>(), "' of ", TypeName<T>(), " has type ", TypeName<A>(), ", which does not convert to the member; arguments follow declaration order." }));
|
|
return true;
|
|
}
|
|
|
|
// One argument per member, each encoded straight into its columns.
|
|
template<Aggregate T, class... Args>
|
|
void EmplaceRow(std::byte* row, HeapWriter& heap, const Args&... args) {
|
|
static_assert(sizeof...(Args) == memberCount<T>, "Crafter.DB: Emplace takes exactly one argument per member of the table struct, in declaration order");
|
|
[&]<std::size_t... I>(std::index_sequence<I...>) {
|
|
(CheckEmplaceArgument<T, I, Args>(), ...);
|
|
}(std::make_index_sequence<memberCount<T>>{});
|
|
std::memset(row, 0, Layout<T>::rowSize);
|
|
[&]<std::size_t... I>(std::index_sequence<I...>) {
|
|
(EncodeInto<T, LeafStart<T, I>(), MemberType<I, T>>(row, heap, args), ...);
|
|
}(std::make_index_sequence<memberCount<T>>{});
|
|
}
|
|
|
|
// Encodes member I only (Set); the rest of the row is left alone.
|
|
template<Aggregate T, std::size_t I, class V>
|
|
void EncodeMember(std::byte* row, HeapWriter& heap, const V& value) {
|
|
EncodeInto<T, LeafStart<T, I>(), MemberType<I, T>>(row, heap, value);
|
|
}
|
|
|
|
// Out-of-line heap bytes referenced by member I's columns.
|
|
template<Aggregate T, std::size_t I>
|
|
std::uint64_t MemberHeapBytes(const std::byte* row) noexcept {
|
|
std::uint64_t bytes = 0;
|
|
constexpr std::size_t first = LeafStart<T, I>();
|
|
constexpr std::size_t count = LeafCount<MemberType<I, T>>();
|
|
for (std::size_t leaf = first; leaf < first + count; leaf++) {
|
|
const ColumnInfo& column = Layout<T>::columns[Layout<T>::Column(leaf)];
|
|
if (column.kind != Kind::String && column.kind != Kind::Blob) continue;
|
|
Var var;
|
|
std::memcpy(&var, row + column.offset, sizeof(var));
|
|
if (!VarInline(var.length, column.kind == Kind::Blob && column.paramNum != 0 ? column.paramNum : 1)) bytes += var.length;
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
// --- ordered column ------------------------------------------------------
|
|
|
|
template<class T>
|
|
using OrderedKey = MemberTypeOf<T::orderedBy>;
|
|
|
|
template<class T> requires HasOrderedBy<T>
|
|
OrderedKey<T> KeyOfSlot(const TableFile& file, std::uint64_t slot) {
|
|
return DecodeMemberValue<T, Layout<T>::orderedMember>(file.Slot(slot), file.HeapView());
|
|
}
|
|
|
|
template<class T> requires HasOrderedBy<T>
|
|
std::uint64_t LowerBoundSlot(const TableFile& file, const OrderedKey<T>& key, std::uint64_t first, std::uint64_t last) {
|
|
while (first < last) {
|
|
const std::uint64_t mid = first + (last - first) / 2;
|
|
if (KeyOfSlot<T>(file, mid) < key) first = mid + 1;
|
|
else last = mid;
|
|
}
|
|
return first;
|
|
}
|
|
|
|
template<class T> requires HasOrderedBy<T>
|
|
std::uint64_t UpperBoundSlot(const TableFile& file, const OrderedKey<T>& key, std::uint64_t first, std::uint64_t last) {
|
|
while (first < last) {
|
|
const std::uint64_t mid = first + (last - first) / 2;
|
|
if (key < KeyOfSlot<T>(file, mid)) last = mid;
|
|
else first = mid + 1;
|
|
}
|
|
return first;
|
|
}
|
|
|
|
// Throws OrderError when `key` would sit below the key of slot `before`
|
|
// (or above the key of slot `after`, when given).
|
|
template<class T> requires HasOrderedBy<T>
|
|
void CheckOrder(const TableFile& file, const OrderedKey<T>& key, std::optional<std::uint64_t> before, std::optional<std::uint64_t> after) {
|
|
if (before && key < KeyOfSlot<T>(file, *before)) {
|
|
throw OrderError(std::format("{}: value would break the order of '{}' (it sorts before the previous row)", file.Path().string(), MemberName<Layout<T>::orderedMember, T>()));
|
|
}
|
|
if (after && KeyOfSlot<T>(file, *after) < key) {
|
|
throw OrderError(std::format("{}: value would break the order of '{}' (it sorts after the next row)", file.Path().string(), MemberName<Layout<T>::orderedMember, T>()));
|
|
}
|
|
}
|
|
|
|
// Appends encoded rows. Checks the ordered column against the last slot
|
|
// and within the batch before anything is written.
|
|
template<Aggregate T, std::ranges::input_range R> requires std::convertible_to<std::ranges::range_reference_t<R>, const T&>
|
|
std::uint64_t AppendRows(TableFile& file, R&& values) {
|
|
file.RequireWritable();
|
|
const std::uint64_t first = file.SlotCount();
|
|
if constexpr (std::ranges::sized_range<R> && std::ranges::random_access_range<R>) {
|
|
const std::uint64_t count = std::ranges::size(values);
|
|
if (count == 0) return first;
|
|
if constexpr (HasOrderedBy<T>) {
|
|
std::optional<std::uint64_t> before;
|
|
if (first != 0) before = first - 1;
|
|
auto it = std::ranges::begin(values);
|
|
CheckOrder<T>(file, static_cast<const T&>(*it).*T::orderedBy, before, std::nullopt);
|
|
for (std::uint64_t i = 1; i < count; i++) {
|
|
if (static_cast<const T&>(it[i]).*T::orderedBy < static_cast<const T&>(it[i - 1]).*T::orderedBy) {
|
|
throw OrderError(std::format("{}: batch is not sorted by '{}'", file.Path().string(), MemberName<Layout<T>::orderedMember, T>()));
|
|
}
|
|
}
|
|
}
|
|
std::byte* out = file.BeginAppend(count);
|
|
HeapWriter& heap = file.Writer();
|
|
try {
|
|
for (const T& value : values) {
|
|
EncodeRow<T>(value, out, heap);
|
|
out += Layout<T>::rowSize;
|
|
}
|
|
} catch (...) {
|
|
file.AbortAppend();
|
|
throw;
|
|
}
|
|
file.CommitAppend(count);
|
|
return first;
|
|
} else {
|
|
// A range that cannot be sized is buffered first, so the order
|
|
// check runs over the whole batch before any row is written and a
|
|
// failing batch leaves the table untouched.
|
|
std::vector<T> buffered;
|
|
for (const T& value : values) buffered.push_back(value);
|
|
return AppendRows<T>(file, std::span<const T>(buffered));
|
|
}
|
|
}
|
|
}
|