Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#86: Support startsWith(substring) and endsWith(substring) in FilterExpressions #89

Merged
merged 1 commit into from
Sep 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
}
}