|
|
@@ -195,6 +195,31 @@ std::optional<nlohmann::json> resolve_from_yyjson(yyjson_val* root,
|
|
|
return to_json(cur);
|
|
|
}
|
|
|
|
|
|
+// v2.11.0 T3 — the set of parent ids a reference field value names.
|
|
|
+//
|
|
|
+// Relation ids are raw strings (unlike secondary index keys, they need no
|
|
|
+// type-tagged encoding - see relation_index.hpp), so this is simpler than
|
|
|
+// encode_index_keys: a string value names one id, an array value names one
|
|
|
+// id per STRING element (non-string elements are not valid ids and are
|
|
|
+// skipped rather than stringified, since an id must round-trip exactly for
|
|
|
+// relation_index_remove to find it again). Absent/null must never reach
|
|
|
+// here as a reference - callers pass std::nullopt for those, which this
|
|
|
+// treats the same as "no ids".
|
|
|
+std::vector<std::string> extract_relation_ids(const std::optional<nlohmann::json>& v) {
|
|
|
+ std::vector<std::string> out;
|
|
|
+ if (!v || v->is_null()) return out;
|
|
|
+ if (v->is_string()) {
|
|
|
+ out.push_back(v->get<std::string>());
|
|
|
+ } else if (v->is_array()) {
|
|
|
+ for (const auto& el : *v) {
|
|
|
+ if (el.is_string()) out.push_back(el.get<std::string>());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ std::sort(out.begin(), out.end());
|
|
|
+ out.erase(std::unique(out.begin(), out.end()), out.end());
|
|
|
+ return out;
|
|
|
+}
|
|
|
+
|
|
|
void sort_documents(std::vector<smartbotic::database::Document>& docs,
|
|
|
const smartbotic::database::Sort& sort) {
|
|
|
std::sort(docs.begin(), docs.end(),
|
|
|
@@ -485,13 +510,20 @@ void LmdbDocumentStore::put(std::string_view collection,
|
|
|
// asynchronously-maintained index would let a query read entries for a row
|
|
|
// that was never stored, and return silently wrong rows rather than an error.
|
|
|
std::vector<std::pair<std::string, unsigned int>> index_dbis;
|
|
|
- if (!indexed_fields(collection).empty()) {
|
|
|
+ const bool needs_index = !indexed_fields(collection).empty();
|
|
|
+ const bool needs_relations = !relations(collection).empty();
|
|
|
+ if (needs_index || needs_relations) {
|
|
|
MDB_val old{0, nullptr};
|
|
|
const int rc = mdb_get(wtxn.raw(), dbi, &k, &old);
|
|
|
if (rc != MDB_SUCCESS && rc != MDB_NOTFOUND) throw_mdb(rc, "get (pre-index)");
|
|
|
const std::string_view old_payload =
|
|
|
rc == MDB_SUCCESS ? to_sv(old) : std::string_view{};
|
|
|
- maintainIndexes(wtxn, collection, id, old_payload, &doc, index_dbis);
|
|
|
+ if (needs_index) {
|
|
|
+ maintainIndexes(wtxn, collection, id, old_payload, &doc, index_dbis);
|
|
|
+ }
|
|
|
+ if (needs_relations) {
|
|
|
+ maintainRelations(wtxn, collection, id, old_payload, &doc, index_dbis);
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
MDB_val v = to_val(payload);
|
|
|
@@ -642,6 +674,105 @@ void LmdbDocumentStore::maintainIndexes(
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+// -------------------------------------------------------------------------
|
|
|
+// v2.11.0 T3 — relation reverse-index maintenance on child writes.
|
|
|
+// -------------------------------------------------------------------------
|
|
|
+
|
|
|
+void LmdbDocumentStore::set_relations(std::string_view collection,
|
|
|
+ std::vector<RelationRef> rels) {
|
|
|
+ std::lock_guard<std::mutex> lock(relations_mutex_);
|
|
|
+ if (rels.empty()) {
|
|
|
+ relations_.erase(std::string(collection));
|
|
|
+ } else {
|
|
|
+ relations_[std::string(collection)] = std::move(rels);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+std::vector<RelationRef> LmdbDocumentStore::relations(std::string_view collection) {
|
|
|
+ std::lock_guard<std::mutex> lock(relations_mutex_);
|
|
|
+ auto it = relations_.find(std::string(collection));
|
|
|
+ if (it == relations_.end()) return {};
|
|
|
+ return it->second;
|
|
|
+}
|
|
|
+
|
|
|
+void LmdbDocumentStore::maintainRelations(
|
|
|
+ WriteTxn& wtxn,
|
|
|
+ std::string_view collection,
|
|
|
+ std::string_view id,
|
|
|
+ std::string_view old_payload,
|
|
|
+ const smartbotic::database::Document* new_doc,
|
|
|
+ std::vector<std::pair<std::string, unsigned int>>& to_cache) {
|
|
|
+
|
|
|
+ const auto rels = relations(collection);
|
|
|
+ if (rels.empty()) return; // collections with no relations pay nothing
|
|
|
+
|
|
|
+ // Old references come from the STORED bytes, parsed with yyjson and
|
|
|
+ // resolved one field at a time - never materialised into a Document,
|
|
|
+ // for the same reason maintainIndexes avoids it (decode_document was
|
|
|
+ // measured at 88% of write cost on a large row).
|
|
|
+ std::unordered_map<std::string, std::vector<std::string>> old_ids_by_field;
|
|
|
+ if (!old_payload.empty()) {
|
|
|
+ yyjson_doc* d = yyjson_read(old_payload.data(), old_payload.size(), 0);
|
|
|
+ if (d) {
|
|
|
+ yyjson_val* root = yyjson_doc_get_root(d);
|
|
|
+ for (const auto& r : rels) {
|
|
|
+ if (old_ids_by_field.count(r.childField)) continue;
|
|
|
+ old_ids_by_field[r.childField] =
|
|
|
+ extract_relation_ids(resolve_from_yyjson(root, r.childField));
|
|
|
+ }
|
|
|
+ yyjson_doc_free(d);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const auto& r : rels) {
|
|
|
+ std::vector<std::string> new_ids;
|
|
|
+ if (new_doc != nullptr) {
|
|
|
+ // resolveFilterValue is the SAME resolver queries/filters use, so a
|
|
|
+ // relation and a query can never disagree about which field a
|
|
|
+ // dot-path names.
|
|
|
+ new_ids = extract_relation_ids(
|
|
|
+ filter_eval::resolveFilterValue(*new_doc, r.childField));
|
|
|
+ }
|
|
|
+ std::vector<std::string> old_ids;
|
|
|
+ if (auto it = old_ids_by_field.find(r.childField); it != old_ids_by_field.end()) {
|
|
|
+ old_ids = it->second;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Unchanged reference costs no index write - the common case for an
|
|
|
+ // update that touches other fields. Both sides are sorted/deduped by
|
|
|
+ // extract_relation_ids, so this comparison is exact.
|
|
|
+ if (old_ids == new_ids) continue;
|
|
|
+
|
|
|
+ std::vector<std::string> to_remove;
|
|
|
+ std::vector<std::string> to_add;
|
|
|
+ std::set_difference(old_ids.begin(), old_ids.end(), new_ids.begin(), new_ids.end(),
|
|
|
+ std::back_inserter(to_remove));
|
|
|
+ std::set_difference(new_ids.begin(), new_ids.end(), old_ids.begin(), old_ids.end(),
|
|
|
+ std::back_inserter(to_add));
|
|
|
+ if (to_remove.empty() && to_add.empty()) continue;
|
|
|
+
|
|
|
+ const std::string sub = relation_index_subdb(r.name);
|
|
|
+ const unsigned int dbi = open_for_write(wtxn, sub, MDB_DUPSORT);
|
|
|
+ to_cache.emplace_back(sub, dbi);
|
|
|
+
|
|
|
+ for (const auto& parentId : to_remove) {
|
|
|
+ MDB_val pk = to_val(parentId);
|
|
|
+ MDB_val cv = to_val(id);
|
|
|
+ // DUPSORT: passing the data removes just this (parent, child) pair -
|
|
|
+ // siblings of the same parent are untouched.
|
|
|
+ const int rc = mdb_del(wtxn.raw(), dbi, &pk, &cv);
|
|
|
+ if (rc != MDB_SUCCESS && rc != MDB_NOTFOUND) throw_mdb(rc, "relation index del");
|
|
|
+ }
|
|
|
+ for (const auto& parentId : to_add) {
|
|
|
+ MDB_val pk = to_val(parentId);
|
|
|
+ MDB_val cv = to_val(id);
|
|
|
+ // MDB_NODUPDATA makes a repeat put a no-op rather than an error.
|
|
|
+ const int rc = mdb_put(wtxn.raw(), dbi, &pk, &cv, MDB_NODUPDATA);
|
|
|
+ if (rc != MDB_SUCCESS && rc != MDB_KEYEXIST) throw_mdb(rc, "relation index put");
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
std::optional<uint64_t>
|
|
|
LmdbDocumentStore::index_count_eq(std::string_view collection,
|
|
|
const std::string& field,
|
|
|
@@ -1154,11 +1285,18 @@ bool LmdbDocumentStore::del(std::string_view collection, std::string_view id) {
|
|
|
// Remove index entries before the row goes, while its stored bytes are
|
|
|
// still readable - they are the only record of which index keys it owns.
|
|
|
std::vector<std::pair<std::string, unsigned int>> index_dbis;
|
|
|
- if (!indexed_fields(collection).empty()) {
|
|
|
+ const bool needs_index = !indexed_fields(collection).empty();
|
|
|
+ const bool needs_relations = !relations(collection).empty();
|
|
|
+ if (needs_index || needs_relations) {
|
|
|
MDB_val old{0, nullptr};
|
|
|
const int grc = mdb_get(wtxn.raw(), dbi, &k, &old);
|
|
|
if (grc == MDB_SUCCESS) {
|
|
|
- maintainIndexes(wtxn, collection, id, to_sv(old), nullptr, index_dbis);
|
|
|
+ if (needs_index) {
|
|
|
+ maintainIndexes(wtxn, collection, id, to_sv(old), nullptr, index_dbis);
|
|
|
+ }
|
|
|
+ if (needs_relations) {
|
|
|
+ maintainRelations(wtxn, collection, id, to_sv(old), nullptr, index_dbis);
|
|
|
+ }
|
|
|
} else if (grc != MDB_NOTFOUND) {
|
|
|
throw_mdb(grc, "get (pre-index del)");
|
|
|
}
|