feat(search): add AttributeFilter record with key regex + wildcard pattern translation

This commit is contained in:
hsiegeln
2026-04-24 09:51:28 +02:00
parent 5f6f9e523d
commit 0b419db9f1
2 changed files with 121 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
package com.cameleer.server.core.search;
import java.util.regex.Pattern;
/**
* Structured attribute filter for execution search.
* <p>
* Value semantics:
* <ul>
* <li>{@code value == null} or blank -> key-exists check</li>
* <li>{@code value} contains {@code *} -> wildcard match (translated to SQL LIKE pattern)</li>
* <li>otherwise -> exact match</li>
* </ul>
* <p>
* 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();
}
}

View File

@@ -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();
}
}