feat(search): push AttributeFilter list into ClickHouse WHERE clause

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-24 10:13:30 +02:00
parent f9b5f235cc
commit 2dcbd5a772
2 changed files with 76 additions and 2 deletions

View File

@@ -1,6 +1,7 @@
package com.cameleer.server.app.search; package com.cameleer.server.app.search;
import com.cameleer.server.core.alerting.AlertMatchSpec; 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.ExecutionSummary;
import com.cameleer.server.core.search.SearchRequest; import com.cameleer.server.core.search.SearchRequest;
import com.cameleer.server.core.search.SearchResult; import com.cameleer.server.core.search.SearchResult;
@@ -256,6 +257,23 @@ public class ClickHouseSearchIndex implements SearchIndex {
params.add(likeTerm); 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); return String.join(" AND ", conditions);
} }

View File

@@ -2,6 +2,7 @@ package com.cameleer.server.app.search;
import com.cameleer.server.app.storage.ClickHouseExecutionStore; import com.cameleer.server.app.storage.ClickHouseExecutionStore;
import com.cameleer.server.core.ingestion.MergedExecution; 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.ExecutionSummary;
import com.cameleer.server.core.search.SearchRequest; import com.cameleer.server.core.search.SearchRequest;
import com.cameleer.server.core.search.SearchResult; import com.cameleer.server.core.search.SearchResult;
@@ -62,7 +63,7 @@ class ClickHouseSearchIndexIT {
500L, 500L,
"", "", "", "", "", "", "", "", "", "", "", "",
"hash-abc", "FULL", "hash-abc", "FULL",
"{\"order\":\"12345\"}", "", "", "", "", "", "{\"env\":\"prod\"}", "", "", "", "", "", "", "{\"order\":\"12345\",\"tenant\":\"acme\"}",
"", "", "", "",
false, false, false, false,
null, null null, null
@@ -79,7 +80,7 @@ class ClickHouseSearchIndexIT {
"java.lang.NPE\n at Foo.bar(Foo.java:42)", "java.lang.NPE\n at Foo.bar(Foo.java:42)",
"NullPointerException", "RUNTIME", "", "", "NullPointerException", "RUNTIME", "", "",
"", "FULL", "", "FULL",
"", "", "", "", "", "", "", "", "", "", "", "", "", "{\"order\":\"99999\"}",
"", "", "", "",
false, false, false, false,
null, null null, null
@@ -309,4 +310,59 @@ class ClickHouseSearchIndexIT {
assertThat(result.total()).isEqualTo(1); assertThat(result.total()).isEqualTo(1);
assertThat(result.data().get(0).executionId()).isEqualTo("exec-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<ExecutionSummary> 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<ExecutionSummary> 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<ExecutionSummary> 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<ExecutionSummary> result = searchIndex.search(request);
assertThat(result.total()).isEqualTo(1);
assertThat(result.data().get(0).executionId()).isEqualTo("exec-1");
}
} }