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:
commit
bfd0cc261b
34 changed files with 7506 additions and 0 deletions
207
tests/ShouldRejectSchemaChange/main.cpp
Normal file
207
tests/ShouldRejectSchemaChange/main.cpp
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// A struct that changed without a version bump must be refused with the
|
||||
// column difference in the message; so must files that are not tables, have
|
||||
// a damaged header, or have both commit records corrupted.
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
struct Original {
|
||||
std::string name;
|
||||
std::int64_t amount;
|
||||
static constexpr std::uint32_t version = 1;
|
||||
};
|
||||
|
||||
struct AddedColumn { // same version, one more member
|
||||
std::string name;
|
||||
std::int64_t amount;
|
||||
std::string reference;
|
||||
static constexpr std::uint32_t version = 1;
|
||||
};
|
||||
|
||||
struct RenamedColumn { // same version, same layout, different name
|
||||
std::string name;
|
||||
std::int64_t total;
|
||||
static constexpr std::uint32_t version = 1;
|
||||
};
|
||||
|
||||
struct RetypedColumn { // same version, int64 -> int32
|
||||
std::string name;
|
||||
std::int32_t amount;
|
||||
static constexpr std::uint32_t version = 1;
|
||||
};
|
||||
|
||||
struct NowOrdered { // same columns, but ordered
|
||||
std::string name;
|
||||
std::int64_t amount;
|
||||
static constexpr std::uint32_t version = 1;
|
||||
static constexpr auto orderedBy = &NowOrdered::amount;
|
||||
};
|
||||
|
||||
struct IntBlob {
|
||||
std::vector<std::int32_t> values;
|
||||
static constexpr std::uint32_t version = 1;
|
||||
};
|
||||
|
||||
struct FloatBlob { // same bytes per element, different meaning
|
||||
std::vector<float> values;
|
||||
static constexpr std::uint32_t version = 1;
|
||||
};
|
||||
|
||||
template<class T>
|
||||
std::string ExpectSchemaError(const fs::path& path, std::string_view what) {
|
||||
try {
|
||||
DB::Table<T> table(path, { .readOnly = true });
|
||||
} catch (const DB::SchemaError& error) {
|
||||
return error.what();
|
||||
} catch (const std::exception& error) {
|
||||
std::println("FAIL: {} threw {} instead of SchemaError", what, error.what());
|
||||
++failures;
|
||||
return "";
|
||||
}
|
||||
std::println("FAIL: {} did not throw", what);
|
||||
++failures;
|
||||
return "";
|
||||
}
|
||||
|
||||
void CheckOrFail(bool condition, std::string_view what) {
|
||||
if (!condition) {
|
||||
std::println("FAIL: {}", what);
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
template<class E>
|
||||
bool Throws(const fs::path& path) {
|
||||
try {
|
||||
DB::Table<Original> table(path, { .readOnly = true });
|
||||
} catch (const E&) {
|
||||
return true;
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void Patch(const fs::path& path, std::uint64_t offset, std::span<const std::byte> bytes) {
|
||||
std::fstream file(path, std::ios::in | std::ios::out | std::ios::binary);
|
||||
file.seekp(static_cast<std::streamoff>(offset));
|
||||
file.write(reinterpret_cast<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
const fs::path dir = fs::temp_directory_path() / "crafter-db-schema";
|
||||
fs::remove_all(dir);
|
||||
const fs::path path = dir / "o.cdb";
|
||||
{
|
||||
DB::Table<Original> table(path);
|
||||
table.Append({ "x", 1 });
|
||||
}
|
||||
|
||||
const std::string added = ExpectSchemaError<AddedColumn>(path, "added member");
|
||||
Check(added.find("reference") != std::string::npos && added.find("without bumping") != std::string::npos, std::format("message names the new column: {}", added));
|
||||
const std::string renamed = ExpectSchemaError<RenamedColumn>(path, "renamed member");
|
||||
Check(renamed.find("total") != std::string::npos && renamed.find("amount") != std::string::npos, std::format("message shows both names: {}", renamed));
|
||||
const std::string retyped = ExpectSchemaError<RetypedColumn>(path, "retyped member");
|
||||
Check(retyped.find("i32") != std::string::npos && retyped.find("i64") != std::string::npos, std::format("message shows both kinds: {}", retyped));
|
||||
|
||||
const std::string ordered = ExpectSchemaError<NowOrdered>(path, "orderedBy added");
|
||||
Check(ordered.find("orderedBy") != std::string::npos && ordered.find("amount") != std::string::npos, std::format("message names the ordered column change: {}", ordered));
|
||||
|
||||
{
|
||||
const DB::Table<Original> fine(path, { .readOnly = true });
|
||||
Check(fine.Size() == 1, "the original struct still opens");
|
||||
}
|
||||
|
||||
{
|
||||
const fs::path blob = dir / "blob.cdb";
|
||||
{
|
||||
DB::Table<IntBlob> ints(blob);
|
||||
ints.Append({ { 1, 2, 3, 1065353216 } });
|
||||
}
|
||||
const std::string retypedBlob = ExpectSchemaError<FloatBlob>(blob, "blob element type changed");
|
||||
Check(!retypedBlob.empty(), "vector<int32> -> vector<float> is refused");
|
||||
}
|
||||
|
||||
{
|
||||
// CreateNew on an existing file must refuse without touching it.
|
||||
const DB::Inspection before = DB::TableFile::Inspect(path);
|
||||
bool threw = false;
|
||||
try {
|
||||
DB::Table<Original> again(path, { .mode = DB::OpenMode::CreateNew });
|
||||
} catch (const DB::IOError& error) {
|
||||
threw = error.code == std::errc::file_exists;
|
||||
}
|
||||
Check(threw, "CreateNew on an existing table throws file_exists");
|
||||
const DB::Inspection after = DB::TableFile::Inspect(path);
|
||||
CheckOrFail(before.commit.commitSeq == after.commit.commitSeq, "CreateNew wrote nothing to the existing file");
|
||||
}
|
||||
|
||||
// A descriptor whose presenceOffset points outside the row.
|
||||
const fs::path presence = dir / "presence.cdb";
|
||||
fs::copy_file(path, presence);
|
||||
{
|
||||
const DB::Inspection info = DB::TableFile::Inspect(presence);
|
||||
// descriptor i starts at 4096 + 16 + i * 24; presenceOffset is at +6
|
||||
const std::uint16_t bogus = 0xFFF0;
|
||||
Patch(presence, 4096 + 16 + 1 * 24 + 6, std::as_bytes(std::span(&bogus, 1)));
|
||||
Check(info.schema.columns.size() == 3, "fixture has three columns");
|
||||
}
|
||||
Check(Throws<DB::CorruptError>(presence), "an out-of-row presenceOffset throws CorruptError");
|
||||
|
||||
// Damaged magic.
|
||||
const fs::path magic = dir / "magic.cdb";
|
||||
fs::copy_file(path, magic);
|
||||
const std::array<std::byte, 2> junk = { std::byte{ 'X' }, std::byte{ 'X' } };
|
||||
Patch(magic, 0, junk);
|
||||
Check(Throws<DB::CorruptError>(magic), "bad magic throws CorruptError");
|
||||
|
||||
// Both commit records damaged (flip a byte in each slot's sequence number).
|
||||
const fs::path commits = dir / "commits.cdb";
|
||||
fs::copy_file(path, commits);
|
||||
const std::array<std::byte, 1> flip = { std::byte{ 0xFF } };
|
||||
Patch(commits, 3072 + 1, flip);
|
||||
Patch(commits, 3584 + 1, flip);
|
||||
Check(Throws<DB::CorruptError>(commits), "two damaged commit records throw CorruptError");
|
||||
|
||||
// Not a table at all.
|
||||
const fs::path random = dir / "random.cdb";
|
||||
{
|
||||
std::ofstream out(random, std::ios::binary);
|
||||
std::string bytes(8192, '\0');
|
||||
for (std::size_t i = 0; i < bytes.size(); i++) bytes[i] = static_cast<char>(i * 31 + 7);
|
||||
out.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
|
||||
}
|
||||
Check(Throws<DB::CorruptError>(random), "random bytes throw CorruptError");
|
||||
|
||||
// Too short.
|
||||
const fs::path tiny = dir / "tiny.cdb";
|
||||
{ std::ofstream(tiny) << "short"; }
|
||||
Check(Throws<DB::CorruptError>(tiny), "a tiny file throws CorruptError");
|
||||
|
||||
// Truncated below the committed length.
|
||||
const fs::path truncated = dir / "truncated.cdb";
|
||||
fs::copy_file(path, truncated);
|
||||
fs::resize_file(truncated, 4096 * 2);
|
||||
Check(Throws<DB::CorruptError>(truncated), "a truncated file throws CorruptError");
|
||||
|
||||
fs::remove_all(dir);
|
||||
if (failures == 0) std::println("ok");
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
Loading…
Reference in a new issue