Explorar el Código

test(storage): port filter+sort+projection coverage to DocumentStore

Phase C Stage 2 Task 2.3 — extends test_document_store with the v1.x
operator parity matrix against the LmdbDocumentStore backend.

  EQ (top-level + nested dotted path), NE, GT/GTE/LT/LTE, IN, CONTAINS,
  EXISTS (true/false), REGEX, SEARCH (full-text across id + strings),
  sort asc/desc, projection (include-only subset), limit+offset
  pagination, total_matched / has_more counters.

Filter semantics match service/src/memory_store.cpp::matchesFilters
bit-for-bit — same special-cased struct members (_id, _created_at,
_updated_at, _version), same dotted-path resolution, same regex /
SEARCH behaviour. The reference implementation is read-only; the new
predicates live in document_store_lmdb.cpp's anonymous namespace.

10/10 ctest targets green.
fszontagh hace 2 meses
padre
commit
237658f23a
Se han modificado 1 ficheros con 373 adiciones y 2 borrados
  1. 373 2
      tests/test_document_store.cpp

+ 373 - 2
tests/test_document_store.cpp

@@ -30,7 +30,11 @@
 namespace fs = std::filesystem;
 
 using smartbotic::database::Document;
+using smartbotic::database::Filter;
+using smartbotic::database::FilterOp;
 using smartbotic::database::Query;
+using smartbotic::database::Sort;
+using smartbotic::db::storage::DocumentStore;
 using smartbotic::db::storage::LmdbDocumentStore;
 using smartbotic::db::storage::LmdbEnv;
 using smartbotic::db::storage::LmdbEnvOpts;
@@ -101,6 +105,13 @@ Document make_zoe_doc(std::string_view id, std::string_view name,
     return make_doc(id, "zoe", data);
 }
 
+bool docs_contain_id(const std::vector<Document>& docs, std::string_view id) {
+    for (const auto& d : docs) {
+        if (d.id == id) return true;
+    }
+    return false;
+}
+
 // =========================================================================
 // Task 2.1 — round-trip / absence / count / collection / scan basics
 // =========================================================================
@@ -336,6 +347,354 @@ void test_scan_returns_empty_for_nonexistent_collection() {
     check(!r.has_more, "has_more = false");
 }
 
+// =========================================================================
+// Task 2.3 — filter + sort + projection parity
+// =========================================================================
+
+// Seed 10 docs into `c` exercising the operator matrix.
+//
+// docs[0..9] field map:
+//   _id = "u0".."u9"
+//   _created_at = 1000 + i*100 (struct member)
+//   name = cycling through 10 distinct names
+//   age  = 20 + i*3
+//   tags = subset of {"hot", "new", "vip", "stale"}
+//   email = "u<i>@example.com"
+//   meta.score = (i % 4) * 1.5
+//   pinned = (i % 2 == 0)
+void seed_filter_corpus(DocumentStore& store) {
+    const std::vector<std::string> names = {
+        "Alice", "Bob", "Carol", "Dave", "Eve",
+        "Frank", "Grace", "Heidi", "Ivan", "Judy"
+    };
+    const std::vector<std::vector<std::string>> tag_sets = {
+        {"hot"},
+        {"new", "vip"},
+        {"hot", "vip"},
+        {},
+        {"stale"},
+        {"hot", "new"},
+        {"vip"},
+        {"hot", "stale"},
+        {"new"},
+        {"vip", "new"}
+    };
+    for (int i = 0; i < 10; ++i) {
+        std::string id = "u" + std::to_string(i);
+        nlohmann::json data = {
+            {"_id", id},
+            {"name", names[i]},
+            {"age", 20 + i * 3},
+            {"tags", tag_sets[i]},
+            {"email", "u" + std::to_string(i) + "@example.com"},
+            {"meta", {
+                {"score", (i % 4) * 1.5}
+            }},
+            {"pinned", (i % 2 == 0)}
+        };
+        Document d = make_doc(id, "c", data, 1000 + i * 100);
+        store.put("c", id, d);
+    }
+}
+
+void test_scan_filter_eq_top_level() {
+    TmpEnv t("filter-eq");
+    LmdbDocumentStore store(t.env);
+    seed_filter_corpus(store);
+
+    Query q;
+    Filter f;
+    f.field = "name";
+    f.op = FilterOp::EQ;
+    f.value = "Carol";
+    q.filters.push_back(f);
+    auto r = store.scan("c", q);
+    check(r.documents.size() == 1, "EQ name=Carol returns 1");
+    check(r.documents[0].id == "u2", "EQ name=Carol matches u2");
+    check(r.total_matched == 1, "total_matched=1");
+}
+
+void test_scan_filter_eq_nested_meta_score() {
+    TmpEnv t("filter-eq-nested");
+    LmdbDocumentStore store(t.env);
+    seed_filter_corpus(store);
+
+    // i=1 -> meta.score = 1.5; i=5 -> 1.5; i=9 -> 1.5.
+    Query q;
+    Filter f;
+    f.field = "meta.score";
+    f.op = FilterOp::EQ;
+    f.value = 1.5;
+    q.filters.push_back(f);
+    auto r = store.scan("c", q);
+    check(r.total_matched == 3,
+          "EQ meta.score=1.5 matches 3 docs (i=1,5,9)");
+    check(docs_contain_id(r.documents, "u1") &&
+          docs_contain_id(r.documents, "u5") &&
+          docs_contain_id(r.documents, "u9"),
+          "EQ meta.score=1.5 returns the expected ids");
+}
+
+void test_scan_filter_ne_on_string_field() {
+    TmpEnv t("filter-ne");
+    LmdbDocumentStore store(t.env);
+    seed_filter_corpus(store);
+
+    Query q;
+    Filter f;
+    f.field = "name";
+    f.op = FilterOp::NE;
+    f.value = "Alice";
+    q.filters.push_back(f);
+    auto r = store.scan("c", q);
+    check(r.total_matched == 9, "NE name!=Alice matches 9");
+    check(!docs_contain_id(r.documents, "u0"), "NE excludes u0");
+}
+
+void test_scan_filter_numeric_comparisons() {
+    TmpEnv t("filter-cmp");
+    LmdbDocumentStore store(t.env);
+    seed_filter_corpus(store);
+
+    auto run = [&](FilterOp op, uint64_t target) {
+        Query q;
+        Filter f;
+        f.field = "_created_at";
+        f.op = op;
+        f.value = target;
+        q.filters.push_back(f);
+        return store.scan("c", q);
+    };
+
+    // _created_at values: 1000, 1100, ..., 1900.
+    auto r_gt  = run(FilterOp::GT,  1500);
+    check(r_gt.total_matched == 4,
+          "GT _created_at>1500 matches 4 (1600..1900)");
+
+    auto r_gte = run(FilterOp::GTE, 1500);
+    check(r_gte.total_matched == 5,
+          "GTE _created_at>=1500 matches 5 (1500..1900)");
+
+    auto r_lt  = run(FilterOp::LT,  1500);
+    check(r_lt.total_matched == 5,
+          "LT _created_at<1500 matches 5 (1000..1400)");
+
+    auto r_lte = run(FilterOp::LTE, 1500);
+    check(r_lte.total_matched == 6,
+          "LTE _created_at<=1500 matches 6 (1000..1500)");
+}
+
+void test_scan_filter_in_array_literal() {
+    TmpEnv t("filter-in");
+    LmdbDocumentStore store(t.env);
+    seed_filter_corpus(store);
+
+    Query q;
+    Filter f;
+    f.field = "name";
+    f.op = FilterOp::IN;
+    f.value = nlohmann::json::array({"Alice", "Carol", "Eve"});
+    q.filters.push_back(f);
+    auto r = store.scan("c", q);
+    check(r.total_matched == 3, "IN name in {Alice,Carol,Eve} matches 3");
+    check(docs_contain_id(r.documents, "u0") &&
+          docs_contain_id(r.documents, "u2") &&
+          docs_contain_id(r.documents, "u4"),
+          "IN matches u0,u2,u4");
+}
+
+void test_scan_filter_contains_array_field() {
+    TmpEnv t("filter-contains");
+    LmdbDocumentStore store(t.env);
+    seed_filter_corpus(store);
+
+    Query q;
+    Filter f;
+    f.field = "tags";
+    f.op = FilterOp::CONTAINS;
+    f.value = "hot";
+    q.filters.push_back(f);
+    auto r = store.scan("c", q);
+    // tags containing "hot": u0, u2, u5, u7 = 4 docs.
+    check(r.total_matched == 4, "CONTAINS tags has \"hot\" matches 4");
+    check(docs_contain_id(r.documents, "u0") &&
+          docs_contain_id(r.documents, "u2") &&
+          docs_contain_id(r.documents, "u5") &&
+          docs_contain_id(r.documents, "u7"),
+          "CONTAINS returns the expected ids");
+}
+
+void test_scan_filter_exists() {
+    TmpEnv t("filter-exists");
+    LmdbDocumentStore store(t.env);
+    seed_filter_corpus(store);
+
+    // Add a doc missing the "email" field.
+    nlohmann::json data = {
+        {"_id", "n0"},
+        {"name", "Noemail"},
+        {"age", 50},
+        {"pinned", false}
+    };
+    store.put("c", "n0", make_doc("n0", "c", data, 2000));
+
+    {
+        Query q;
+        Filter f;
+        f.field = "email";
+        f.op = FilterOp::EXISTS;
+        f.value = true;
+        q.filters.push_back(f);
+        auto r = store.scan("c", q);
+        check(r.total_matched == 10,
+              "EXISTS email=true matches the 10 corpus docs (not n0)");
+    }
+    {
+        Query q;
+        Filter f;
+        f.field = "email";
+        f.op = FilterOp::EXISTS;
+        f.value = false;
+        q.filters.push_back(f);
+        auto r = store.scan("c", q);
+        check(r.total_matched == 1, "EXISTS email=false matches just n0");
+        check(r.documents[0].id == "n0",
+              "EXISTS email=false matches n0");
+    }
+}
+
+void test_scan_filter_regex() {
+    TmpEnv t("filter-regex");
+    LmdbDocumentStore store(t.env);
+    seed_filter_corpus(store);
+
+    Query q;
+    Filter f;
+    f.field = "email";
+    f.op = FilterOp::REGEX;
+    f.value = "^u[0-2]@";  // u0, u1, u2.
+    q.filters.push_back(f);
+    auto r = store.scan("c", q);
+    check(r.total_matched == 3, "REGEX matches u0..u2");
+    check(docs_contain_id(r.documents, "u0") &&
+          docs_contain_id(r.documents, "u1") &&
+          docs_contain_id(r.documents, "u2"),
+          "REGEX returns u0,u1,u2");
+}
+
+void test_scan_filter_search() {
+    TmpEnv t("filter-search");
+    LmdbDocumentStore store(t.env);
+    seed_filter_corpus(store);
+
+    // SEARCH matches across _id + any string field, case-insensitive.
+    // "carol" appears in u2's name; nowhere else.
+    Query q;
+    Filter f;
+    f.field = "";
+    f.op = FilterOp::SEARCH;
+    f.value = "carol";
+    q.filters.push_back(f);
+    auto r = store.scan("c", q);
+    check(r.total_matched == 1, "SEARCH carol matches 1 doc");
+    check(r.documents[0].id == "u2", "SEARCH carol matches u2");
+}
+
+void test_scan_sort_asc_and_desc() {
+    TmpEnv t("sort");
+    LmdbDocumentStore store(t.env);
+    seed_filter_corpus(store);
+
+    {
+        Query q;
+        q.sort = Sort{"age", false};  // ascending
+        auto r = store.scan("c", q);
+        check(r.documents.size() == 10, "asc returns all 10");
+        check(r.documents.front().id == "u0",
+              "asc age has u0 first (age=20)");
+        check(r.documents.back().id == "u9",
+              "asc age has u9 last (age=47)");
+    }
+    {
+        Query q;
+        q.sort = Sort{"age", true};  // descending
+        auto r = store.scan("c", q);
+        check(r.documents.size() == 10, "desc returns all 10");
+        check(r.documents.front().id == "u9",
+              "desc age has u9 first");
+        check(r.documents.back().id == "u0",
+              "desc age has u0 last");
+    }
+}
+
+void test_scan_projection_include_subset() {
+    TmpEnv t("projection-include");
+    LmdbDocumentStore store(t.env);
+    seed_filter_corpus(store);
+
+    Query q;
+    q.projection = {"name", "age"};
+    q.limit = 3;
+    q.sort = Sort{"_id", false};  // deterministic ordering
+    auto r = store.scan("c", q);
+    check(r.documents.size() == 3, "projection limit 3");
+    for (const auto& d : r.documents) {
+        const auto& data = d.data();
+        check(data.contains("name"), "projection keeps name");
+        check(data.contains("age"), "projection keeps age");
+        check(!data.contains("email"), "projection drops email");
+        check(!data.contains("tags"), "projection drops tags");
+        check(!data.contains("meta"), "projection drops meta");
+    }
+}
+
+void test_scan_limit_offset_pagination() {
+    TmpEnv t("pagination");
+    LmdbDocumentStore store(t.env);
+    seed_filter_corpus(store);
+
+    Query q;
+    q.sort = Sort{"_id", false};
+    q.limit = 4;
+    q.offset = 0;
+    auto p1 = store.scan("c", q);
+    check(p1.documents.size() == 4, "page1 size 4");
+    check(p1.documents.front().id == "u0", "page1 starts at u0");
+    check(p1.total_matched == 10, "page1 total_matched=10");
+    check(p1.has_more, "page1 has_more=true");
+
+    q.offset = 4;
+    auto p2 = store.scan("c", q);
+    check(p2.documents.size() == 4, "page2 size 4");
+    check(p2.documents.front().id == "u4", "page2 starts at u4");
+    check(p2.has_more, "page2 has_more=true");
+
+    q.offset = 8;
+    auto p3 = store.scan("c", q);
+    check(p3.documents.size() == 2, "page3 size 2");
+    check(!p3.has_more, "page3 has_more=false");
+}
+
+void test_scan_total_matched_and_has_more_counters() {
+    TmpEnv t("counters");
+    LmdbDocumentStore store(t.env);
+    seed_filter_corpus(store);
+
+    Query q;
+    Filter f;
+    f.field = "age";
+    f.op = FilterOp::GTE;
+    f.value = 30;
+    q.filters.push_back(f);
+    q.limit = 2;
+    q.offset = 0;
+    auto r = store.scan("c", q);
+    // age >= 30 is i>=4 (age=32,..,47) — 6 docs.
+    check(r.total_matched == 6, "total_matched counts pre-limit matches");
+    check(r.documents.size() == 2, "limit cuts to 2");
+    check(r.has_more, "has_more=true (4 remain)");
+}
+
 }  // namespace
 
 int main() {
@@ -360,8 +719,20 @@ int main() {
     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.
+    // Task 2.3 — filter / sort / projection parity.
+    test_scan_filter_eq_top_level();
+    test_scan_filter_eq_nested_meta_score();
+    test_scan_filter_ne_on_string_field();
+    test_scan_filter_numeric_comparisons();
+    test_scan_filter_in_array_literal();
+    test_scan_filter_contains_array_field();
+    test_scan_filter_exists();
+    test_scan_filter_regex();
+    test_scan_filter_search();
+    test_scan_sort_asc_and_desc();
+    test_scan_projection_include_subset();
+    test_scan_limit_offset_pagination();
+    test_scan_total_matched_and_has_more_counters();
 
     std::cout << "test_document_store: all passed\n";
     return 0;