Repository navigation
Escape regex metacharacters in Postgres LIKE filter values #331
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
base: main
Are you sure you want to change the base?
Changes from all commits
1f38464
8f3696b
a0aad04
e20a5e6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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)); | ||
|
|
@@ -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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.TipThe same input passes through three different grammars: Java References:
|
||
| return stringValue; | ||
| } catch (final PatternSyntaxException e) { | ||
| return escapeRegexMetacharacters(stringValue); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.TipPostgreSQL's ARE metasyntax defines the References:
|
||
| } | ||
| } | ||
|
|
||
| public static String parseNonCompositeFilterWithCasting( | ||
| String fieldName, String columnName, String op, Object value, Builder paramsBuilder) { | ||
| String parsedExpression = | ||
|
|
@@ -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": | ||
|
|
@@ -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": | ||
|
|
||
| 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()); | ||
| } | ||
| } |
There was a problem hiding this comment.
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.*, andb.*B, then assert one shared expected result.References: