diff --git a/databind/src/main/java/tech/ydb/yoj/databind/expression/FilterBuilder.java b/databind/src/main/java/tech/ydb/yoj/databind/expression/FilterBuilder.java index c5453230..aa5e6a76 100644 --- a/databind/src/main/java/tech/ydb/yoj/databind/expression/FilterBuilder.java +++ b/databind/src/main/java/tech/ydb/yoj/databind/expression/FilterBuilder.java @@ -21,6 +21,7 @@ import static tech.ydb.yoj.databind.expression.NullExpr.Operator.IS_NOT_NULL; import static tech.ydb.yoj.databind.expression.NullExpr.Operator.IS_NULL; import static tech.ydb.yoj.databind.expression.ScalarExpr.Operator.CONTAINS; +import static tech.ydb.yoj.databind.expression.ScalarExpr.Operator.ENDS_WITH; import static tech.ydb.yoj.databind.expression.ScalarExpr.Operator.EQ; import static tech.ydb.yoj.databind.expression.ScalarExpr.Operator.GT; import static tech.ydb.yoj.databind.expression.ScalarExpr.Operator.GTE; @@ -28,6 +29,7 @@ import static tech.ydb.yoj.databind.expression.ScalarExpr.Operator.LTE; import static tech.ydb.yoj.databind.expression.ScalarExpr.Operator.NEQ; import static tech.ydb.yoj.databind.expression.ScalarExpr.Operator.NOT_CONTAINS; +import static tech.ydb.yoj.databind.expression.ScalarExpr.Operator.STARTS_WITH; @RequiredArgsConstructor(access = PRIVATE) public final class FilterBuilder { @@ -258,6 +260,18 @@ public FilterBuilder doesNotContain(@NonNull String value) { return FilterBuilder.this; } + @NonNull + public FilterBuilder startsWith(@NonNull String value) { + current = finisher.apply(new ScalarExpr<>(schema, generated, field, STARTS_WITH, fieldValue(value))); + return FilterBuilder.this; + } + + @NonNull + public FilterBuilder endsWith(@NonNull String value) { + current = finisher.apply(new ScalarExpr<>(schema, generated, field, ENDS_WITH, fieldValue(value))); + return FilterBuilder.this; + } + @NonNull public FilterBuilder isNull() { current = finisher.apply(new NullExpr<>(schema, generated, field, IS_NULL)); diff --git a/databind/src/main/java/tech/ydb/yoj/databind/expression/ScalarExpr.java b/databind/src/main/java/tech/ydb/yoj/databind/expression/ScalarExpr.java index 96fb59ed..df4cdae3 100644 --- a/databind/src/main/java/tech/ydb/yoj/databind/expression/ScalarExpr.java +++ b/databind/src/main/java/tech/ydb/yoj/databind/expression/ScalarExpr.java @@ -51,7 +51,8 @@ public V visit(@NonNull Visitor visitor) { @Override public FilterExpression negate() { - return new ScalarExpr<>(schema, generated, operator.negate(), field, value); + Operator negation = operator.negate(); + return negation != null ? new ScalarExpr<>(schema, generated, negation, field, value) : super.negate(); } @Override @@ -188,6 +189,36 @@ public String toString() { return ">="; } }, + /** + * "Starts with" is case-sensitive match to check if a string starts with the specified substring. + * E.g., {@code name startswith "Al"} + */ + STARTS_WITH { + @Override + public Operator negate() { + return null; + } + + @Override + public String toString() { + return "startswith"; + } + }, + /** + * "Ends with" is case-sensitive match to check if a string ends with the specified substring. + * E.g., {@code name endswith "exey"} + */ + ENDS_WITH { + @Override + public Operator negate() { + return null; + } + + @Override + public String toString() { + return "endswith"; + } + }, /** * "Contains" case-sensitive match for a substring in a string * E.g., {@code name contains "abc"} @@ -219,6 +250,7 @@ public String toString() { } }; + @Nullable public abstract Operator negate(); } } diff --git a/repository-test/src/main/java/tech/ydb/yoj/repository/test/ListingTest.java b/repository-test/src/main/java/tech/ydb/yoj/repository/test/ListingTest.java index 845920b0..90c615ca 100644 --- a/repository-test/src/main/java/tech/ydb/yoj/repository/test/ListingTest.java +++ b/repository-test/src/main/java/tech/ydb/yoj/repository/test/ListingTest.java @@ -579,6 +579,78 @@ public void containsEscaped() { }); } + @Test + public void startsWith() { + LogEntry e1 = new LogEntry(new LogEntry.Id("log1", 1L), LogEntry.Level.ERROR, "#tag earliest msg"); + LogEntry notInOutput = new LogEntry(new LogEntry.Id("log2", 2L), LogEntry.Level.DEBUG, "will be ignored"); + LogEntry e2 = new LogEntry(new LogEntry.Id("log1", 4L), LogEntry.Level.WARN, "#tag middle msg"); + LogEntry e3 = new LogEntry(new LogEntry.Id("log1", 5L), LogEntry.Level.INFO, "#tag latest msg"); + db.tx(() -> db.logEntries().insert(e1, e2, notInOutput, e3)); + + db.tx(() -> { + ListResult page = listLogEntries(ListRequest.builder(LogEntry.class) + .pageSize(100) + .filter(fb -> fb.where("message").startsWith("#tag")) + .build()); + assertThat(page).containsExactly(e1, e2, e3); + assertThat(page.isLastPage()).isTrue(); + }); + } + + @Test + public void startsWithEscaped() { + LogEntry e1 = new LogEntry(new LogEntry.Id("log1", 1L), LogEntry.Level.ERROR, "%_acme-challenge.blahblahblah."); + LogEntry notInOutput = new LogEntry(new LogEntry.Id("log2", 2L), LogEntry.Level.DEBUG, "will be ignored"); + LogEntry e2 = new LogEntry(new LogEntry.Id("log1", 4L), LogEntry.Level.WARN, "__hi%_there_"); + LogEntry e3 = new LogEntry(new LogEntry.Id("log1", 5L), LogEntry.Level.INFO, "%_"); + db.tx(() -> db.logEntries().insert(e1, e2, notInOutput, e3)); + + db.tx(() -> { + ListResult page = listLogEntries(ListRequest.builder(LogEntry.class) + .pageSize(100) + .filter(fb -> fb.where("message").startsWith("%_")) + .build()); + assertThat(page).containsExactly(e1, e3); + assertThat(page.isLastPage()).isTrue(); + }); + } + + @Test + public void endsWith() { + LogEntry e1 = new LogEntry(new LogEntry.Id("log1", 1L), LogEntry.Level.ERROR, "earliest msg #tag"); + LogEntry inOutput = new LogEntry(new LogEntry.Id("log2", 2L), LogEntry.Level.DEBUG, "will be ignored"); + LogEntry e2 = new LogEntry(new LogEntry.Id("log1", 4L), LogEntry.Level.WARN, "middle msg #tag"); + LogEntry e3 = new LogEntry(new LogEntry.Id("log1", 5L), LogEntry.Level.INFO, "latest msg #tag"); + db.tx(() -> db.logEntries().insert(e1, e2, inOutput, e3)); + + db.tx(() -> { + ListResult page = listLogEntries(ListRequest.builder(LogEntry.class) + .pageSize(100) + .filter(fb -> fb.where("message").endsWith(" #tag")) + .build()); + assertThat(page).containsExactly(e1, e2, e3); + assertThat(page.isLastPage()).isTrue(); + }); + } + + @Test + public void endsWithEscaped() { + LogEntry e1 = new LogEntry(new LogEntry.Id("log1", 1L), LogEntry.Level.ERROR, "acme-challenge.blahblahblah.%_"); + LogEntry notInOutput = new LogEntry(new LogEntry.Id("log2", 2L), LogEntry.Level.DEBUG, "will be ignored"); + LogEntry e2 = new LogEntry(new LogEntry.Id("log1", 4L), LogEntry.Level.WARN, "__hi%_there_"); + LogEntry e3 = new LogEntry(new LogEntry.Id("log1", 5L), LogEntry.Level.INFO, "%_"); + db.tx(() -> db.logEntries().insert(e1, e2, notInOutput, e3)); + + db.tx(() -> { + ListResult page = listLogEntries(ListRequest.builder(LogEntry.class) + .pageSize(100) + .filter(fb -> fb.where("message").endsWith("%_")) + .build()); + assertThat(page).containsExactly(e1, e3); + assertThat(page.isLastPage()).isTrue(); + }); + } + protected final ListResult listProjects(ListRequest request) { return db.projects().list(request); } diff --git a/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/yql/YqlListingQuery.java b/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/yql/YqlListingQuery.java index ae3c1978..38d07175 100644 --- a/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/yql/YqlListingQuery.java +++ b/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/yql/YqlListingQuery.java @@ -66,6 +66,8 @@ public YqlPredicate visitScalarExpr(@NonNull ScalarExpr scalarExpr) { case GTE -> pred.gte(expected); case CONTAINS -> pred.like(likePatternForContains((String) expected), LIKE_ESCAPE_CHAR); case NOT_CONTAINS -> pred.notLike(likePatternForContains((String) expected), LIKE_ESCAPE_CHAR); + case STARTS_WITH -> pred.like(likePatternForStartsWith((String) expected), LIKE_ESCAPE_CHAR); + case ENDS_WITH -> pred.like(likePatternForEndsWith((String) expected), LIKE_ESCAPE_CHAR); }; } @@ -74,14 +76,10 @@ public YqlPredicate visitNullExpr(@NonNull NullExpr nullExpr) { String fieldPath = nullExpr.getFieldPath(); YqlPredicate.FieldPredicateBuilder pred = YqlPredicate.where(fieldPath); - switch (nullExpr.getOperator()) { - case IS_NULL: - return pred.isNull(); - case IS_NOT_NULL: - return pred.isNotNull(); - default: - throw new UnsupportedOperationException("Unknown relation in nullability expression: " + nullExpr.getOperator()); - } + return switch (nullExpr.getOperator()) { + case IS_NULL -> pred.isNull(); + case IS_NOT_NULL -> pred.isNotNull(); + }; } @Override @@ -89,14 +87,10 @@ public YqlPredicate visitListExpr(@NonNull ListExpr listExpr) { String fieldPath = listExpr.getFieldPath(); JavaField field = listExpr.getField(); List expected = listExpr.getValues().stream().map(v -> v.getRaw(field)).collect(toList()); - switch (listExpr.getOperator()) { - case IN: - return YqlPredicate.where(fieldPath).in(expected); - case NOT_IN: - return YqlPredicate.where(fieldPath).notIn(expected); - default: - throw new UnsupportedOperationException("Unknown relation in filter expression: " + listExpr.getOperator()); - } + return switch (listExpr.getOperator()) { + case IN -> YqlPredicate.where(fieldPath).in(expected); + case NOT_IN -> YqlPredicate.where(fieldPath).notIn(expected); + }; } @Override @@ -120,6 +114,7 @@ public YqlPredicate visitNotExpr(@NonNull NotExpr notExpr) { }); } + // %% @NonNull private static String likePatternForContains(@NonNull String str) { StringBuilder sb = new StringBuilder(str.length() + 2); @@ -133,6 +128,32 @@ private static String likePatternForContains(@NonNull String str) { return sb.toString(); } + // % + @NonNull + private static String likePatternForStartsWith(@NonNull String str) { + StringBuilder sb = new StringBuilder(str.length() + 1); + if (LIKE_PATTERN_CHARS.matchesNoneOf(str)) { + sb.append(str); + } else { + escapeLikePatternToSb(str, sb); + } + sb.append('%'); + return sb.toString(); + } + + // % + @NonNull + private static String likePatternForEndsWith(@NonNull String str) { + StringBuilder sb = new StringBuilder(str.length() + 1); + sb.append('%'); + if (LIKE_PATTERN_CHARS.matchesNoneOf(str)) { + sb.append(str); + } else { + escapeLikePatternToSb(str, sb); + } + return sb.toString(); + } + private static void escapeLikePatternToSb(@NonNull String str, StringBuilder sb) { for (int i = 0; i < str.length(); i++) { char ch = str.charAt(i); diff --git a/repository/src/main/java/tech/ydb/yoj/repository/db/list/InMemoryQueries.java b/repository/src/main/java/tech/ydb/yoj/repository/db/list/InMemoryQueries.java index 921c7d4b..e7e1ad4a 100644 --- a/repository/src/main/java/tech/ydb/yoj/repository/db/list/InMemoryQueries.java +++ b/repository/src/main/java/tech/ydb/yoj/repository/db/list/InMemoryQueries.java @@ -91,6 +91,8 @@ public Predicate visitScalarExpr(@NonNull ScalarExpr scalarExpr) { case LTE -> obj -> compare(getActual.apply(obj), expected) <= 0; case CONTAINS -> obj -> contains((String) getActual.apply(obj), (String) expected); case NOT_CONTAINS -> obj -> !contains((String) getActual.apply(obj), (String) expected); + case STARTS_WITH -> obj -> startsWith((String) getActual.apply(obj), (String) expected); + case ENDS_WITH -> obj -> endsWith((String) getActual.apply(obj), (String) expected); }; } @@ -194,4 +196,18 @@ private static boolean contains(@Nullable String input, @Nullable String substri } return input.contains(substring); } + + private static boolean startsWith(@Nullable String input, @Nullable String substring) { + if (input == null || substring == null) { + return false; + } + return input.startsWith(substring); + } + + private static boolean endsWith(@Nullable String input, @Nullable String substring) { + if (input == null || substring == null) { + return false; + } + return input.endsWith(substring); + } }