|
|
@@ -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
|