// SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // From a C++ struct to a row layout, entirely at compile time: members are // classified by Shape, nested aggregates are flattened into dotted column // names, columns are placed by ascending alignment after the flags byte at // offset 0, and the result is hashed into the schema fingerprint the file // header carries. The same partition holds the table-level declarations // (version, orderedBy, Previous, Migrate) and the diagnostics for misuse. export module Crafter.DB:Schema; import std; import :Reflect; import :Format; import :Formatter; export namespace Crafter::DB { enum class Shape : std::uint8_t { Scalar, FixedBytes, ScalarArray, Optional, Fixed, Var, Aggregate, Pointer, Unsupported }; template struct OptionalTraits : std::false_type {}; template struct OptionalTraits> : std::true_type { using Type = M; }; template struct StdArrayTraits : std::false_type {}; template struct StdArrayTraits> : std::true_type { using Element = E; static constexpr std::size_t size = N; }; template struct FixedStringTraits : std::false_type {}; template struct FixedStringTraits> : std::true_type { static constexpr std::size_t size = N; }; template consteval Shape ShapeOf() { if constexpr (std::is_pointer_v || std::is_member_pointer_v || std::is_reference_v || std::is_null_pointer_v) return Shape::Pointer; else if constexpr (Scalar) return Shape::Scalar; else if constexpr (FixedStringTraits::value) return Shape::FixedBytes; else if constexpr (StdArrayTraits::value) { if constexpr (ByteLike::Element>) return Shape::FixedBytes; else if constexpr (Scalar::Element>) return Shape::ScalarArray; else return Shape::Unsupported; } else if constexpr (OptionalTraits::value) return Shape::Optional; else if constexpr (VarFormatter) return Shape::Var; else if constexpr (FixedFormatter) return Shape::Fixed; else if constexpr (Aggregate) return Shape::Aggregate; else return Shape::Unsupported; } // Number of row columns a member of type T occupies. template consteval std::size_t LeafCount() { constexpr Shape shape = ShapeOf(); if constexpr (shape == Shape::Scalar || shape == Shape::FixedBytes || shape == Shape::Var) return 1; else if constexpr (shape == Shape::ScalarArray) return StdArrayTraits::size; else if constexpr (shape == Shape::Optional) return 1 + LeafCount::Type>(); else if constexpr (shape == Shape::Fixed) return LeafCount::Stored>(); else if constexpr (shape == Shape::Aggregate) { return [](std::index_sequence) { return (std::size_t(0) + ... + LeafCount>()); }(std::make_index_sequence>{}); } else return 1; // unsupported shapes are rejected by CheckTable; count 1 keeps indices sane meanwhile } // First leaf of member I of aggregate T. template consteval std::size_t LeafStart() { return [](std::index_sequence) { return (std::size_t(0) + ... + LeafCount>()); }(std::make_index_sequence{}); } // A compile-time string with value semantics (std::string cannot cross // from consteval into a constexpr variable). Overflow is a build error, // never a silent truncation: a truncated column name would alias another // column, a truncated diagnostic would lose its hint. template struct FixedText { std::array chars{}; std::uint32_t length = 0; constexpr void Append(std::string_view text) { if (length + text.size() > chars.size()) throw "Crafter.DB: a column name or diagnostic exceeds the fixed buffer; shorten the member path"; for (const char c : text) chars[length++] = c; } constexpr std::string_view View() const noexcept { return { chars.data(), length }; } constexpr std::size_t size() const noexcept { return length; } constexpr const char* data() const noexcept { return chars.data(); } }; using FixedName = FixedText<256>; // a dotted column path using MessageText = FixedText<1024>; // a static_assert message inline constexpr std::uint32_t noLeaf = 0xFFFFFFFF; struct LeafInfo { FixedName name; Kind kind = Kind::UInt8; std::uint8_t flags = 0; std::uint16_t size = 1; std::uint16_t align = 1; std::uint32_t paramNum = 0; std::uint32_t paramDen = 0; std::uint32_t variant = 0; std::uint32_t presenceLeaf = noLeaf; }; template consteval void CollectLeaves(std::vector& out, const FixedName& name, std::uint32_t presenceLeaf) { constexpr Shape shape = ShapeOf(); const std::uint8_t optionalFlag = presenceLeaf == noLeaf ? 0 : columnFlagOptional; if constexpr (shape == Shape::Scalar) { LeafInfo leaf{ name, ScalarKind(), optionalFlag, sizeof(T), alignof(T) }; if constexpr (std::is_enum_v) leaf.flags |= columnFlagEnum; leaf.presenceLeaf = presenceLeaf; out.push_back(leaf); } else if constexpr (shape == Shape::FixedBytes) { constexpr std::size_t n = [] { if constexpr (FixedStringTraits::value) return FixedStringTraits::size; else return StdArrayTraits::size; }(); static_assert(n > 0 && n <= 0xFFFF, "fixed byte columns must be 1..65535 bytes"); LeafInfo leaf{ name, Kind::FixedBytes, optionalFlag, static_cast(n), 1, static_cast(n), 0 }; leaf.presenceLeaf = presenceLeaf; out.push_back(leaf); } else if constexpr (shape == Shape::ScalarArray) { for (std::size_t i = 0; i < StdArrayTraits::size; i++) { FixedName element = name; element.Append("["); char digits[20]{}; std::size_t len = 0; std::size_t v = i; do { digits[len++] = static_cast('0' + v % 10); v /= 10; } while (v != 0); for (std::size_t k = 0; k < len; k++) element.Append(std::string_view(&digits[len - 1 - k], 1)); element.Append("]"); CollectLeaves::Element>(out, element, presenceLeaf); } } else if constexpr (shape == Shape::Optional) { FixedName presence = name; presence.Append("?"); LeafInfo leaf{ presence, Kind::UInt8, static_cast(columnFlagPresence | optionalFlag), 1, 1 }; leaf.presenceLeaf = presenceLeaf; const std::uint32_t index = static_cast(out.size()); out.push_back(leaf); CollectLeaves::Type>(out, name, index); } else if constexpr (shape == Shape::Var) { LeafInfo leaf{ name, Kind::String, optionalFlag, sizeof(Var), alignof(Var) }; if constexpr (HasFormatterKind) leaf.kind = Formatter::kind; if constexpr (HasFormatterPeriod) { leaf.paramNum = Formatter::paramNum; leaf.paramDen = Formatter::paramDen; } if constexpr (HasFormatterVariant) leaf.variant = Formatter::variant; leaf.presenceLeaf = presenceLeaf; out.push_back(leaf); } else if constexpr (shape == Shape::Fixed) { const std::size_t first = out.size(); using S = typename Formatter::Stored; CollectLeaves(out, name, presenceLeaf); if constexpr (HasFormatterKind) { static_assert(LeafCount() == 1, "a Formatter may only override `kind` when Stored is a single column"); // A chrono type with a floating rep keeps its Float kind so the // bytes are read back as what they are; the period still records // that it is a time. if constexpr (std::is_integral_v) out[first].kind = Formatter::kind; } if constexpr (HasFormatterPeriod) { out[first].paramNum = Formatter::paramNum; out[first].paramDen = Formatter::paramDen; } if constexpr (HasFormatterVariant) out[first].variant = Formatter::variant; } else if constexpr (shape == Shape::Aggregate) { [&](std::index_sequence) { ((void)[&] { FixedName member = name; if (member.length != 0) member.Append("."); member.Append(MemberName()); CollectLeaves>(out, member, presenceLeaf); }(), ...); }(std::make_index_sequence>{}); } else { LeafInfo leaf{ name, Kind::UInt8, optionalFlag, 1, 1 }; out.push_back(leaf); } } template consteval auto MakeLeaves() { std::vector leaves; CollectLeaves(leaves, FixedName{}, noLeaf); std::array()> out{}; for (std::size_t i = 0; i < out.size(); i++) out[i] = leaves[i]; return out; } // One placed column: a leaf with its row offset, or the flags byte. struct ColumnInfo { FixedName name; Kind kind = Kind::Flags; std::uint8_t flags = 0; std::uint16_t size = 1; std::uint16_t align = 1; std::uint16_t offset = 0; std::uint16_t presenceOffset = noColumn; std::uint32_t paramNum = 0; std::uint32_t paramDen = 0; std::uint32_t variant = 0; }; template struct Arrangement { std::array columns{}; std::array leafOffset{}; std::array leafColumn{}; std::uint32_t rowSize = 0; std::uint16_t rowAlign = 1; }; template consteval Arrangement Arrange(const std::array& leaves) { Arrangement result; result.columns[0].name.Append("flags"); std::array order{}; for (std::size_t i = 0; i < N; i++) order[i] = i; std::ranges::stable_sort(order, [&](std::size_t a, std::size_t b) { return leaves[a].align < leaves[b].align; }); std::uint32_t cursor = 1; std::uint16_t rowAlign = 1; for (std::size_t k = 0; k < N; k++) { const LeafInfo& leaf = leaves[order[k]]; const std::uint32_t align = leaf.align; cursor = (cursor + align - 1) / align * align; if (cursor + leaf.size > 0xFFFF) throw "a row must fit in 65535 bytes"; ColumnInfo& column = result.columns[k + 1]; column.name = leaf.name; column.kind = leaf.kind; column.flags = leaf.flags; column.size = leaf.size; column.align = leaf.align; column.offset = static_cast(cursor); column.paramNum = leaf.paramNum; column.paramDen = leaf.paramDen; column.variant = leaf.variant; result.leafOffset[order[k]] = static_cast(cursor); result.leafColumn[order[k]] = static_cast(k + 1); cursor += leaf.size; if (leaf.align > rowAlign) rowAlign = leaf.align; } for (std::size_t k = 0; k < N; k++) { const LeafInfo& leaf = leaves[order[k]]; if (leaf.presenceLeaf != noLeaf) result.columns[k + 1].presenceOffset = result.leafOffset[leaf.presenceLeaf]; } result.rowAlign = rowAlign; result.rowSize = (cursor + rowAlign - 1) / rowAlign * rowAlign; return result; } template consteval std::uint64_t FingerprintOf(const Arrangement& arrangement, std::uint16_t orderedColumn) { std::uint64_t hash = Fnv1a(fnvOffset, "crafter-db-v1"); hash = Fnv1a(hash, arrangement.rowSize); hash = Fnv1a(hash, arrangement.rowAlign); hash = Fnv1a(hash, orderedColumn); for (const ColumnInfo& column : arrangement.columns) { hash = Fnv1a(hash, column.name.View()); hash = Fnv1a(hash, static_cast(column.kind)); hash = Fnv1a(hash, column.offset); hash = Fnv1a(hash, column.size); hash = Fnv1a(hash, column.flags); hash = Fnv1a(hash, column.paramNum); hash = Fnv1a(hash, column.paramDen); hash = Fnv1a(hash, column.variant); } return hash; } template consteval std::size_t NamePoolLength(const Arrangement& arrangement) { std::size_t total = 0; for (const ColumnInfo& column : arrangement.columns) total += column.name.length; return total; } // --- table-level declarations --------------------------------------------- template concept HasVersion = requires { { T::version } -> std::convertible_to; } && std::is_integral_v>; template concept HasOrderedBy = requires { T::orderedBy; } && std::is_member_object_pointer_v>; template concept HasPrevious = requires { typename T::Previous; } && Aggregate; template concept HasMigrate = HasPrevious && requires(typename T::Previous&& previous) { { T::Migrate(std::move(previous)) } -> std::same_as; }; // What `orderedBy` may point at: a type whose on-disk column is a single // integer with a total order. Floats are out (NaN breaks the search), // strings and fixed byte arrays are out (no binary search over them). template concept OrderableKey = (std::is_integral_v && !std::is_same_v) || std::is_enum_v || (FixedFormatter && HasFormatterKind && std::is_integral_v::Stored> && std::totally_ordered); // What a table struct must be. The diagnostics live in CheckTable. template concept TableType = Aggregate && HasVersion && std::is_default_constructible_v; // Everything the engine knows about T's row at compile time. template struct Layout { static constexpr std::size_t leafCount = LeafCount(); static constexpr std::size_t columnCount = leafCount + 1; static constexpr std::array leaves = MakeLeaves(); static constexpr Arrangement arrangement = Arrange(leaves); static constexpr const std::array& columns = arrangement.columns; static constexpr std::uint32_t rowSize = arrangement.rowSize; static constexpr std::uint16_t rowAlign = arrangement.rowAlign; static_assert(columnCount <= 0xFFFF, "Crafter.DB: a table may have at most 65535 columns"); static_assert(NamePoolLength(arrangement) <= 0xFFFF, "Crafter.DB: the column names of a table may total at most 65535 bytes"); // Row offset of a leaf (leaf indices follow declaration order, depth first). static constexpr std::uint16_t Offset(std::size_t leaf) noexcept { return arrangement.leafOffset[leaf]; } static constexpr std::uint16_t Column(std::size_t leaf) noexcept { return arrangement.leafColumn[leaf]; } static constexpr std::uint32_t version = [] { if constexpr (HasVersion) return static_cast(T::version); else return std::uint32_t(0); }(); static constexpr std::size_t orderedMember = [] { if constexpr (HasOrderedBy) return MemberIndex(); else return noMember; }(); static constexpr std::uint16_t orderedColumn = [] { if constexpr (HasOrderedBy) return Column(LeafStart()>()); else return noColumn; }(); static constexpr std::uint64_t fingerprint = FingerprintOf(arrangement, orderedColumn); static constexpr bool hasHeap = std::ranges::any_of(columns, [](const ColumnInfo& c) { return c.kind == Kind::String || c.kind == Kind::Blob; }); }; // The file-side description of Layout, byte-compatible with what // SchemaInfo::Parse reads back; MakeSchemaInfo().Fingerprint() equals // Layout::fingerprint by construction (a test pins this). template SchemaInfo MakeSchemaInfo() { SchemaInfo info; info.rowSize = Layout::rowSize; info.rowAlign = Layout::rowAlign; for (const ColumnInfo& column : Layout::columns) { ColumnDescriptor d{}; d.offset = column.offset; d.size = column.size; d.kind = column.kind; d.flags = column.flags; d.presenceOffset = column.presenceOffset; d.nameOffset = static_cast(info.namePool.size()); d.nameLength = static_cast(column.name.length); d.paramNum = column.paramNum; d.paramDen = column.paramDen; d.variant = column.variant; info.namePool.append(column.name.View()); info.columns.push_back(d); } return info; } // --- diagnostics --------------------------------------------------------- consteval MessageText Message(std::initializer_list parts) { MessageText message; for (const std::string_view part : parts) message.Append(part); return message; } template consteval MessageText MemberMessage(std::string_view problem, std::string_view hint) { return Message({ "Crafter.DB: member '", MemberName(), "' of ", TypeName(), " ", problem, " ", hint }); } template consteval bool CheckMembers(); template consteval bool CheckMember() { using M = MemberType; constexpr Shape shape = ShapeOf(); static_assert(shape != Shape::Pointer, MemberMessage("is a pointer or reference and cannot be stored;", "store a RowId, an index or a blob instead.")); static_assert(shape != Shape::Unsupported, MemberMessage("has a type with no Crafter::DB::Formatter;", "specialise Crafter::DB::Formatter for it or make it an aggregate of storable members.")); if constexpr (shape == Shape::Aggregate) return CheckMembers(); else if constexpr (shape == Shape::Optional) { using P = typename OptionalTraits::Type; static_assert(ShapeOf

() != Shape::Pointer && ShapeOf

() != Shape::Unsupported, MemberMessage("is a std::optional of an unsupported type;", "specialise Crafter::DB::Formatter for the payload type.")); static_assert(ShapeOf

() != Shape::Optional, MemberMessage("is a std::optional of a std::optional;", "nested optionals have no distinct column names; wrap the inner one in a struct.")); if constexpr (ShapeOf

() == Shape::Aggregate) return CheckMembers

(); } else if constexpr (shape == Shape::Fixed) { using S = typename Formatter::Stored; static_assert(ShapeOf() != Shape::Pointer && ShapeOf() != Shape::Unsupported && ShapeOf() != Shape::Var && ShapeOf() != Shape::Optional, MemberMessage("has a Formatter whose Stored type is not storable;", "Stored must be a scalar, enum, std::array or aggregate of those.")); if constexpr (ShapeOf() == Shape::Aggregate) return CheckMembers(); } return true; } template consteval bool CheckMembers() { return [](std::index_sequence) { return (true && ... && CheckMember()); }(std::make_index_sequence>{}); } // Instantiated by Table; every rule a table struct must follow is // reported here with the table and member name in the message. template consteval bool CheckTable() { static_assert(Aggregate, Message({ "Crafter.DB: ", TypeName(), " must be an aggregate: public data members only, no base classes with members, no user-declared constructors. Replace constructors with designated initialisers; Table::Emplace builds rows from fields." })); static_assert(HasVersion, Message({ "Crafter.DB: ", TypeName(), " must declare `static constexpr std::uint32_t version = 1;`" })); static_assert(std::is_default_constructible_v, Message({ "Crafter.DB: ", TypeName(), " must be default constructible (rows are decoded into a value-initialised instance)." })); if constexpr (Aggregate) { CheckMembers(); if constexpr (HasOrderedBy) { static_assert(std::is_same_v, T>, Message({ "Crafter.DB: ", TypeName(), "::orderedBy must point to a member of ", TypeName() })); static_assert(OrderableKey>, Message({ "Crafter.DB: ", TypeName(), "::orderedBy must name an integer, enum or integral chrono member; floats (NaN has no order), strings, byte arrays and aggregates cannot be ordered." })); } if constexpr (HasPrevious) { static_assert(HasVersion, Message({ "Crafter.DB: ", TypeName(), "::Previous must itself declare `version`." })); static_assert(Layout::version < Layout::version, Message({ "Crafter.DB: ", TypeName(), "::Previous must have a lower version than ", TypeName(), "; the Previous chain is strictly decreasing." })); } } return true; } } namespace Crafter::DB::SchemaTests { enum class Currency : std::uint16_t { Euro }; struct Transaction { std::string name; std::string bic; std::string iban; std::string description; Currency currency; std::int64_t amount; std::chrono::system_clock::time_point timePoint; static constexpr std::uint32_t version = 1; static constexpr auto orderedBy = &Transaction::timePoint; }; static_assert(TableType); static_assert(CheckTable()); static_assert(Layout::leafCount == 7); static_assert(Layout::rowSize == 88); static_assert(Layout::rowAlign == 8); static_assert(Layout::columns[0].kind == Kind::Flags && Layout::columns[0].offset == 0); static_assert(Layout::Offset(4) == 2); // currency: the only 2-byte column, right after the flags byte static_assert(Layout::Offset(0) == 8); // name static_assert(Layout::Offset(2) == 40); // iban static_assert(Layout::Offset(5) == 72); // amount static_assert(Layout::Offset(6) == 80); // timePoint static_assert(Layout::columns[Layout::Column(6)].kind == Kind::TimePoint); static_assert(Layout::columns[Layout::Column(6)].paramDen == 1000000); static_assert(Layout::columns[Layout::Column(4)].flags == columnFlagEnum); static_assert(Layout::columns[Layout::Column(2)].name.View() == "iban"); static_assert(Layout::orderedColumn == Layout::Column(6)); static_assert(Layout::hasHeap); struct Money { std::int64_t minor; Currency currency; }; struct Nested { std::optional note; Money money; std::array tags; FixedString<11> bic; std::vector ids; static constexpr std::uint32_t version = 2; }; static_assert(CheckTable()); static_assert(Layout::leafCount == 2 + 2 + 3 + 1 + 1); static_assert(Layout::columns[Layout::Column(0)].name.View() == "note?"); static_assert(Layout::columns[Layout::Column(1)].name.View() == "note"); static_assert(Layout::columns[Layout::Column(1)].flags == columnFlagOptional); static_assert(Layout::columns[Layout::Column(1)].presenceOffset == Layout::Offset(0)); static_assert(Layout::columns[Layout::Column(2)].name.View() == "money.minor"); static_assert(Layout::columns[Layout::Column(5)].name.View() == "tags[1]"); static_assert(Layout::columns[Layout::Column(7)].kind == Kind::FixedBytes && Layout::columns[Layout::Column(7)].size == 11); static_assert(Layout::columns[Layout::Column(8)].kind == Kind::Blob && Layout::columns[Layout::Column(8)].paramDen == 4); static_assert(Layout::columns[Layout::Column(8)].variant == static_cast(Kind::UInt32)); struct FloatBlob { std::vector ids; static constexpr std::uint32_t version = 1; }; struct IntBlob { std::vector ids; static constexpr std::uint32_t version = 1; }; static_assert(Layout::fingerprint != Layout::fingerprint, "blob element type must be part of the fingerprint"); struct Unordered { std::int64_t at; std::string text; static constexpr std::uint32_t version = 1; }; struct Ordered { std::int64_t at; std::string text; static constexpr std::uint32_t version = 1; static constexpr auto orderedBy = &Ordered::at; }; static_assert(Layout::fingerprint != Layout::fingerprint, "orderedBy must be part of the fingerprint"); }