diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/search/ClickHouseSearchIndex.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/search/ClickHouseSearchIndex.java index 246557c1..7546cfd3 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/search/ClickHouseSearchIndex.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/search/ClickHouseSearchIndex.java @@ -1,6 +1,7 @@ package com.cameleer.server.app.search; import com.cameleer.server.core.alerting.AlertMatchSpec; +import com.cameleer.server.core.search.AttributeFilter; import com.cameleer.server.core.search.ExecutionSummary; import com.cameleer.server.core.search.SearchRequest; import com.cameleer.server.core.search.SearchResult; @@ -256,6 +257,23 @@ public class ClickHouseSearchIndex implements SearchIndex { params.add(likeTerm); } + // Structured attribute filters. Keys were validated at AttributeFilter construction + // time against ^[a-zA-Z0-9._-]+$ so they are safe to single-quote-inline; the JSON path + // argument of JSONExtractString does not accept a ? placeholder in ClickHouse JDBC + // (same constraint as countExecutionsForAlerting below). Values are parameter-bound. + for (AttributeFilter filter : request.attributeFilters()) { + String escapedKey = filter.key().replace("'", "\\'"); + if (filter.isKeyOnly()) { + conditions.add("JSONHas(attributes, '" + escapedKey + "')"); + } else if (filter.isWildcard()) { + conditions.add("JSONExtractString(attributes, '" + escapedKey + "') LIKE ?"); + params.add(filter.toLikePattern()); + } else { + conditions.add("JSONExtractString(attributes, '" + escapedKey + "') = ?"); + params.add(filter.value()); + } + } + return String.join(" AND ", conditions); } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/search/ClickHouseSearchIndexIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/search/ClickHouseSearchIndexIT.java index b1269825..7a5e73bd 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/search/ClickHouseSearchIndexIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/search/ClickHouseSearchIndexIT.java @@ -2,6 +2,7 @@ package com.cameleer.server.app.search; import com.cameleer.server.app.storage.ClickHouseExecutionStore; import com.cameleer.server.core.ingestion.MergedExecution; +import com.cameleer.server.core.search.AttributeFilter; import com.cameleer.server.core.search.ExecutionSummary; import com.cameleer.server.core.search.SearchRequest; import com.cameleer.server.core.search.SearchResult; @@ -62,7 +63,7 @@ class ClickHouseSearchIndexIT { 500L, "", "", "", "", "", "", "hash-abc", "FULL", - "{\"order\":\"12345\"}", "", "", "", "", "", "{\"env\":\"prod\"}", + "", "", "", "", "", "", "{\"order\":\"12345\",\"tenant\":\"acme\"}", "", "", false, false, null, null @@ -79,7 +80,7 @@ class ClickHouseSearchIndexIT { "java.lang.NPE\n at Foo.bar(Foo.java:42)", "NullPointerException", "RUNTIME", "", "", "", "FULL", - "", "", "", "", "", "", "", + "", "", "", "", "", "", "{\"order\":\"99999\"}", "", "", false, false, null, null @@ -309,4 +310,59 @@ class ClickHouseSearchIndexIT { assertThat(result.total()).isEqualTo(1); assertThat(result.data().get(0).executionId()).isEqualTo("exec-1"); } + + @Test + void search_byAttributeFilter_exactMatch_matchesExec1() { + SearchRequest request = new SearchRequest( + null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, 0, 50, null, null, null, null, + List.of(new AttributeFilter("order", "12345"))); + + SearchResult result = searchIndex.search(request); + + assertThat(result.total()).isEqualTo(1); + assertThat(result.data().get(0).executionId()).isEqualTo("exec-1"); + } + + @Test + void search_byAttributeFilter_keyOnly_matchesExec1AndExec2() { + SearchRequest request = new SearchRequest( + null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, 0, 50, null, null, null, null, + List.of(new AttributeFilter("order", null))); + + SearchResult result = searchIndex.search(request); + + assertThat(result.total()).isEqualTo(2); + assertThat(result.data()).extracting(ExecutionSummary::executionId) + .containsExactlyInAnyOrder("exec-1", "exec-2"); + } + + @Test + void search_byAttributeFilter_wildcardValue_matchesExec1Only() { + SearchRequest request = new SearchRequest( + null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, 0, 50, null, null, null, null, + List.of(new AttributeFilter("order", "123*"))); + + SearchResult result = searchIndex.search(request); + + assertThat(result.total()).isEqualTo(1); + assertThat(result.data().get(0).executionId()).isEqualTo("exec-1"); + } + + @Test + void search_byAttributeFilter_multipleFiltersAreAnded() { + SearchRequest request = new SearchRequest( + null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, 0, 50, null, null, null, null, + List.of( + new AttributeFilter("order", "12345"), + new AttributeFilter("tenant", "acme"))); + + SearchResult result = searchIndex.search(request); + + assertThat(result.total()).isEqualTo(1); + assertThat(result.data().get(0).executionId()).isEqualTo("exec-1"); + } }