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>
160 lines
7.4 KiB
C++
160 lines
7.4 KiB
C++
// SPDX-License-Identifier: LGPL-3.0-only
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
// Tombstones and compaction: erased rows disappear from iteration while
|
|
// RowIds stay put, Back() skips trailing tombstones, Compact renumbers
|
|
// densely and reclaims heap garbage, Clear empties, a stale temp file is
|
|
// removed on open, and views taken before Compact are not used after it.
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
template<typename T>
|
|
void CheckEqual(const T& actual, const T& expected, std::string_view what) {
|
|
if (!(actual == expected)) {
|
|
std::println("FAIL: {} — expected '{}', got '{}'", what, expected, actual);
|
|
++failures;
|
|
}
|
|
}
|
|
|
|
struct Note {
|
|
std::string text;
|
|
std::int32_t rank;
|
|
|
|
static constexpr std::uint32_t version = 1;
|
|
static constexpr auto orderedBy = &Note::rank;
|
|
friend bool operator==(const Note&, const Note&) = default;
|
|
};
|
|
|
|
struct Memo {
|
|
std::optional<std::string> text;
|
|
std::int32_t id;
|
|
static constexpr std::uint32_t version = 1;
|
|
};
|
|
|
|
std::vector<std::int32_t> Ranks(const DB::Table<Note>& table) {
|
|
std::vector<std::int32_t> ranks;
|
|
for (DB::Row<Note> row : table) ranks.push_back(row[&Note::rank]);
|
|
return ranks;
|
|
}
|
|
}
|
|
|
|
int main() {
|
|
const fs::path dir = fs::temp_directory_path() / "crafter-db-compact";
|
|
fs::remove_all(dir);
|
|
const fs::path path = dir / "notes.cdb";
|
|
|
|
{
|
|
DB::Table<Note> table(path);
|
|
for (std::int32_t i = 0; i < 10; i++) table.Append({ std::format("note number {} with a long body", i), i });
|
|
const std::uint64_t heapBefore = table.Stats().heapUsed;
|
|
|
|
Check(table.Erase(2), "Erase returns true");
|
|
Check(!table.Erase(2), "second Erase returns false");
|
|
table.Erase(5);
|
|
table.Erase(9);
|
|
CheckEqual<std::uint64_t>(table.Size(), 7, "Size after erase");
|
|
CheckEqual<std::uint64_t>(table.SlotCount(), 10, "SlotCount unchanged");
|
|
CheckEqual(Ranks(table), std::vector<std::int32_t>{ 0, 1, 3, 4, 6, 7, 8 }, "iteration skips tombstones");
|
|
Check(table[7][&Note::rank] == 7, "RowIds stable after erase");
|
|
Check(table.Back()[&Note::rank] == 8, "Back skips a trailing tombstone");
|
|
Check(table.Front()[&Note::rank] == 0, "Front");
|
|
Check(!table.Contains(2) && table.Contains(3) && !table.Contains(10), "Contains");
|
|
Check(table[2].Erased(), "Erased flag visible through Slots");
|
|
bool threw = false;
|
|
try {
|
|
table.At(2);
|
|
} catch (const std::out_of_range&) {
|
|
threw = true;
|
|
}
|
|
Check(threw, "At on an erased row throws");
|
|
Check(table.Stats().heapGarbage == 3 * std::string("note number 0 with a long body").size(), "erase accounts heap garbage");
|
|
|
|
CheckEqual<std::uint64_t>(table.EraseIf([](DB::Row<Note> r) { return r[&Note::rank] % 4 == 3; }), 2, "EraseIf count (3 and 7)");
|
|
CheckEqual(Ranks(table), std::vector<std::int32_t>{ 0, 1, 4, 6, 8 }, "EraseIf result");
|
|
|
|
table.Update(4, { "four", 4 });
|
|
Check(table[4][&Note::text] == "four", "Update in place");
|
|
const std::uint64_t sizeBefore = fs::file_size(path);
|
|
table.Flush();
|
|
table.Compact();
|
|
CheckEqual<std::uint64_t>(table.Size(), 5, "Size after Compact");
|
|
CheckEqual<std::uint64_t>(table.SlotCount(), 5, "SlotCount after Compact");
|
|
CheckEqual(Ranks(table), std::vector<std::int32_t>{ 0, 1, 4, 6, 8 }, "order preserved by Compact");
|
|
Check(table[2][&Note::text] == "four", "rows renumbered densely");
|
|
Check(table[4][&Note::text] == "note number 8 with a long body", "heap values repacked correctly");
|
|
CheckEqual<std::uint64_t>(table.Stats().heapGarbage, 0, "no garbage after Compact");
|
|
Check(table.Stats().heapUsed < heapBefore, "heap shrank");
|
|
Check(fs::file_size(path) <= sizeBefore, "file did not grow");
|
|
table.Append({ "after compact", 9 });
|
|
CheckEqual<std::uint64_t>(table.Size(), 6, "append after Compact");
|
|
CheckEqual<std::uint64_t>(table.Back().Id(), 5, "new row gets the next dense id");
|
|
Check(table.Back()[&Note::text] == "after compact", "append after Compact readable");
|
|
}
|
|
|
|
{
|
|
// A leftover temp file (crash during Compact) is cleaned on the next open.
|
|
const fs::path stale = fs::path(path.string() + ".tmp-deadbeef0000");
|
|
{ std::ofstream(stale) << "junk"; }
|
|
DB::Table<Note> table(path);
|
|
Check(!fs::exists(stale), "stale temp file removed on open");
|
|
CheckEqual<std::uint64_t>(table.Size(), 6, "data intact after reopen");
|
|
|
|
table.Clear();
|
|
CheckEqual<std::uint64_t>(table.Size(), 0, "Clear empties the table");
|
|
CheckEqual<std::uint64_t>(table.SlotCount(), 0, "Clear resets slots");
|
|
table.Append({ "fresh", 1 });
|
|
CheckEqual<std::uint64_t>(table.Back().Id(), 0, "ids restart after Clear");
|
|
}
|
|
|
|
{
|
|
const DB::Table<Note> table(path, { .readOnly = true });
|
|
CheckEqual<std::uint64_t>(table.Size(), 1, "Clear + append persisted");
|
|
Check(table[0][&Note::text] == "fresh", "row persisted");
|
|
Check(fs::file_size(path) <= 3 * 65536, std::format("Clear did not pre-size the new file to the old data ({} bytes)", fs::file_size(path)));
|
|
|
|
const auto plain = table.Rows(0, 1);
|
|
std::uint64_t seen = 0;
|
|
for (DB::Row<Note> row : plain) seen += row[&Note::rank];
|
|
CheckEqual<std::uint64_t>(seen, 1, "const Query over a LiveRange iterates");
|
|
CheckEqual<std::uint64_t>(plain.Count(), 1, "Count on a const Query");
|
|
CheckEqual<std::uint64_t>(table.Rows(0, 1).Count(), 1, "Count on a prvalue Query");
|
|
static_assert(std::ranges::range<const decltype(plain)>, "a Query over a plain range is const-iterable");
|
|
static_assert(!std::ranges::range<const decltype(table.Where([](DB::Row<Note>) { return true; }))>, "a filter-backed Query is not const-iterable (filter_view has no const begin)");
|
|
Check(table.Where([](DB::Row<Note> r) { return r[&Note::rank] == 1; }).Any(), "Any on a prvalue filter Query");
|
|
}
|
|
|
|
{
|
|
// A disengaged optional must not keep its old heap bytes alive.
|
|
const fs::path memos = dir / "memos.cdb";
|
|
DB::Table<Memo> table(memos);
|
|
table.Append({ std::string("a note long enough to live in the heap"), 1 });
|
|
const std::uint64_t used = table.Stats().heapUsed;
|
|
Check(used > 0, "optional string went to the heap");
|
|
table.Set<&Memo::text>(0, std::optional<std::string>());
|
|
Check(!table[0][&Memo::text].has_value(), "optional cleared");
|
|
CheckEqual<std::uint64_t>(table.Stats().heapGarbage, used, "clearing the optional orphans its heap bytes");
|
|
table.Compact();
|
|
CheckEqual<std::uint64_t>(table.Stats().heapUsed, 0, "Compact dropped the dead payload");
|
|
DB::Var payload;
|
|
std::memcpy(&payload, table[0].Bytes() + DB::Layout<Memo>::Offset(1), sizeof(payload));
|
|
Check(payload.length == 0 && payload.tailOrOffset == 0, "payload handle is zero after clearing");
|
|
}
|
|
|
|
fs::remove_all(dir);
|
|
if (failures == 0) std::println("ok");
|
|
return failures == 0 ? 0 : 1;
|
|
}
|