From 0b419db9f18226c3f5e26dd50bc46c767826e334 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:51:28 +0200 Subject: [PATCH] feat(search): add AttributeFilter record with key regex + wildcard pattern translation --- .../server/core/search/AttributeFilter.java | 60 ++++++++++++++++++ .../core/search/AttributeFilterTest.java | 61 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 cameleer-server-core/src/main/java/com/cameleer/server/core/search/AttributeFilter.java create mode 100644 cameleer-server-core/src/test/java/com/cameleer/server/core/search/AttributeFilterTest.java diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/search/AttributeFilter.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/search/AttributeFilter.java new file mode 100644 index 00000000..7dbb9d02 --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/search/AttributeFilter.java @@ -0,0 +1,60 @@ +package com.cameleer.server.core.search; + +import java.util.regex.Pattern; + +/** + * Structured attribute filter for execution search. + *
+ * Value semantics: + *
+ * Keys must match {@code ^[a-zA-Z0-9._-]+$} — they are later inlined into + * ClickHouse SQL via {@code JSONExtractString}, which does not accept a + * parameter placeholder for the JSON path. Values are always parameter-bound. + */ +public record AttributeFilter(String key, String value) { + + private static final Pattern KEY_PATTERN = Pattern.compile("^[a-zA-Z0-9._-]+$"); + + public AttributeFilter { + if (key == null || !KEY_PATTERN.matcher(key).matches()) { + throw new IllegalArgumentException( + "Invalid attribute key: must match " + KEY_PATTERN.pattern() + ", got: " + key); + } + if (value != null && value.isBlank()) { + value = null; + } + } + + public boolean isKeyOnly() { + return value == null; + } + + public boolean isWildcard() { + return value != null && value.indexOf('*') >= 0; + } + + /** + * Returns a SQL LIKE pattern for wildcard matches with {@code %} / {@code _} / {@code \} + * in the source value escaped, or {@code null} for exact / key-only filters. + */ + public String toLikePattern() { + if (!isWildcard()) return null; + StringBuilder sb = new StringBuilder(value.length() + 4); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '\\' -> sb.append("\\\\"); + case '%' -> sb.append("\\%"); + case '_' -> sb.append("\\_"); + case '*' -> sb.append('%'); + default -> sb.append(c); + } + } + return sb.toString(); + } +} diff --git a/cameleer-server-core/src/test/java/com/cameleer/server/core/search/AttributeFilterTest.java b/cameleer-server-core/src/test/java/com/cameleer/server/core/search/AttributeFilterTest.java new file mode 100644 index 00000000..bad0c232 --- /dev/null +++ b/cameleer-server-core/src/test/java/com/cameleer/server/core/search/AttributeFilterTest.java @@ -0,0 +1,61 @@ +package com.cameleer.server.core.search; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AttributeFilterTest { + + @Test + void keyOnly_blankValue_normalizesToNull() { + AttributeFilter f = new AttributeFilter("order", ""); + assertThat(f.value()).isNull(); + assertThat(f.isKeyOnly()).isTrue(); + assertThat(f.isWildcard()).isFalse(); + } + + @Test + void keyOnly_nullValue_isKeyOnly() { + AttributeFilter f = new AttributeFilter("order", null); + assertThat(f.isKeyOnly()).isTrue(); + } + + @Test + void exactValue_isNotWildcard() { + AttributeFilter f = new AttributeFilter("order", "47"); + assertThat(f.isKeyOnly()).isFalse(); + assertThat(f.isWildcard()).isFalse(); + } + + @Test + void starInValue_isWildcard() { + AttributeFilter f = new AttributeFilter("order", "47*"); + assertThat(f.isWildcard()).isTrue(); + } + + @Test + void invalidKey_throws() { + assertThatThrownBy(() -> new AttributeFilter("bad key", "x")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("attribute key"); + } + + @Test + void blankKey_throws() { + assertThatThrownBy(() -> new AttributeFilter(" ", null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void wildcardPattern_escapesLikeMetaCharacters() { + AttributeFilter f = new AttributeFilter("order", "a_b%c\\d*"); + assertThat(f.toLikePattern()).isEqualTo("a\\_b\\%c\\\\d%"); + } + + @Test + void exactValue_toLikePattern_returnsNull() { + AttributeFilter f = new AttributeFilter("order", "47"); + assertThat(f.toLikePattern()).isNull(); + } +}