Skip to content

Commit

Permalink
#86: Support startsWith(substring) and endsWith(substring) in `Fi…
Browse files Browse the repository at this point in the history
…lterExpression`s
  • Loading branch information
nvamelichev committed Sep 26, 2024
1 parent 43b05e0 commit eef3cd0
Show file tree
Hide file tree
Showing 5 changed files with 172 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@
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;
import static tech.ydb.yoj.databind.expression.ScalarExpr.Operator.LT;
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<T> {
Expand Down Expand Up @@ -258,6 +260,18 @@ public FilterBuilder<T> doesNotContain(@NonNull String value) {
return FilterBuilder.this;
}

@NonNull
public FilterBuilder<T> startsWith(@NonNull String value) {
current = finisher.apply(new ScalarExpr<>(schema, generated, field, STARTS_WITH, fieldValue(value)));
return FilterBuilder.this;
}

@NonNull
public FilterBuilder<T> endsWith(@NonNull String value) {
current = finisher.apply(new ScalarExpr<>(schema, generated, field, ENDS_WITH, fieldValue(value)));
return FilterBuilder.this;
}

@NonNull
public FilterBuilder<T> isNull() {
current = finisher.apply(new NullExpr<>(schema, generated, field, IS_NULL));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ public <V> V visit(@NonNull Visitor<T, V> visitor) {

@Override
public FilterExpression<T> 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
Expand Down Expand Up @@ -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"}
Expand Down Expand Up @@ -219,6 +250,7 @@ public String toString() {
}
};

@Nullable
public abstract Operator negate();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<LogEntry> 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<LogEntry> 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<LogEntry> 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<LogEntry> 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<Project> listProjects(ListRequest<Project> request) {
return db.projects().list(request);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ public YqlPredicate visitScalarExpr(@NonNull ScalarExpr<T> 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);
};
}

Expand All @@ -74,29 +76,21 @@ public YqlPredicate visitNullExpr(@NonNull NullExpr<T> 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
public YqlPredicate visitListExpr(@NonNull ListExpr<T> 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
Expand All @@ -120,6 +114,7 @@ public YqlPredicate visitNotExpr(@NonNull NotExpr<T> notExpr) {
});
}

// %<str>%
@NonNull
private static String likePatternForContains(@NonNull String str) {
StringBuilder sb = new StringBuilder(str.length() + 2);
Expand All @@ -133,6 +128,32 @@ private static String likePatternForContains(@NonNull String str) {
return sb.toString();
}

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

// %<str>
@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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ public Predicate<T> visitScalarExpr(@NonNull ScalarExpr<T> 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);
};
}

Expand Down Expand Up @@ -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);
}
}

0 comments on commit eef3cd0

Please sign in to comment.