Skip to content
Open
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 @@ -1916,6 +1916,53 @@ public void testUnnestWithRegularFilterAndNullAndEmptyPreservedAtFirstLevel(Stri
dataStoreName, iterator, "query/unwind_preserve_with_regular_filter_first_level.json", 3);
}

@ParameterizedTest
@ArgumentsSource(AllProvider.class)
void testLikeFilterWithRegexMetacharactersInValue(String dataStoreName) throws IOException {
// A LIKE value containing regex metacharacters (e.g. "{{") used to throw on Postgres's
// `~*` engine while matching fine on Mongo's lenient PCRE. sanitizeRegexValue now escapes
// such values so Postgres matches them literally instead of erroring out.
Collection collection = getCollection(dataStoreName);

// Upserted here instead of in collection_data.json, and deleted in the finally block below,
// so this transient doc never affects other tests' counts on the shared fixture.
SingleValueKey key = new SingleValueKey(TENANT_ID, "regex-metacharacters-notes-doc");
collection.upsert(
key, new JSONDocument("{\"notes\": \"weird {{bracket}} and ${${8gs} value\"}"));
try {
Query bracesQuery =
Query.builder()
.setFilter(
RelationalExpression.of(
IdentifierExpression.of("notes"), LIKE, ConstantExpression.of("{{")))
.build();

// "{{" is an unmatched brace: Mongo's PCRE treats it as literal, and Postgres now escapes
// it to match literally too, so both stores agree.
long bracesCount = collection.count(bracesQuery);
assertEquals(1, bracesCount);

Query dollarBraceQuery =
Query.builder()
.setFilter(
RelationalExpression.of(
IdentifierExpression.of("notes"), LIKE, ConstantExpression.of("${${8gs")))
.build();

// "${${8gs" is a valid PCRE where mid-pattern "$" is an anchor, so Mongo legitimately
// finds no match; Postgres rejects it as invalid regex and falls back to a literal match,
// which does hit. This divergence is expected, not a bug.
long dollarBraceCount = collection.count(dollarBraceQuery);
if (MONGO_STORE.equals(dataStoreName)) {
assertEquals(0, dollarBraceCount);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] Do not encode different MongoDB and PostgreSQL results as expected behavior

This test explicitly accepts MongoDB returning 0 while PostgreSQL returns 1 for the same document and LIKE value. With document text `weird {{bracket}} and ${${8gs} value`, MongoDB/PCRE2 interprets the `$` characters as end-of-string assertions and does not match, whereas the PostgreSQL fallback turns the expression into literal `\$\{\$\{8gs` and finds that substring. If datastore compatibility is required, this should assert the same result for both providers; either quote the value for both backends when the contract is literal substring search, or define a common regex subset and reject/translate expressions that cannot have equivalent semantics.

Tip

A regression test that intentionally branches by datastore makes the incompatibility permanent. Add parity cases for malformed and engine-specific expressions alongside existing valid expressions such as ^Sun, Sha.*, and b.*B, then assert one shared expected result.

References:

Reviewed using @claude traceable:traceable-java-code-review

} else {
assertEquals(1, dollarBraceCount);
}
} finally {
collection.delete(key);
}
}

@ParameterizedTest
@ArgumentsSource(AllProvider.class)
void testContainsAndUnnestFilters(String dataStoreName) throws IOException {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.hypertrace.core.documentstore.postgres.query.v1.parser.filter;

import org.hypertrace.core.documentstore.expression.impl.RelationalExpression;
import org.hypertrace.core.documentstore.postgres.utils.PostgresUtils;

class PostgresLikeRelationalFilterParser implements PostgresRelationalFilterParser {

Expand All @@ -10,9 +11,12 @@ public String parse(
final Object parsedLhs = expression.getLhs().accept(context.lhsParser());
final Object parsedRhs = expression.getRhs().accept(context.rhsParser());

// Append ".*" at beginning and end of value to do a regex
// Like operator is only applicable for string RHS. Hence, convert the RHS to string
final String rhsValue = ".*" + parsedRhs + ".*";
// Like operator is only applicable for string RHS. Hence, convert the RHS to string.
// Postgres's `~*` is already an unanchored, case-insensitive regex search (not a full-string
// match), so no ".*" wrapping is needed. If the RHS is a valid regex, pass it through
// unchanged; otherwise escape POSIX ARE metacharacters so it is matched literally instead of
// making Postgres's `~*` operator error out (see PostgresUtils#sanitizeRegexValue).
final String rhsValue = PostgresUtils.sanitizeRegexValue(parsedRhs);
context.getParamsBuilder().addObjectParam(rhsValue);

// Case-insensitive regex search
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -43,6 +45,9 @@ public class PostgresUtils {
private static final String JSON_DATA_ACCESSOR = "->>";
private static final String DOT_STR = "_dot_";
private static final String DOT = ".";
// Every character that is special at the regex pattern level, for use by
// #escapeRegexMetacharacters(Object).
private static final String REGEX_METACHARACTERS = "\\.^$*+?()[]{}|";

public static final Set<String> OUTER_COLUMNS =
new TreeSet<>(List.of(ID, CREATED_AT, UPDATED_AT));
Expand Down Expand Up @@ -179,6 +184,42 @@ public static String prepareParameterizedStringForJsonList(
return "(" + collect + ")";
}

/**
* Escapes POSIX ARE metacharacters so the value matches literally under Postgres's {@code ~*}
* operator. Fallback only - use {@link #sanitizeRegexValue(Object)} to decide whether escaping is
* needed, since a value that is itself a valid regex must be passed through unescaped.
*/
public static String escapeRegexMetacharacters(final Object value) {
// String.valueOf() preserves the pre-existing null-handling behaviour (literal "null").
final String stringValue = String.valueOf(value);
final StringBuilder escaped = new StringBuilder(stringValue.length());
for (int i = 0; i < stringValue.length(); i++) {
final char c = stringValue.charAt(i);
// Built char by char, so an inserted backslash is never re-escaped.
if (REGEX_METACHARACTERS.indexOf(c) >= 0) {
escaped.append('\\');
}
escaped.append(c);
}
return escaped.toString();
}

/**
* {@code LIKE} is actually a regex match (see {@code RelationalOperator.LIKE}), so a value that
* is already a valid regex is passed through unchanged; otherwise it is escaped via {@link
* #escapeRegexMetacharacters(Object)} so Postgres's stricter {@code ~*} engine matches it
* literally instead of erroring out on malformed patterns that Mongo's PCRE tolerates.
*/
public static String sanitizeRegexValue(final Object value) {
final String stringValue = String.valueOf(value);
try {
Pattern.compile(stringValue);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[high] Java regex validation does not validate PostgreSQL ARE or MongoDB PCRE2

`Pattern.compile` validates Java regex syntax, but the value is executed by PostgreSQL's ARE engine while MongoDB uses PCRE2. For example, Java accepts `C++` (possessive quantifier) and `a{256}`, so both pass this gate unchanged, but PostgreSQL rejects `++` and only permits repetition bounds through 255; the query can still fail with `invalid regular expression`. In the opposite direction, PostgreSQL documents `{` followed by a non-digit as a literal brace, so `foo.{bar` is a valid PostgreSQL pattern matching `fooX{bar`; Java rejects it and this code converts the whole pattern to the literal `foo\.\{bar`, changing the match result. Because MongoDB generally accepts the original PCRE pattern, this also creates cross-store incompatibility; please define and validate a common supported regex subset (or consistently treat LIKE as literal search in both backends) instead of using the Java engine as the compatibility gate.

Tip

The same input passes through three different grammars: Java Pattern, PostgreSQL ARE, and MongoDB PCRE2. Validity in one is not evidence of validity or equivalent semantics in either of the others. Compatibility tests should execute representative patterns against both datastore implementations and assert the same results, including C++, a{256}, foo.{bar, ^Sun, and b.*B.

References:

Reviewed using @claude traceable:traceable-java-code-review

return stringValue;
} catch (final PatternSyntaxException e) {
return escapeRegexMetacharacters(stringValue);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] Use PostgreSQL's documented literal-pattern mode for a literal fallback

If the intended fallback is literal matching, PostgreSQL already documents `***=` (or a leading `(?q)`) to quote the rest of an ARE. For example, binding `***=${${8gs` makes `$`, `{`, and the remaining characters ordinary, while `***=C++` safely matches literal `C++`; this avoids maintaining a context-sensitive metacharacter list and correctly handles backslashes and bracket expressions. This only makes the PostgreSQL fallback safer—it does not by itself provide MongoDB parity, because Mongo may successfully interpret the original value as a PCRE regex—so the fallback policy still needs to be shared by both backends or unsupported patterns should be rejected.

Tip

PostgreSQL's ARE metasyntax defines the ***= director as “the rest of the RE is taken to be a literal string, with all characters considered ordinary characters.” Since the pattern is bound through a prepared-statement parameter, the prefix and backslashes are regex content rather than SQL-literal syntax.

References:

Reviewed using @claude traceable:traceable-java-code-review

}
}

public static String parseNonCompositeFilterWithCasting(
String fieldName, String columnName, String op, Object value, Builder paramsBuilder) {
String parsedExpression =
Expand Down Expand Up @@ -235,10 +276,10 @@ public static String parseNonCompositeFilter(
break;
case "LIKE":
case "~":
// Case insensitive regex search, Append % at beginning and end of value to do a regex
// search
// Postgres's `~*` is an unanchored, case-insensitive regex search (not a full-string
// match), so no additional ".*" wrapping is needed to do a "contains" style search.
sqlOperator = " ~* ";
value = ".*" + value + ".*";
value = sanitizeRegexValue(value);
break;
case "NOT_IN":
case "NOT IN":
Expand Down Expand Up @@ -444,10 +485,10 @@ public static String prepareParsedNonCompositeFilter(
break;
case "LIKE":
case "~":
// Case insensitive regex search, Append % at beginning and end of value to do a regex
// search
// Postgres's `~*` is an unanchored, case-insensitive regex search (not a full-string
// match), so no additional ".*" wrapping is needed to do a "contains" style search.
sqlOperator = " ~* ";
value = ".*" + value + ".*";
value = sanitizeRegexValue(value);
break;
case "NOT_IN":
case "NOT IN":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,39 @@ void testParseNonCompositeFilter() {
Assertions.assertEquals(ID + " ~* ?", query);
}

{
// "a{{b" is not a valid regex (unmatched quantifier brace), so POSIX ARE metacharacters
// in the LIKE value must be escaped so that the value is matched literally instead of
// being rejected outright by Postgres's strict ~* operator.
Filter filter = new Filter(Filter.Op.LIKE, ID, "a{{b");
Params.Builder paramsBuilder = initParams();
String query =
PostgresUtils.parseNonCompositeFilterWithCasting(
filter.getFieldName(),
PostgresUtils.DOCUMENT_COLUMN,
filter.getOp().toString(),
filter.getValue(),
paramsBuilder);
Assertions.assertEquals(ID + " ~* ?", query);
Assertions.assertEquals(Map.of(1, "a\\{\\{b"), paramsBuilder.build().getObjectParams());
}

{
// A value that is already a valid regex (e.g. "^Sun") must be passed through unchanged,
// since LIKE is a case-insensitive regex match, not a literal "contains".
Filter filter = new Filter(Filter.Op.LIKE, ID, "^Sun");
Params.Builder paramsBuilder = initParams();
String query =
PostgresUtils.parseNonCompositeFilterWithCasting(
filter.getFieldName(),
PostgresUtils.DOCUMENT_COLUMN,
filter.getOp().toString(),
filter.getValue(),
paramsBuilder);
Assertions.assertEquals(ID + " ~* ?", query);
Assertions.assertEquals(Map.of(1, "^Sun"), paramsBuilder.build().getObjectParams());
}

{
Filter filter = new Filter(Filter.Op.IN, ID, List.of("abc", "xyz"));
String query =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package org.hypertrace.core.documentstore.postgres.query.v1.parser.filter;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import java.util.Map;
import org.hypertrace.core.documentstore.expression.impl.ConstantExpression;
import org.hypertrace.core.documentstore.expression.impl.IdentifierExpression;
import org.hypertrace.core.documentstore.expression.impl.RelationalExpression;
import org.hypertrace.core.documentstore.expression.operators.RelationalOperator;
import org.hypertrace.core.documentstore.postgres.Params;
import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.PostgresRelationalFilterParser.PostgresRelationalFilterContext;
import org.hypertrace.core.documentstore.postgres.query.v1.vistors.PostgresSelectTypeExpressionVisitor;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class PostgresLikeRelationalFilterParserTest {

private PostgresLikeRelationalFilterParser parser;
private PostgresRelationalFilterContext context;
private PostgresSelectTypeExpressionVisitor lhsParser;
private PostgresSelectTypeExpressionVisitor rhsParser;
private Params.Builder paramsBuilder;

@BeforeEach
void setUp() {
parser = new PostgresLikeRelationalFilterParser();
context = mock(PostgresRelationalFilterContext.class);
lhsParser = mock(PostgresSelectTypeExpressionVisitor.class);
rhsParser = mock(PostgresSelectTypeExpressionVisitor.class);
paramsBuilder = Params.newBuilder();

when(context.lhsParser()).thenReturn(lhsParser);
when(context.rhsParser()).thenReturn(rhsParser);
when(context.getParamsBuilder()).thenReturn(paramsBuilder);
}

@Test
void testParse_plainValue() {
IdentifierExpression lhs = IdentifierExpression.of("key1");
ConstantExpression rhs = ConstantExpression.of("abc");
RelationalExpression expression = RelationalExpression.of(lhs, RelationalOperator.LIKE, rhs);

when(lhsParser.visit(any(IdentifierExpression.class))).thenReturn("document->>'key1'");
when(rhsParser.visit(any(ConstantExpression.class))).thenReturn("abc");

String result = parser.parse(expression, context);

assertEquals("document->>'key1' ~* ?", result);
assertEquals(Map.of(1, "abc"), paramsBuilder.build().getObjectParams());
}

@Test
void testParse_valueWithRegexMetacharacters() {
// POSIX ARE metacharacters (e.g. "{{") must be escaped so Postgres's strict ~* operator
// matches the value literally instead of throwing "invalid regular expression: quantifier
// operand invalid".
IdentifierExpression lhs = IdentifierExpression.of("key1");
ConstantExpression rhs = ConstantExpression.of("a{{b");
RelationalExpression expression = RelationalExpression.of(lhs, RelationalOperator.LIKE, rhs);

when(lhsParser.visit(any(IdentifierExpression.class))).thenReturn("document->>'key1'");
when(rhsParser.visit(any(ConstantExpression.class))).thenReturn("a{{b");

String result = parser.parse(expression, context);

assertEquals("document->>'key1' ~* ?", result);
assertEquals(Map.of(1, "a\\{\\{b"), paramsBuilder.build().getObjectParams());
}

@Test
void testParse_valueWithAllMetacharacters() {
// "${${8gs" is not a valid Java/Postgres regex (unmatched quantifier brace), so it must be
// escaped and matched literally, same as "a{{b" above.
IdentifierExpression lhs = IdentifierExpression.of("key1");
ConstantExpression rhs = ConstantExpression.of("${${8gs");
RelationalExpression expression = RelationalExpression.of(lhs, RelationalOperator.LIKE, rhs);

when(lhsParser.visit(any(IdentifierExpression.class))).thenReturn("document->>'key1'");
when(rhsParser.visit(any(ConstantExpression.class))).thenReturn("${${8gs");

String result = parser.parse(expression, context);

assertEquals("document->>'key1' ~* ?", result);
assertEquals(Map.of(1, "\\$\\{\\$\\{8gs"), paramsBuilder.build().getObjectParams());
}

@Test
void testParse_valueIsValidRegex_passedThroughUnchanged() {
// A value that is already a valid regex must be passed through unchanged rather than
// escaped, since LIKE is a case-insensitive regex match (not literal "contains").
IdentifierExpression lhs = IdentifierExpression.of("key1");
ConstantExpression rhs = ConstantExpression.of(".*amp.*");
RelationalExpression expression = RelationalExpression.of(lhs, RelationalOperator.LIKE, rhs);

when(lhsParser.visit(any(IdentifierExpression.class))).thenReturn("document->>'key1'");
when(rhsParser.visit(any(ConstantExpression.class))).thenReturn(".*amp.*");

String result = parser.parse(expression, context);

assertEquals("document->>'key1' ~* ?", result);
assertEquals(Map.of(1, ".*amp.*"), paramsBuilder.build().getObjectParams());
}

@Test
void testParse_valueIsValidAnchoredRegex_passedThroughUnchanged() {
IdentifierExpression lhs = IdentifierExpression.of("key1");
ConstantExpression rhs = ConstantExpression.of("^Sun");
RelationalExpression expression = RelationalExpression.of(lhs, RelationalOperator.LIKE, rhs);

when(lhsParser.visit(any(IdentifierExpression.class))).thenReturn("document->>'key1'");
when(rhsParser.visit(any(ConstantExpression.class))).thenReturn("^Sun");

String result = parser.parse(expression, context);

assertEquals("document->>'key1' ~* ?", result);
assertEquals(Map.of(1, "^Sun"), paramsBuilder.build().getObjectParams());
}
}
Loading