Эх сурвалжийг харах

feat(views): projection helper with nested path support + unit tests

fszontagh 3 сар өмнө
parent
commit
f2c1921764

+ 1 - 0
service/CMakeLists.txt

@@ -26,6 +26,7 @@ set(DATABASE_SERVICE_SOURCES
     src/replication/sync_protocol.cpp
     src/replication/conflict_resolver.cpp
     src/migrations/migration_runner.cpp
+    src/views/projection.cpp
 )
 
 # Create executable

+ 99 - 0
service/src/views/projection.cpp

@@ -0,0 +1,99 @@
+#include "projection.hpp"
+
+#include <sstream>
+
+namespace smartbotic::database {
+
+namespace {
+
+const std::vector<std::string> METADATA_FIELDS = {
+    "_id", "_version", "_created_at", "_updated_at", "_created_by", "_updated_by"
+};
+
+std::vector<std::string> splitPath(const std::string& path) {
+    std::vector<std::string> parts;
+    std::stringstream ss(path);
+    std::string part;
+    while (std::getline(ss, part, '.')) {
+        if (!part.empty()) parts.push_back(part);
+    }
+    return parts;
+}
+
+void copyPath(const nlohmann::json& src, nlohmann::json& dst,
+              const std::vector<std::string>& parts, size_t idx) {
+    if (idx >= parts.size()) return;
+    const std::string& key = parts[idx];
+    if (!src.is_object() || !src.contains(key)) return;
+
+    if (idx == parts.size() - 1) {
+        dst[key] = src[key];
+        return;
+    }
+
+    if (!src[key].is_object()) {
+        dst[key] = src[key];
+        return;
+    }
+    if (!dst.contains(key) || !dst[key].is_object()) {
+        dst[key] = nlohmann::json::object();
+    }
+    copyPath(src[key], dst[key], parts, idx + 1);
+}
+
+void deletePath(nlohmann::json& dst, const std::vector<std::string>& parts, size_t idx) {
+    if (idx >= parts.size()) return;
+    const std::string& key = parts[idx];
+    if (!dst.is_object() || !dst.contains(key)) return;
+
+    if (idx == parts.size() - 1) {
+        dst.erase(key);
+        return;
+    }
+
+    if (dst[key].is_object()) {
+        deletePath(dst[key], parts, idx + 1);
+    }
+}
+
+} // anonymous namespace
+
+nlohmann::json applyProjection(const nlohmann::json& document,
+                                const std::vector<std::string>& include,
+                                const std::vector<std::string>& exclude) {
+    if (include.empty() && exclude.empty()) {
+        return document;
+    }
+
+    if (!include.empty()) {
+        nlohmann::json result = nlohmann::json::object();
+        for (const auto& path : include) {
+            auto parts = splitPath(path);
+            if (parts.empty()) continue;
+            copyPath(document, result, parts, 0);
+        }
+        for (const auto& meta : METADATA_FIELDS) {
+            if (document.contains(meta)) {
+                result[meta] = document[meta];
+            }
+        }
+        return result;
+    }
+
+    nlohmann::json result = document;
+    for (const auto& path : exclude) {
+        auto parts = splitPath(path);
+        if (parts.empty()) continue;
+        if (parts.size() == 1) {
+            bool isMeta = false;
+            for (const auto& meta : METADATA_FIELDS) {
+                if (parts[0] == meta) { isMeta = true; break; }
+            }
+            if (isMeta) continue;
+        }
+        deletePath(result, parts, 0);
+    }
+    return result;
+}
+
+} // namespace smartbotic::database

+ 27 - 0
service/src/views/projection.hpp

@@ -0,0 +1,27 @@
+#pragma once
+
+#include <nlohmann/json.hpp>
+#include <string>
+#include <vector>
+
+namespace smartbotic::database {
+
+/**
+ * Apply view projection (include/exclude) to a document.
+ *
+ * Semantics:
+ *  - include non-empty: only those paths are kept (exclude ignored)
+ *  - include empty, exclude non-empty: all fields except excluded paths
+ *  - both empty: document returned unchanged
+ *
+ * Paths use dot notation: "user.profile.name". Stops at arrays.
+ * Missing intermediate paths are silently skipped.
+ *
+ * Metadata fields (_id, _version, _created_at, _updated_at, _created_by, _updated_by)
+ * are always preserved regardless of include/exclude.
+ */
+nlohmann::json applyProjection(const nlohmann::json& document,
+                                const std::vector<std::string>& include,
+                                const std::vector<std::string>& exclude);
+
+} // namespace smartbotic::database

+ 20 - 0
tests/CMakeLists.txt

@@ -32,6 +32,26 @@ endif()
 find_package(Threads REQUIRED)
 target_link_libraries(test_vector_storage PRIVATE Threads::Threads)
 
+# View projection unit test
+set(TEST_VIEWS_SOURCES
+    test_views.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/views/projection.cpp
+)
+
+add_executable(test_views ${TEST_VIEWS_SOURCES})
+
+target_include_directories(test_views PRIVATE
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src
+)
+
+# nlohmann/json
+if(TARGET nlohmann_json::nlohmann_json)
+    target_link_libraries(test_views PRIVATE nlohmann_json::nlohmann_json)
+else()
+    target_include_directories(test_views PRIVATE ${NLOHMANN_JSON_INCLUDE_DIRS})
+endif()
+
 # Register with CTest
 enable_testing()
 add_test(NAME vector_storage COMMAND test_vector_storage)
+add_test(NAME views COMMAND test_views)

+ 114 - 0
tests/test_views.cpp

@@ -0,0 +1,114 @@
+#include "../service/src/views/projection.hpp"
+
+#include <cassert>
+#include <iostream>
+#include <nlohmann/json.hpp>
+
+using smartbotic::database::applyProjection;
+
+void test_empty_projection_returns_document_unchanged() {
+    nlohmann::json doc = {{"name", "Alice"}, {"age", 30}};
+    auto result = applyProjection(doc, {}, {});
+    assert(result == doc);
+    std::cout << "PASS: empty projection returns document unchanged\n";
+}
+
+void test_include_keeps_only_listed_fields() {
+    nlohmann::json doc = {{"name", "Alice"}, {"email", "a@x.com"}, {"password", "secret"}};
+    auto result = applyProjection(doc, {"name", "email"}, {});
+    assert(result.contains("name"));
+    assert(result.contains("email"));
+    assert(!result.contains("password"));
+    std::cout << "PASS: include keeps only listed fields\n";
+}
+
+void test_exclude_removes_fields() {
+    nlohmann::json doc = {{"name", "Alice"}, {"email", "a@x.com"}, {"password", "secret"}};
+    auto result = applyProjection(doc, {}, {"password"});
+    assert(result.contains("name"));
+    assert(result.contains("email"));
+    assert(!result.contains("password"));
+    std::cout << "PASS: exclude removes fields\n";
+}
+
+void test_include_takes_precedence_over_exclude() {
+    nlohmann::json doc = {{"a", 1}, {"b", 2}, {"c", 3}};
+    auto result = applyProjection(doc, {"a"}, {"b", "c"});
+    assert(result.contains("a"));
+    assert(!result.contains("b"));
+    assert(!result.contains("c"));
+    assert(result.size() == 1);
+    std::cout << "PASS: include takes precedence over exclude\n";
+}
+
+void test_nested_include_path() {
+    nlohmann::json doc = {
+        {"user", {
+            {"profile", {{"name", "Alice"}, {"bio", "hi"}}},
+            {"private", {{"ssn", "123"}}}
+        }}
+    };
+    auto result = applyProjection(doc, {"user.profile.name"}, {});
+    assert(result["user"]["profile"]["name"] == "Alice");
+    assert(!result["user"]["profile"].contains("bio"));
+    assert(!result["user"].contains("private"));
+    std::cout << "PASS: nested include path\n";
+}
+
+void test_nested_exclude_path() {
+    nlohmann::json doc = {
+        {"user", {
+            {"name", "Alice"},
+            {"private", {{"ssn", "123"}, {"email", "a@x.com"}}}
+        }}
+    };
+    auto result = applyProjection(doc, {}, {"user.private.ssn"});
+    assert(result["user"]["name"] == "Alice");
+    assert(result["user"]["private"]["email"] == "a@x.com");
+    assert(!result["user"]["private"].contains("ssn"));
+    std::cout << "PASS: nested exclude path\n";
+}
+
+void test_metadata_always_preserved_with_include() {
+    nlohmann::json doc = {
+        {"_id", "doc-1"},
+        {"_version", 5},
+        {"name", "Alice"},
+        {"secret", "xyz"}
+    };
+    auto result = applyProjection(doc, {"name"}, {});
+    assert(result.contains("_id"));
+    assert(result.contains("_version"));
+    assert(result.contains("name"));
+    assert(!result.contains("secret"));
+    std::cout << "PASS: metadata always preserved with include\n";
+}
+
+void test_metadata_cannot_be_excluded() {
+    nlohmann::json doc = {{"_id", "doc-1"}, {"_version", 5}, {"name", "Alice"}};
+    auto result = applyProjection(doc, {}, {"_id", "_version"});
+    assert(result.contains("_id"));
+    assert(result.contains("_version"));
+    std::cout << "PASS: metadata cannot be excluded\n";
+}
+
+void test_missing_path_silently_skipped() {
+    nlohmann::json doc = {{"name", "Alice"}};
+    auto result = applyProjection(doc, {"user.profile.name"}, {});
+    assert(!result.contains("user"));
+    std::cout << "PASS: missing path silently skipped\n";
+}
+
+int main() {
+    test_empty_projection_returns_document_unchanged();
+    test_include_keeps_only_listed_fields();
+    test_exclude_removes_fields();
+    test_include_takes_precedence_over_exclude();
+    test_nested_include_path();
+    test_nested_exclude_path();
+    test_metadata_always_preserved_with_include();
+    test_metadata_cannot_be_excluded();
+    test_missing_path_silently_skipped();
+    std::cout << "\nAll view projection tests PASSED!\n";
+    return 0;
+}