فهرست منبع

feat(relations): T8 - per-collection relations_enforced + uniqueFields storage

Adds two fields to CollectionCfg, following the versioningEnabled (v2.4.5)
precedent exactly:

- relationsEnforced (bool, default true): declaring a relation names its
  child and parent explicitly, so the declaration IS the opt-in - unlike
  versioningEnabled/indexedFields which default off. Persisted through
  toJson/fromJson with absent-on-disk meaning "enforced", so every pre-2.11
  config record keeps working untouched. Wired to the wire as
  `optional bool relations_enforced` on CollectionConfig (field 3), read
  with has_relations_enforced() in ConfigureCollection so a precision-only
  call cannot silently disable enforcement - the exact trap v2.4.5 hit with
  versioning_enabled.

- uniqueFields (vector<string>): persisted only. No RPC surface, no proto
  field - deliberately deferred to a later task that activates enforcement.
  A constraint that is advertised but unenforced is worse than an absent one.

No boot-time arming needed: relationsEnforced is read per-call (Task 4's
delete enforcement reads configFor(collection).relationsEnforced), not
pushed into another component the way indexedFields is pushed into
LmdbDocumentStore::set_indexed_fields().

Test: test_timestamp_precision.cpp gains
test_relations_enforced_is_a_partial_update (check()-based, not assert),
covering the default, a precision-only call leaving it alone, and turning
it off without resetting precision.
fszontagh 1 ماه پیش
والد
کامیت
035a0f0dea

+ 12 - 0
proto/database.proto

@@ -908,6 +908,18 @@ message CollectionConfig {
     // which means "unlimited", not "off".
     optional bool versioning_enabled = 2;
 
+    // v2.11.0 T8 — whether declared relations are enforced for this collection.
+    //
+    // `optional` for the same reason versioning_enabled is: a plain bool
+    // defaults to false, so a ConfigureCollection call that meant to set
+    // only timestamp_precision would silently switch relation enforcement
+    // OFF. With presence, an absent field means "leave unchanged".
+    //
+    // Defaults to true (enforced) when never explicitly configured -
+    // declaring a relation names its child and parent explicitly, so the
+    // declaration IS the opt-in.
+    optional bool relations_enforced = 3;
+
     // Room for future per-collection knobs
 }
 

+ 8 - 1
service/src/config/collection_config_manager.cpp

@@ -13,7 +13,9 @@ nlohmann::json toJson(const CollectionCfg& cfg) {
     return {
         {"timestamp_precision", cfg.timestampPrecision},
         {"versioning_enabled", cfg.versioningEnabled},
-        {"indexed_fields", cfg.indexedFields}
+        {"indexed_fields", cfg.indexedFields},
+        {"relations_enforced", cfg.relationsEnforced},
+        {"unique_fields", cfg.uniqueFields}
     };
 }
 
@@ -26,6 +28,11 @@ CollectionCfg fromJson(const nlohmann::json& j) {
     c.versioningEnabled = j.value("versioning_enabled", true);
     // Absent means no indexes, which is what every pre-v2.9.0 config record has.
     c.indexedFields = j.value("indexed_fields", std::vector<std::string>{});
+    // v2.11.0 T8 — absent on every pre-2.11 record, which must keep enforcing
+    // (declaring a relation is the opt-in) and must keep having no unique
+    // fields (nothing enforces those yet).
+    c.relationsEnforced = j.value("relations_enforced", true);
+    c.uniqueFields = j.value("unique_fields", std::vector<std::string>{});
     return c;
 }
 

+ 26 - 0
service/src/config/collection_config_manager.hpp

@@ -49,6 +49,32 @@ struct CollectionCfg {
     // set_indexed_fields(). If that did not happen, writes would stop maintaining
     // an index that queries still consult - stale rows returned as if current.
     std::vector<std::string> indexedFields;
+
+    // v2.11.0 T8 — whether declared relations are enforced for this collection.
+    //
+    // Defaults to true, unlike versioningEnabled/indexedFields which default
+    // to "off"/empty: declaring a relation names its child and parent
+    // explicitly, so the declaration IS the opt-in. A declared constraint
+    // that silently did nothing would be worse than no constraint at all.
+    //
+    // Lives here for the same reason versioningEnabled does: `_collection_meta`
+    // is an ordinary collection, so it is WAL'd and snapshotted for free.
+    //
+    // Consumed by Task 4's delete enforcement (restrict/no_action on delete):
+    // read via configFor(collection).relationsEnforced before refusing a
+    // delete that would orphan a child row. Nothing else applies this at
+    // boot yet - there is no separate "arming" step, because enforcement is
+    // just a per-call read of this flag, not state that needs to be pushed
+    // into another component the way indexedFields is pushed into
+    // LmdbDocumentStore::set_indexed_fields().
+    bool relationsEnforced = true;
+
+    // v2.11.0 T8 — persisted only. Nothing enforces uniqueness yet (Task 11
+    // activates it). A constraint that is advertised but unenforced would be
+    // worse than an absent one, so this deliberately has no RPC surface and
+    // no proto field yet - it exists here purely so the storage shape is
+    // settled before enforcement lands.
+    std::vector<std::string> uniqueFields;
 };
 
 /**

+ 4 - 0
service/src/database_grpc_impl.cpp

@@ -2992,6 +2992,9 @@ grpc::Status DatabaseGrpcImpl::ConfigureCollection(
         if (request->config().has_versioning_enabled()) {
             cfg.versioningEnabled = request->config().versioning_enabled();
         }
+        if (request->config().has_relations_enforced()) {
+            cfg.relationsEnforced = request->config().relations_enforced();
+        }
 
         // Idempotent: setConfig handles create-or-update
         std::string err;
@@ -3020,6 +3023,7 @@ grpc::Status DatabaseGrpcImpl::GetCollectionConfig(
     auto cfg = config_manager_.configFor(request->collection());
     response->mutable_config()->set_timestamp_precision(cfg.timestampPrecision);
     response->mutable_config()->set_versioning_enabled(cfg.versioningEnabled);
+    response->mutable_config()->set_relations_enforced(cfg.relationsEnforced);
     response->set_found(config_manager_.hasExplicitConfig(request->collection()));
     return grpc::Status::OK;
 }

+ 73 - 1
tests/test_timestamp_precision.cpp

@@ -20,6 +20,7 @@
 #include <cstdint>
 #include <iostream>
 #include <nlohmann/json.hpp>
+#include <optional>
 #include <set>
 #include <string>
 
@@ -31,6 +32,18 @@ using smartbotic::database::MemoryStore;
 
 namespace {
 
+int g_pass = 0;
+int g_fail = 0;
+
+void check(bool cond, const char* msg) {
+    if (cond) {
+        ++g_pass;
+    } else {
+        ++g_fail;
+        std::cerr << "FAIL: " << msg << "\n";
+    }
+}
+
 // Anything below 10^15 fits comfortably in the ms-since-epoch range (≈ 2001..),
 // anything above is unambiguously ns-since-epoch in the current era.
 constexpr uint64_t NS_THRESHOLD = 1'000'000'000'000'000ULL;  // 10^15
@@ -49,6 +62,27 @@ Document makeDoc(const std::string& id = "") {
     return d;
 }
 
+// Mirrors the partial-update shape of DatabaseGrpcImpl::ConfigureCollection
+// (service/src/database_grpc_impl.cpp): start from the collection's current
+// config and overlay only the fields the caller actually set. An empty
+// precision string and a nullopt relations_enforced both mean "leave
+// unchanged" here, exactly as they do on the wire.
+void configureCollection(CollectionConfigManager& mgr,
+                          const std::string& collection,
+                          const std::string& precision,
+                          std::optional<bool> relationsEnforced) {
+    CollectionCfg cfg = mgr.configFor(collection);
+    if (!precision.empty()) {
+        cfg.timestampPrecision = precision;
+    }
+    if (relationsEnforced.has_value()) {
+        cfg.relationsEnforced = relationsEnforced.value();
+    }
+    std::string err;
+    bool ok = mgr.setConfig(collection, cfg, err);
+    check(ok, "setConfig should succeed for a partial-update configure call");
+}
+
 } // anonymous namespace
 
 void test_default_is_ns() {
@@ -195,6 +229,38 @@ void test_default_for_unknown_collection() {
     std::cout << "PASS: default config returned for unknown collection\n";
 }
 
+// v2.11.0 T8 — relations_enforced is a partial update, same shape as
+// versioning_enabled (v2.4.5). Declaring a relation names its child and
+// parent explicitly, so the declaration IS the opt-in: the default must be
+// enforced, and an unrelated configure call (e.g. precision-only) must never
+// silently disable it.
+void test_relations_enforced_is_a_partial_update() {
+    MemoryStore store(defaultConfig());
+    store.start();
+    CollectionConfigManager mgr(store);
+    store.setConfigManager(&mgr);
+
+    CollectionCfg cfg = mgr.configFor("c");
+    check(cfg.relationsEnforced,
+          "defaults to enforced - declaring a relation IS the opt-in, so a "
+          "declared constraint must not silently do nothing");
+
+    // Change ONLY precision. A plain proto3 bool defaults to false and would
+    // silently disable enforcement here - the exact trap v2.4.5 hit with
+    // versioning_enabled, which is why the field is `optional`.
+    configureCollection(mgr, "c", /*precision=*/"ms", /*relationsEnforced=*/std::nullopt);
+    check(mgr.configFor("c").relationsEnforced,
+          "a precision-only call leaves enforcement alone");
+
+    configureCollection(mgr, "c", "", /*relationsEnforced=*/false);
+    check(!mgr.configFor("c").relationsEnforced, "and it can be turned off");
+    check(mgr.configFor("c").timestampPrecision == "ms",
+          "without resetting precision");
+
+    store.stop();
+    std::cout << "PASS: relations_enforced is a partial update\n";
+}
+
 int main() {
     test_default_is_ns();
     test_ns_yields_unique_timestamps_in_tight_loop();
@@ -202,6 +268,12 @@ int main() {
     test_idempotent_configure();
     test_cache_reflects_configure_immediately();
     test_default_for_unknown_collection();
-    std::cout << "\nAll timestamp precision tests PASSED!\n";
+    test_relations_enforced_is_a_partial_update();
+
+    if (g_fail > 0) {
+        std::cerr << "\n" << g_fail << " check(s) FAILED (" << g_pass << " passed)\n";
+        return 1;
+    }
+    std::cout << "\nAll timestamp precision tests PASSED! (" << g_pass << " checks)\n";
     return 0;
 }