فهرست منبع

feat(relations): RelationManager with project-qualified keys

fszontagh 1 ماه پیش
والد
کامیت
9b00e020d7

+ 1 - 0
service/CMakeLists.txt

@@ -57,6 +57,7 @@ set(DATABASE_SERVICE_SOURCES
     src/migrations/migration_runner.cpp
     src/views/projection.cpp
     src/views/view_manager.cpp
+    src/relations/relation_manager.cpp
     src/config/collection_config_manager.cpp
     src/config/config_loader.cpp
     src/storage/lmdb_env.cpp

+ 234 - 0
service/src/relations/relation_manager.cpp

@@ -0,0 +1,234 @@
+#include "relation_manager.hpp"
+
+#include "../memory_store.hpp"
+#include "../project_addressing.hpp"
+
+#include <chrono>
+#include <nlohmann/json.hpp>
+#include <spdlog/spdlog.h>
+
+namespace smartbotic::database {
+
+namespace {
+
+uint64_t nowMs() {
+    return std::chrono::duration_cast<std::chrono::milliseconds>(
+        std::chrono::system_clock::now().time_since_epoch()).count();
+}
+
+std::string onDeleteToString(OnDelete v) {
+    switch (v) {
+        case OnDelete::Restrict: return "restrict";
+        case OnDelete::Cascade: return "cascade";
+        case OnDelete::SetNull: return "set_null";
+        case OnDelete::NoAction: return "no_action";
+    }
+    return "restrict";
+}
+
+OnDelete onDeleteFromString(const std::string& s) {
+    if (s == "cascade") return OnDelete::Cascade;
+    if (s == "set_null") return OnDelete::SetNull;
+    if (s == "no_action") return OnDelete::NoAction;
+    return OnDelete::Restrict;
+}
+
+nlohmann::json toJson(const RelationInfo& r) {
+    return nlohmann::json{
+        {"name", r.name},
+        {"child", r.child},
+        {"child_field", r.childField},
+        {"parent", r.parent},
+        {"on_delete", onDeleteToString(r.onDelete)},
+        {"validate_on_write", r.validateOnWrite},
+        {"created_at", r.createdAt},
+        {"updated_at", r.updatedAt}
+    };
+}
+
+RelationInfo fromJson(const nlohmann::json& j) {
+    RelationInfo r;
+    r.name = j.value("name", "");
+    r.child = j.value("child", "");
+    r.childField = j.value("child_field", "");
+    r.parent = j.value("parent", "");
+    r.onDelete = onDeleteFromString(j.value("on_delete", "restrict"));
+    r.validateOnWrite = j.value("validate_on_write", false);
+    r.createdAt = j.value("created_at", uint64_t{0});
+    r.updatedAt = j.value("updated_at", uint64_t{0});
+    return r;
+}
+
+} // anonymous namespace
+
+RelationManager::RelationManager(MemoryStore& store) : store_(store) {}
+
+void RelationManager::loadFromStore() {
+    std::unique_lock<std::shared_mutex> lock(mutex_);
+    cache_.clear();
+
+    // Ensure _relations collection exists.
+    CollectionOptions opts;
+    store_.createCollection(SYSTEM_COLLECTION, opts);
+
+    // Page explicitly. Query::limit defaults to 100, and limit=0 returns
+    // nothing (not everything) - the same trap that has already shipped
+    // as a bug in ViewManager, PolicyManager and CollectionConfigManager.
+    constexpr uint32_t kPage = 500;
+    uint32_t offset = 0;
+    while (true) {
+        Query q;
+        q.limit = kPage;
+        q.offset = offset;
+        auto res = store_.find(SYSTEM_COLLECTION, q);
+        if (res.documents.empty()) break;
+        for (const auto& d : res.documents) {
+            if (d.id.empty()) continue;
+            RelationInfo r = fromJson(d.data());
+            if (r.name.empty()) continue;
+            cache_[r.name] = std::move(r);
+        }
+        if (res.documents.size() < kPage) break;
+        offset += kPage;
+    }
+    spdlog::info("RelationManager: loaded {} relation(s) from {}", cache_.size(), SYSTEM_COLLECTION);
+}
+
+bool RelationManager::createRelation(const RelationInfo& r, std::string& errorOut) {
+    if (r.name.empty()) {
+        errorOut = "relation name is required";
+        return false;
+    }
+    if (r.child.empty()) {
+        errorOut = "child collection is required";
+        return false;
+    }
+    if (r.parent.empty()) {
+        errorOut = "parent collection is required";
+        return false;
+    }
+    if (r.childField.empty()) {
+        errorOut = "child_field is required";
+        return false;
+    }
+
+    // Each project owns its own LMDB env and no transaction spans two, so a
+    // cross-project relation could never be enforced atomically.
+    ResolvedCollection rn, rc, rp;
+    try {
+        rn = resolveCollection(r.name);
+        rc = resolveCollection(r.child);
+        rp = resolveCollection(r.parent);
+    } catch (const std::exception& e) {
+        errorOut = std::string("invalid relation/child/parent name: ") + e.what();
+        return false;
+    }
+    if (rc.project != rp.project || rc.project != rn.project) {
+        errorOut = "relation, child and parent must be in one project (no "
+                   "transaction spans two project envs)";
+        return false;
+    }
+
+    {
+        std::shared_lock<std::shared_mutex> rlock(mutex_);
+        if (cache_.contains(r.name)) {
+            errorOut = "relation '" + r.name + "' already exists";
+            return false;
+        }
+    }
+
+    RelationInfo out = r;
+    out.createdAt = nowMs();
+    out.updatedAt = out.createdAt;
+
+    Document doc;
+    doc.id = out.name;
+    doc.set_data(toJson(out));
+    try {
+        std::string id = store_.insert(SYSTEM_COLLECTION, doc);
+        if (id.empty()) {
+            errorOut = "failed to persist relation declaration";
+            return false;
+        }
+    } catch (const std::exception& e) {
+        errorOut = std::string("failed to persist relation declaration: ") + e.what();
+        return false;
+    }
+
+    {
+        std::unique_lock<std::shared_mutex> wlock(mutex_);
+        cache_[out.name] = out;
+    }
+    spdlog::info("RelationManager: created relation '{}' ({} -> {} via {})",
+                 out.name, out.child, out.parent, out.childField);
+    return true;
+}
+
+bool RelationManager::dropRelation(const std::string& qualifiedName, std::string& errorOut) {
+    {
+        std::shared_lock<std::shared_mutex> rlock(mutex_);
+        if (!cache_.contains(qualifiedName)) {
+            errorOut = "relation '" + qualifiedName + "' does not exist";
+            return false;
+        }
+    }
+
+    bool removed = store_.remove(SYSTEM_COLLECTION, qualifiedName);
+    if (!removed) {
+        errorOut = "failed to remove relation from store";
+        return false;
+    }
+
+    {
+        std::unique_lock<std::shared_mutex> wlock(mutex_);
+        cache_.erase(qualifiedName);
+    }
+    spdlog::info("RelationManager: dropped relation '{}'", qualifiedName);
+    return true;
+}
+
+std::optional<RelationInfo> RelationManager::getRelation(const std::string& qualifiedName) const {
+    std::shared_lock<std::shared_mutex> lock(mutex_);
+    auto it = cache_.find(qualifiedName);
+    if (it == cache_.end()) return std::nullopt;
+    return it->second;
+}
+
+std::vector<RelationInfo> RelationManager::listRelations(const std::string& project) const {
+    std::shared_lock<std::shared_mutex> lock(mutex_);
+    std::vector<RelationInfo> out;
+    out.reserve(cache_.size());
+    for (const auto& [_, r] : cache_) {
+        if (!project.empty()) {
+            ResolvedCollection rn;
+            try {
+                rn = resolveCollection(r.name);
+            } catch (const std::exception&) {
+                continue;
+            }
+            if (rn.project != project) continue;
+        }
+        out.push_back(r);
+    }
+    return out;
+}
+
+std::vector<RelationInfo> RelationManager::relationsWithParent(const std::string& qualifiedCollection) const {
+    std::shared_lock<std::shared_mutex> lock(mutex_);
+    std::vector<RelationInfo> out;
+    for (const auto& [_, r] : cache_) {
+        if (r.parent == qualifiedCollection) out.push_back(r);
+    }
+    return out;
+}
+
+std::vector<RelationInfo> RelationManager::relationsWithChild(const std::string& qualifiedCollection) const {
+    std::shared_lock<std::shared_mutex> lock(mutex_);
+    std::vector<RelationInfo> out;
+    for (const auto& [_, r] : cache_) {
+        if (r.child == qualifiedCollection) out.push_back(r);
+    }
+    return out;
+}
+
+} // namespace smartbotic::database

+ 107 - 0
service/src/relations/relation_manager.hpp

@@ -0,0 +1,107 @@
+#pragma once
+
+#include <optional>
+#include <shared_mutex>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+namespace smartbotic::database {
+
+class MemoryStore;
+
+/**
+ * Behavior when the PARENT side of a relation is deleted. Not enforced by
+ * this task - RelationManager only stores the declaration. Enforcement
+ * (Restrict/Cascade/SetNull/NoAction semantics on delete) is a later task.
+ */
+enum class OnDelete { Restrict, Cascade, SetNull, NoAction };
+
+/**
+ * In-memory representation of a relation declaration.
+ * Mirrors the (future) RelationDefinition proto message.
+ *
+ * A relation says: documents in `child` reference documents in `parent`
+ * through `childField` (a dot-path that may resolve to a single id or an
+ * array of ids). `onDelete` and `validateOnWrite` describe how a later
+ * enforcement layer should behave; this task does not enforce them.
+ */
+struct RelationInfo {
+    std::string name;            // qualified "<project>:<name>"
+    std::string child;           // qualified "<project>:<collection>"
+    std::string childField;      // dot-path, may resolve to an array of ids
+    std::string parent;          // qualified "<project>:<collection>"
+    OnDelete onDelete = OnDelete::Restrict;
+    bool validateOnWrite = false;
+    uint64_t createdAt = 0;
+    uint64_t updatedAt = 0;
+};
+
+/**
+ * RelationManager — loads, caches, and manages relation declarations.
+ *
+ * Relations are persisted as documents in the `_relations` system
+ * collection, shared globally across projects (like `_views`), so each
+ * relation's `name` carries its own project qualifier and is used as the
+ * document id. On startup, loadFromStore() populates an in-memory cache
+ * for O(1) lookup. Mutations (createRelation/dropRelation) update both
+ * the store and the cache.
+ *
+ * This task builds the declaration registry only: no enforcement (no
+ * on-write validation, no on-delete cascade/restrict), no LMDB reverse
+ * index. Those are later tasks in the relations-v2.11.0 plan.
+ */
+class RelationManager {
+public:
+    static constexpr const char* SYSTEM_COLLECTION = "_relations";
+
+    explicit RelationManager(MemoryStore& store);
+
+    /**
+     * Load all relation declarations from the _relations collection into
+     * the cache. Call once at startup, AFTER MemoryStore has loaded
+     * persisted state. Pages explicitly - Query::limit defaults to 100.
+     */
+    void loadFromStore();
+
+    /**
+     * Declare a relation. Fails if the name already exists, or if name,
+     * child and parent do not all resolve to the same project (no LMDB
+     * transaction spans two project envs, so cross-project relations
+     * could never be enforced atomically).
+     * @return true on success. On failure errorOut is populated.
+     */
+    bool createRelation(const RelationInfo& r, std::string& errorOut);
+
+    /**
+     * Drop a relation by its qualified name.
+     */
+    bool dropRelation(const std::string& qualifiedName, std::string& errorOut);
+
+    /**
+     * Lookup a relation by its qualified name. Returns nullopt if absent.
+     */
+    std::optional<RelationInfo> getRelation(const std::string& qualifiedName) const;
+
+    /**
+     * List relations. If `project` is empty, lists every relation across
+     * every project; otherwise filters to relations whose name is
+     * qualified with that project.
+     */
+    std::vector<RelationInfo> listRelations(const std::string& project = "") const;
+
+    // Relations whose PARENT is this (qualified) collection - what a
+    // delete must consult.
+    std::vector<RelationInfo> relationsWithParent(const std::string& qualifiedCollection) const;
+
+    // Relations whose CHILD is this (qualified) collection - what a
+    // write must maintain.
+    std::vector<RelationInfo> relationsWithChild(const std::string& qualifiedCollection) const;
+
+private:
+    MemoryStore& store_;
+    mutable std::shared_mutex mutex_;
+    std::unordered_map<std::string, RelationInfo> cache_;
+};
+
+} // namespace smartbotic::database

+ 34 - 0
tests/CMakeLists.txt

@@ -718,3 +718,37 @@ endif()
 find_package(Threads REQUIRED)
 target_link_libraries(test_view_manager_paging PRIVATE Threads::Threads)
 add_test(NAME test_view_manager_paging COMMAND test_view_manager_paging)
+
+# v2.11.0 T1 — RelationManager: the declaration registry for referential
+# integrity relations. Same paging trap as ViewManager/PolicyManager/
+# CollectionConfigManager — loadFromStore() must page explicitly.
+add_executable(test_relation_manager
+    test_relation_manager.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/relations/relation_manager.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/views/view_manager.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/views/projection.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/memory_store.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/config/collection_config_manager.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/persistence/history_store.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/persistence/wal.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/json_parse.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/doc_binary.cpp
+)
+target_include_directories(test_relation_manager PRIVATE
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src
+    ${yyjson_INCLUDE_DIRS}
+)
+target_link_libraries(test_relation_manager PRIVATE ${yyjson_LIBRARIES})
+if(TARGET nlohmann_json::nlohmann_json)
+    target_link_libraries(test_relation_manager PRIVATE nlohmann_json::nlohmann_json)
+else()
+    target_include_directories(test_relation_manager PRIVATE ${NLOHMANN_JSON_INCLUDE_DIRS})
+endif()
+if(TARGET spdlog::spdlog)
+    target_link_libraries(test_relation_manager PRIVATE spdlog::spdlog)
+else()
+    target_link_libraries(test_relation_manager PRIVATE ${SPDLOG_LIBRARIES})
+    target_include_directories(test_relation_manager PRIVATE ${SPDLOG_INCLUDE_DIRS})
+endif()
+target_link_libraries(test_relation_manager PRIVATE Threads::Threads)
+add_test(NAME test_relation_manager COMMAND test_relation_manager)

+ 110 - 0
tests/test_relation_manager.cpp

@@ -0,0 +1,110 @@
+// Task 1 — RelationManager: the declaration registry for referential
+// integrity relations. No enforcement, no LMDB index yet (later tasks).
+//
+// Modeled on tests/test_view_manager_paging.cpp: relations are keyed by
+// their project-qualified name in a `_relations` system collection, and
+// loadFromStore() must page explicitly since Query::limit defaults to 100
+// and limit=0 returns nothing (not everything) — the same trap that has
+// already shipped as a bug in ViewManager, PolicyManager and
+// CollectionConfigManager.
+
+#include <iostream>
+#include <string>
+
+#include <nlohmann/json.hpp>
+
+#include "memory_store.hpp"
+#include "relations/relation_manager.hpp"
+
+using namespace smartbotic::database;
+
+namespace {
+
+int g_pass = 0;
+int g_fail = 0;
+
+void check(bool cond, const std::string& msg) {
+    if (cond) { ++g_pass; }
+    else { ++g_fail; std::cerr << "FAIL: " << msg << "\n"; }
+}
+
+struct Fixture {
+    MemoryStore store;
+    Fixture() : store(MemoryStore::Config{}) {
+        store.start();
+    }
+    ~Fixture() { store.stop(); }
+};
+
+void test_relations_are_project_scoped_and_survive_reload() {
+    Fixture f;                       // MemoryStore, started
+    RelationManager rm(f.store);
+    rm.loadFromStore();
+
+    RelationInfo a;
+    a.name = "default:exec_wf";
+    a.child = "default:executions";
+    a.childField = "workflowId";
+    a.parent = "default:workflows";
+    std::string err;
+    check(rm.createRelation(a, err), "created in default");
+
+    RelationInfo b = a;              // SAME bare name, different project
+    b.name = "acme:exec_wf";
+    b.child = "acme:executions";
+    b.parent = "acme:workflows";
+    check(rm.createRelation(b, err), "the same name in another project is allowed");
+
+    check(rm.listRelations("default").size() == 1, "listing is project-filtered");
+    check(rm.listRelations().size() == 2, "empty project lists everything");
+
+    RelationManager fresh(f.store);   // restart
+    fresh.loadFromStore();
+    check(fresh.getRelation("default:exec_wf").has_value(), "survives reload");
+    check(fresh.getRelation("acme:exec_wf").has_value(), "both survive");
+}
+
+void test_cross_project_relation_is_refused() {
+    Fixture f;
+    RelationManager rm(f.store);
+    RelationInfo r;
+    r.name = "default:bad";
+    r.child = "default:executions";
+    r.childField = "workflowId";
+    r.parent = "acme:workflows";      // different env - no txn spans two
+    std::string err;
+    check(!rm.createRelation(r, err), "a cross-project relation is refused");
+    check(err.find("project") != std::string::npos, "and says why");
+}
+
+void test_more_than_one_page_of_relations_loads() {
+    Fixture f;
+    RelationManager rm(f.store);
+    for (int i = 0; i < 250; ++i) {          // Query::limit defaults to 100
+        RelationInfo r;
+        char buf[32];
+        std::snprintf(buf, sizeof(buf), "default:r%03d", i);
+        r.name = buf;
+        r.child = "default:c";
+        r.childField = "p";
+        r.parent = "default:p";
+        std::string err;
+        rm.createRelation(r, err);
+    }
+    RelationManager fresh(f.store);
+    fresh.loadFromStore();
+    check(fresh.listRelations().size() == 250,
+          "all 250 load - a bare Query would stop at 100, as it did for views, "
+          "policies and collection configs");
+}
+
+}  // namespace
+
+int main() {
+    std::cout << "=== test_relation_manager ===\n";
+    test_relations_are_project_scoped_and_survive_reload();
+    test_cross_project_relation_is_refused();
+    test_more_than_one_page_of_relations_loads();
+    std::cout << "passed: " << g_pass << ", failed: " << g_fail << "\n";
+    return g_fail == 0 ? 0 : 1;
+}