|
|
@@ -0,0 +1,368 @@
|
|
|
+// v2.0 storage engine — unit tests for DocumentStore (Task 2.1 / 2.2 / 2.3).
|
|
|
+//
|
|
|
+// Each test builds a fresh LmdbEnv in a per-process tmpdir, constructs an
|
|
|
+// LmdbDocumentStore over it, exercises the interface contract, and asserts.
|
|
|
+// Tests are TDD-first: Task 2.1 lands the round-trip / absence / collection
|
|
|
+// suite, Task 2.2 makes them pass, Task 2.3 extends with the filter/sort/
|
|
|
+// projection parity suite.
|
|
|
+
|
|
|
+#include <algorithm>
|
|
|
+#include <atomic>
|
|
|
+#include <cassert>
|
|
|
+#include <cstdlib>
|
|
|
+#include <cstring>
|
|
|
+#include <filesystem>
|
|
|
+#include <iostream>
|
|
|
+#include <optional>
|
|
|
+#include <stdexcept>
|
|
|
+#include <string>
|
|
|
+#include <string_view>
|
|
|
+#include <unistd.h>
|
|
|
+#include <vector>
|
|
|
+
|
|
|
+#include <nlohmann/json.hpp>
|
|
|
+
|
|
|
+#include "document.hpp"
|
|
|
+#include "storage/document_store.hpp"
|
|
|
+#include "storage/document_store_lmdb.hpp"
|
|
|
+#include "storage/lmdb_env.hpp"
|
|
|
+
|
|
|
+namespace fs = std::filesystem;
|
|
|
+
|
|
|
+using smartbotic::database::Document;
|
|
|
+using smartbotic::database::Query;
|
|
|
+using smartbotic::db::storage::LmdbDocumentStore;
|
|
|
+using smartbotic::db::storage::LmdbEnv;
|
|
|
+using smartbotic::db::storage::LmdbEnvOpts;
|
|
|
+
|
|
|
+namespace {
|
|
|
+
|
|
|
+void check(bool cond, const char* msg) {
|
|
|
+ if (!cond) {
|
|
|
+ std::cerr << "FAIL: " << msg << "\n";
|
|
|
+ std::abort();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+std::string make_tmpdir(const char* tag) {
|
|
|
+ static std::atomic<int> counter{0};
|
|
|
+ int n = counter.fetch_add(1);
|
|
|
+ std::string path = "/tmp/document-store-test-" + std::to_string(::getpid()) +
|
|
|
+ "-" + std::to_string(n) + "-" + tag;
|
|
|
+ std::error_code ec;
|
|
|
+ fs::remove_all(path, ec);
|
|
|
+ return path;
|
|
|
+}
|
|
|
+
|
|
|
+struct TmpEnv {
|
|
|
+ std::string path;
|
|
|
+ LmdbEnv env;
|
|
|
+ explicit TmpEnv(const char* tag)
|
|
|
+ : path(make_tmpdir(tag)),
|
|
|
+ env(LmdbEnvOpts{path, 64ULL << 20, 256, 126, false}) {}
|
|
|
+ ~TmpEnv() {
|
|
|
+ std::error_code ec;
|
|
|
+ fs::remove_all(path, ec);
|
|
|
+ }
|
|
|
+ TmpEnv(const TmpEnv&) = delete;
|
|
|
+ TmpEnv& operator=(const TmpEnv&) = delete;
|
|
|
+};
|
|
|
+
|
|
|
+// Build a minimal Document for round-trip tests.
|
|
|
+Document make_doc(std::string_view id,
|
|
|
+ std::string_view collection,
|
|
|
+ const nlohmann::json& data,
|
|
|
+ uint64_t ts = 1731628800123ULL) {
|
|
|
+ Document d;
|
|
|
+ d.id = std::string(id);
|
|
|
+ d.collection = std::string(collection);
|
|
|
+ d.set_data(data);
|
|
|
+ d.createdAt = ts;
|
|
|
+ d.updatedAt = ts;
|
|
|
+ d.version = 1;
|
|
|
+ d.lastAccessedAt = ts;
|
|
|
+ return d;
|
|
|
+}
|
|
|
+
|
|
|
+// Build a zoe-shape document: _id, _created_at, name, tags, _vector, meta.
|
|
|
+Document make_zoe_doc(std::string_view id, std::string_view name,
|
|
|
+ const std::vector<std::string>& tags,
|
|
|
+ double score) {
|
|
|
+ nlohmann::json data = {
|
|
|
+ {"_id", std::string(id)},
|
|
|
+ {"name", std::string(name)},
|
|
|
+ {"tags", tags},
|
|
|
+ {"_vector", nlohmann::json::array({0.1, 0.2, 0.3, 0.4})},
|
|
|
+ {"meta", {
|
|
|
+ {"score", score},
|
|
|
+ {"nested", {{"deep", "value"}}}
|
|
|
+ }}
|
|
|
+ };
|
|
|
+ return make_doc(id, "zoe", data);
|
|
|
+}
|
|
|
+
|
|
|
+// =========================================================================
|
|
|
+// Task 2.1 — round-trip / absence / count / collection / scan basics
|
|
|
+// =========================================================================
|
|
|
+
|
|
|
+void test_put_get_roundtrip_minimal_doc() {
|
|
|
+ TmpEnv t("roundtrip-min");
|
|
|
+ LmdbDocumentStore store(t.env);
|
|
|
+
|
|
|
+ Document d = make_doc("doc-1", "coll", nlohmann::json{{"a", 1}, {"b", "two"}});
|
|
|
+ store.put("coll", "doc-1", d);
|
|
|
+
|
|
|
+ auto got = store.get("coll", "doc-1");
|
|
|
+ check(got.has_value(), "doc exists after put");
|
|
|
+ check(got->id == "doc-1", "round-trip id");
|
|
|
+ check(got->collection == "coll", "round-trip collection");
|
|
|
+ check(got->createdAt == 1731628800123ULL, "round-trip createdAt");
|
|
|
+ check(got->version == 1, "round-trip version");
|
|
|
+ const auto& data = got->data();
|
|
|
+ check(data["a"] == 1, "round-trip data.a");
|
|
|
+ check(data["b"] == "two", "round-trip data.b");
|
|
|
+}
|
|
|
+
|
|
|
+void test_put_get_roundtrip_zoe_shape_doc() {
|
|
|
+ TmpEnv t("roundtrip-zoe");
|
|
|
+ LmdbDocumentStore store(t.env);
|
|
|
+
|
|
|
+ Document d = make_zoe_doc("zoe-1", "Zoe", {"hot", "new"}, 9.5);
|
|
|
+ store.put("zoe", "zoe-1", d);
|
|
|
+
|
|
|
+ auto got = store.get("zoe", "zoe-1");
|
|
|
+ check(got.has_value(), "zoe doc exists after put");
|
|
|
+ const auto& data = got->data();
|
|
|
+ check(data["name"] == "Zoe", "round-trip data.name");
|
|
|
+ check(data["_vector"].is_array() && data["_vector"].size() == 4,
|
|
|
+ "round-trip _vector preserved");
|
|
|
+ check(data["tags"].is_array() && data["tags"][0] == "hot",
|
|
|
+ "round-trip tags[0]");
|
|
|
+ check(data["meta"]["score"] == 9.5, "round-trip meta.score");
|
|
|
+ check(data["meta"]["nested"]["deep"] == "value",
|
|
|
+ "round-trip nested deep field");
|
|
|
+}
|
|
|
+
|
|
|
+void test_put_overwrite_replaces() {
|
|
|
+ TmpEnv t("overwrite");
|
|
|
+ LmdbDocumentStore store(t.env);
|
|
|
+
|
|
|
+ store.put("c", "k", make_doc("k", "c", nlohmann::json{{"v", 1}}));
|
|
|
+ store.put("c", "k", make_doc("k", "c", nlohmann::json{{"v", 2}}));
|
|
|
+
|
|
|
+ auto got = store.get("c", "k");
|
|
|
+ check(got.has_value(), "doc exists after overwrite");
|
|
|
+ check(got->data()["v"] == 2, "overwrite replaces value");
|
|
|
+ check(store.count("c") == 1, "overwrite keeps count at 1");
|
|
|
+}
|
|
|
+
|
|
|
+void test_get_missing_returns_nullopt() {
|
|
|
+ TmpEnv t("missing");
|
|
|
+ LmdbDocumentStore store(t.env);
|
|
|
+
|
|
|
+ store.put("c", "exists", make_doc("exists", "c", nlohmann::json{{"v", 1}}));
|
|
|
+ auto g = store.get("c", "absent");
|
|
|
+ check(!g.has_value(), "get on missing id returns nullopt");
|
|
|
+}
|
|
|
+
|
|
|
+void test_get_from_nonexistent_collection_returns_nullopt() {
|
|
|
+ TmpEnv t("missing-coll");
|
|
|
+ LmdbDocumentStore store(t.env);
|
|
|
+
|
|
|
+ auto g = store.get("never-created", "any-id");
|
|
|
+ check(!g.has_value(), "get on nonexistent collection returns nullopt");
|
|
|
+}
|
|
|
+
|
|
|
+void test_del_missing_returns_false() {
|
|
|
+ TmpEnv t("del-missing");
|
|
|
+ LmdbDocumentStore store(t.env);
|
|
|
+
|
|
|
+ // Both nonexistent collection and nonexistent id within a real collection.
|
|
|
+ check(!store.del("never-created", "x"),
|
|
|
+ "del on nonexistent collection returns false");
|
|
|
+ store.put("c", "real", make_doc("real", "c", nlohmann::json{{"v", 1}}));
|
|
|
+ check(!store.del("c", "absent"),
|
|
|
+ "del on absent id within existing collection returns false");
|
|
|
+}
|
|
|
+
|
|
|
+void test_del_then_get_returns_nullopt() {
|
|
|
+ TmpEnv t("del-then-get");
|
|
|
+ LmdbDocumentStore store(t.env);
|
|
|
+
|
|
|
+ store.put("c", "k", make_doc("k", "c", nlohmann::json{{"v", 1}}));
|
|
|
+ check(store.del("c", "k"), "del of present doc returns true");
|
|
|
+
|
|
|
+ auto g = store.get("c", "k");
|
|
|
+ check(!g.has_value(), "after del, get returns nullopt");
|
|
|
+}
|
|
|
+
|
|
|
+void test_del_returns_true_for_existing() {
|
|
|
+ TmpEnv t("del-existing");
|
|
|
+ LmdbDocumentStore store(t.env);
|
|
|
+
|
|
|
+ store.put("c", "a", make_doc("a", "c", nlohmann::json{{"v", 1}}));
|
|
|
+ store.put("c", "b", make_doc("b", "c", nlohmann::json{{"v", 2}}));
|
|
|
+ check(store.del("c", "a"), "del of existing returns true");
|
|
|
+ check(store.count("c") == 1, "count drops to 1 after del");
|
|
|
+}
|
|
|
+
|
|
|
+void test_count_after_inserts() {
|
|
|
+ TmpEnv t("count");
|
|
|
+ LmdbDocumentStore store(t.env);
|
|
|
+
|
|
|
+ for (int i = 0; i < 7; ++i) {
|
|
|
+ std::string id = "d" + std::to_string(i);
|
|
|
+ store.put("c", id, make_doc(id, "c", nlohmann::json{{"i", i}}));
|
|
|
+ }
|
|
|
+ check(store.count("c") == 7, "count reports 7 after 7 inserts");
|
|
|
+}
|
|
|
+
|
|
|
+void test_count_zero_for_empty_collection() {
|
|
|
+ TmpEnv t("count-empty");
|
|
|
+ LmdbDocumentStore store(t.env);
|
|
|
+
|
|
|
+ store.put("c", "k", make_doc("k", "c", nlohmann::json{{"v", 1}}));
|
|
|
+ store.del("c", "k");
|
|
|
+ check(store.count("c") == 0, "count is 0 for emptied collection");
|
|
|
+}
|
|
|
+
|
|
|
+void test_count_zero_for_nonexistent_collection() {
|
|
|
+ TmpEnv t("count-nonexistent");
|
|
|
+ LmdbDocumentStore store(t.env);
|
|
|
+
|
|
|
+ check(store.count("never-created") == 0,
|
|
|
+ "count of nonexistent collection is 0");
|
|
|
+}
|
|
|
+
|
|
|
+void test_writes_to_collection_a_invisible_in_collection_b() {
|
|
|
+ TmpEnv t("isolation");
|
|
|
+ LmdbDocumentStore store(t.env);
|
|
|
+
|
|
|
+ store.put("a", "x", make_doc("x", "a", nlohmann::json{{"side", "A"}}));
|
|
|
+ auto from_b = store.get("b", "x");
|
|
|
+ check(!from_b.has_value(), "writes to a are invisible from b");
|
|
|
+ check(store.count("b") == 0, "b is empty when only a was written");
|
|
|
+}
|
|
|
+
|
|
|
+void test_same_id_in_two_collections_are_independent() {
|
|
|
+ TmpEnv t("same-id");
|
|
|
+ LmdbDocumentStore store(t.env);
|
|
|
+
|
|
|
+ store.put("a", "shared", make_doc("shared", "a", nlohmann::json{{"v", "A"}}));
|
|
|
+ store.put("b", "shared", make_doc("shared", "b", nlohmann::json{{"v", "B"}}));
|
|
|
+
|
|
|
+ auto ga = store.get("a", "shared");
|
|
|
+ auto gb = store.get("b", "shared");
|
|
|
+ check(ga.has_value() && ga->data()["v"] == "A",
|
|
|
+ "shared id in collection a holds value A");
|
|
|
+ check(gb.has_value() && gb->data()["v"] == "B",
|
|
|
+ "shared id in collection b holds value B");
|
|
|
+}
|
|
|
+
|
|
|
+void test_list_collections_empty_initially() {
|
|
|
+ TmpEnv t("list-empty");
|
|
|
+ LmdbDocumentStore store(t.env);
|
|
|
+
|
|
|
+ auto cols = store.list_collections();
|
|
|
+ check(cols.empty(),
|
|
|
+ "list_collections returns empty on fresh env");
|
|
|
+}
|
|
|
+
|
|
|
+void test_list_collections_after_inserts() {
|
|
|
+ TmpEnv t("list-after-inserts");
|
|
|
+ LmdbDocumentStore store(t.env);
|
|
|
+
|
|
|
+ store.put("alpha", "k", make_doc("k", "alpha", nlohmann::json{{"v", 1}}));
|
|
|
+ store.put("zulu", "k", make_doc("k", "zulu", nlohmann::json{{"v", 2}}));
|
|
|
+ store.put("mike", "k", make_doc("k", "mike", nlohmann::json{{"v", 3}}));
|
|
|
+
|
|
|
+ auto cols = store.list_collections();
|
|
|
+ check(cols.size() == 3, "list_collections returns 3 names");
|
|
|
+ check(cols[0] == "alpha" && cols[1] == "mike" && cols[2] == "zulu",
|
|
|
+ "list_collections sorted alphabetically");
|
|
|
+}
|
|
|
+
|
|
|
+void test_drop_collection_removes_all_docs() {
|
|
|
+ TmpEnv t("drop");
|
|
|
+ LmdbDocumentStore store(t.env);
|
|
|
+
|
|
|
+ store.put("c", "a", make_doc("a", "c", nlohmann::json{{"v", 1}}));
|
|
|
+ store.put("c", "b", make_doc("b", "c", nlohmann::json{{"v", 2}}));
|
|
|
+ check(store.count("c") == 2, "precondition: 2 docs");
|
|
|
+
|
|
|
+ bool dropped = store.drop_collection("c");
|
|
|
+ check(dropped, "drop_collection returns true on existing collection");
|
|
|
+ check(store.count("c") == 0, "post-drop count is 0");
|
|
|
+ check(!store.get("c", "a").has_value(), "post-drop get returns nullopt");
|
|
|
+
|
|
|
+ // After drop, the collection name should no longer appear.
|
|
|
+ auto cols = store.list_collections();
|
|
|
+ check(std::find(cols.begin(), cols.end(), "c") == cols.end(),
|
|
|
+ "dropped collection not listed");
|
|
|
+}
|
|
|
+
|
|
|
+void test_drop_nonexistent_returns_false() {
|
|
|
+ TmpEnv t("drop-nonexistent");
|
|
|
+ LmdbDocumentStore store(t.env);
|
|
|
+
|
|
|
+ check(!store.drop_collection("never-created"),
|
|
|
+ "drop_collection returns false for absent collection");
|
|
|
+}
|
|
|
+
|
|
|
+void test_scan_returns_all_for_empty_query() {
|
|
|
+ TmpEnv t("scan-empty-query");
|
|
|
+ LmdbDocumentStore store(t.env);
|
|
|
+
|
|
|
+ for (int i = 0; i < 5; ++i) {
|
|
|
+ std::string id = "d" + std::to_string(i);
|
|
|
+ store.put("c", id, make_doc(id, "c", nlohmann::json{{"i", i}}));
|
|
|
+ }
|
|
|
+ Query q;
|
|
|
+ q.limit = 100;
|
|
|
+ auto r = store.scan("c", q);
|
|
|
+ check(r.documents.size() == 5, "scan returns all 5 docs for empty query");
|
|
|
+ check(r.total_matched == 5, "total_matched = 5");
|
|
|
+ check(!r.has_more, "has_more = false when all fit");
|
|
|
+}
|
|
|
+
|
|
|
+void test_scan_returns_empty_for_nonexistent_collection() {
|
|
|
+ TmpEnv t("scan-nonexistent");
|
|
|
+ LmdbDocumentStore store(t.env);
|
|
|
+
|
|
|
+ Query q;
|
|
|
+ auto r = store.scan("never-created", q);
|
|
|
+ check(r.documents.empty(), "scan on nonexistent collection -> empty");
|
|
|
+ check(r.total_matched == 0, "total_matched = 0");
|
|
|
+ check(!r.has_more, "has_more = false");
|
|
|
+}
|
|
|
+
|
|
|
+} // namespace
|
|
|
+
|
|
|
+int main() {
|
|
|
+ // Task 2.1 — interface contract.
|
|
|
+ test_put_get_roundtrip_minimal_doc();
|
|
|
+ test_put_get_roundtrip_zoe_shape_doc();
|
|
|
+ test_put_overwrite_replaces();
|
|
|
+ test_get_missing_returns_nullopt();
|
|
|
+ test_get_from_nonexistent_collection_returns_nullopt();
|
|
|
+ test_del_missing_returns_false();
|
|
|
+ test_del_then_get_returns_nullopt();
|
|
|
+ test_del_returns_true_for_existing();
|
|
|
+ test_count_after_inserts();
|
|
|
+ test_count_zero_for_empty_collection();
|
|
|
+ test_count_zero_for_nonexistent_collection();
|
|
|
+ test_writes_to_collection_a_invisible_in_collection_b();
|
|
|
+ test_same_id_in_two_collections_are_independent();
|
|
|
+ test_list_collections_empty_initially();
|
|
|
+ test_list_collections_after_inserts();
|
|
|
+ test_drop_collection_removes_all_docs();
|
|
|
+ test_drop_nonexistent_returns_false();
|
|
|
+ test_scan_returns_all_for_empty_query();
|
|
|
+ test_scan_returns_empty_for_nonexistent_collection();
|
|
|
+
|
|
|
+ // Task 2.3 — filter / sort / projection parity is added in a follow-up
|
|
|
+ // commit; the impl-stage Task 2.2 only has to satisfy the 2.1 suite.
|
|
|
+
|
|
|
+ std::cout << "test_document_store: all passed\n";
|
|
|
+ return 0;
|
|
|
+}
|