207 lines
7.7 KiB
C++
207 lines
7.7 KiB
C++
|
|
// 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;
|
||
|
|
}
|