diff --git a/core/src/main/java/org/apache/calcite/runtime/Like.java b/core/src/main/java/org/apache/calcite/runtime/Like.java index ac074afa3da7..376e7ca58db3 100644 --- a/core/src/main/java/org/apache/calcite/runtime/Like.java +++ b/core/src/main/java/org/apache/calcite/runtime/Like.java @@ -110,6 +110,72 @@ static String sqlToRegexLike( return javaPattern.toString(); } + /** + * Translates a SQL LIKE pattern to an anchored regular expression, with an + * optional escape string. + * + *

Similar to {@link #sqlToRegexLike}, except that the result is anchored + * with {@code ^} and {@code $} so that the entire value must match, as SQL + * LIKE requires. The translation is not specific to any dialect; it is used, + * for example, by the MongoDB adapter. + */ + public static String sqlToRegexAnchored( + String sqlPattern, + @Nullable CharSequence escapeStr) { + final char escapeChar; + if (escapeStr != null) { + if (escapeStr.length() != 1) { + throw invalidEscapeCharacter(escapeStr.toString()); + } + escapeChar = escapeStr.charAt(0); + } else { + escapeChar = 0; + } + return sqlToRegexAnchored(sqlPattern, escapeChar); + } + + /** + * Translates a SQL LIKE pattern to an anchored regular expression. + */ + public static String sqlToRegexAnchored( + String sqlPattern, + char escapeChar) { + final int len = sqlPattern.length(); + final StringBuilder javaPattern = new StringBuilder(len + len); + javaPattern.append('^'); + for (int i = 0; i < len; i++) { + char c = sqlPattern.charAt(i); + if (c == escapeChar) { + if (i == (sqlPattern.length() - 1)) { + throw invalidEscapeSequence(sqlPattern, i); + } + char nextChar = sqlPattern.charAt(i + 1); + if ((nextChar == '_') + || (nextChar == '%') + || (nextChar == escapeChar)) { + if (JAVA_REGEX_SPECIALS.indexOf(nextChar) >= 0) { + javaPattern.append('\\'); + } + javaPattern.append(nextChar); + i++; + } else { + throw invalidEscapeSequence(sqlPattern, i); + } + } else if (c == '_') { + javaPattern.append('.'); + } else if (c == '%') { + javaPattern.append(".*"); + } else { + if (JAVA_REGEX_SPECIALS.indexOf(c) >= 0) { + javaPattern.append('\\'); + } + javaPattern.append(c); + } + } + javaPattern.append('$'); + return javaPattern.toString(); + } + private static RuntimeException invalidEscapeCharacter(String s) { return new RuntimeException( "Invalid escape character '" + s + "'"); diff --git a/core/src/test/java/org/apache/calcite/runtime/LikeTest.java b/core/src/test/java/org/apache/calcite/runtime/LikeTest.java new file mode 100644 index 000000000000..549ddc3571ac --- /dev/null +++ b/core/src/test/java/org/apache/calcite/runtime/LikeTest.java @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.calcite.runtime; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +/** Unit tests for {@link Like}. */ +class LikeTest { + + /** Test case for + * [CALCITE-7693] + * Move MongoDB LIKE-to-regex conversion into runtime.Like for consistency. */ + @Test void testSqlToRegexAnchored() { + assertThat(Like.sqlToRegexAnchored("", null), is("^$")); + assertThat(Like.sqlToRegexAnchored("abc", null), is("^abc$")); + assertThat(Like.sqlToRegexAnchored("A%", null), is("^A.*$")); + assertThat(Like.sqlToRegexAnchored("A_", null), is("^A.$")); + assertThat(Like.sqlToRegexAnchored("%abc%", null), is("^.*abc.*$")); + // '.' is an ordinary SQL LIKE character; it must be escaped so that it is + // literal in the generated regex. + assertThat(Like.sqlToRegexAnchored("A.B%", null), is("^A\\.B.*$")); + } + + @Test void testSqlToRegexAnchoredWithEscape() { + // '\' escapes the wildcards, making them literal. + assertThat(Like.sqlToRegexAnchored("A\\_B\\%C%", "\\"), is("^A_B%C.*$")); + assertThat(Like.sqlToRegexAnchored("BROOKLYN\\%", "\\"), is("^BROOKLYN%$")); + // A custom escape character. + assertThat(Like.sqlToRegexAnchored("BROOKLYN!%", "!"), is("^BROOKLYN%$")); + // The escape character followed by itself is a literal escape character. + assertThat(Like.sqlToRegexAnchored("A\\\\B", "\\"), is("^A\\\\B$")); + } +} diff --git a/mongodb/src/main/java/org/apache/calcite/adapter/mongodb/MongoFilter.java b/mongodb/src/main/java/org/apache/calcite/adapter/mongodb/MongoFilter.java index c3d12a84be0b..93f96d79b079 100644 --- a/mongodb/src/main/java/org/apache/calcite/adapter/mongodb/MongoFilter.java +++ b/mongodb/src/main/java/org/apache/calcite/adapter/mongodb/MongoFilter.java @@ -31,6 +31,7 @@ import org.apache.calcite.rex.RexLiteral; import org.apache.calcite.rex.RexNode; import org.apache.calcite.rex.RexUtil; +import org.apache.calcite.runtime.Like; import org.apache.calcite.sql.SqlKind; import org.apache.calcite.sql.type.SqlTypeName; import org.apache.calcite.util.JsonBuilder; @@ -321,8 +322,8 @@ private Void translateLike(RexCall call, final RexLiteral patternLiteral = (RexLiteral) right; final String sqlPattern = patternLiteral.getValue2().toString(); - final @Nullable Character escapeChar = escapeChar(call); - final String finalRegex = sqlLikeToMongoRegex(sqlPattern, escapeChar); + final @Nullable String escapeStr = escapeStr(call); + final String finalRegex = Like.sqlToRegexAnchored(sqlPattern, escapeStr); switch (left.getKind()) { case INPUT_REF: @@ -364,8 +365,8 @@ private Void translateNotLike(RexCall call, List> orMapList) final RexLiteral patternLiteral = (RexLiteral) right; final String sqlPattern = patternLiteral.getValue2().toString(); - final @Nullable Character escapeChar = escapeChar(call); - final String finalRegex = sqlLikeToMongoRegex(sqlPattern, escapeChar); + final @Nullable String escapeStr = escapeStr(call); + final String finalRegex = Like.sqlToRegexAnchored(sqlPattern, escapeStr); final String name; switch (left.getKind()) { @@ -404,8 +405,8 @@ private static RexNode stripCast(RexNode node) { return node; } - /** Returns the escape character declared in a LIKE expression, or null. */ - private static @Nullable Character escapeChar(RexCall call) { + /** Returns the escape string declared in a LIKE expression, or null. */ + private static @Nullable String escapeStr(RexCall call) { if (call.operands.size() != 3) { return null; } @@ -417,72 +418,7 @@ private static RexNode stripCast(RexNode node) { if (escape.length() != 1) { throw new AssertionError("cannot translate LIKE with multi-character escape: " + call); } - return escape.charAt(0); - } - - /** - * Converts SQL LIKE pattern to MongoDB regex pattern. - * - *

SQL: {@code %} matches zero or more characters, {@code _} matches a single - * character. MongoDB: {@code .*} matches zero or more characters, {@code .} - * matches a single character. - * - *

We add {@code ^} and {@code $} anchors so that the entire string matches - * the pattern, just as SQL LIKE does. - */ - private static String sqlLikeToMongoRegex(String sqlPattern, @Nullable Character escapeChar) { - final StringBuilder regex = new StringBuilder(sqlPattern.length() * 2); - regex.append("^"); - for (int i = 0; i < sqlPattern.length(); i++) { - char c = sqlPattern.charAt(i); - if (escapeChar != null && c == escapeChar) { - if (i == sqlPattern.length() - 1) { - throw new AssertionError("Invalid escape sequence at end of LIKE pattern: " - + sqlPattern); - } - final char nextChar = sqlPattern.charAt(i + 1); - if (nextChar == '%' || nextChar == '_' || nextChar == escapeChar) { - regex.append(escapeRegexChar(nextChar)); - i++; - } else { - throw new AssertionError("Invalid escape sequence in LIKE pattern: " + sqlPattern); - } - } else if (c == '%') { - regex.append(".*"); - } else if (c == '_') { - regex.append('.'); - } else { - regex.append(escapeRegexChar(c)); - } - } - regex.append("$"); - return regex.toString(); - } - - /** - * Escapes a character for use in a MongoDB regex if it's a special regex character. - */ - private static String escapeRegexChar(char c) { - // MongoDB regex special characters that need escaping - switch (c) { - case '\\': - case '^': - case '$': - case '.': - case '|': - case '?': - case '*': - case '+': - case '(': - case ')': - case '[': - case ']': - case '{': - case '}': - return "\\" + c; - default: - return String.valueOf(c); - } + return escape; } } }