feat(search): add AttributeFilter record with key regex + wildcard pattern translation
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user