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