This is an automated email from the ASF dual-hosted git repository.
rubenada pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/calcite.git
The following commit(s) were added to refs/heads/main by this push:
new 4f8ad6c313 [CALCITE-7906] Improve LikePattern to support '_' wildcard
and enhance the performance of '%' wildcard
4f8ad6c313 is described below
commit 4f8ad6c3133a215621de0cb411c80d34cc8a2778
Author: Ruben Quesada Lopez <[email protected]>
AuthorDate: Thu Oct 29 19:35:29 2026 +0000
[CALCITE-7906] Improve LikePattern to support '_' wildcard and enhance the
performance of '%' wildcard
---
.../apache/calcite/schema/lookup/LikePattern.java | 119 ++++++++++++-----
.../calcite/schema/lookup/LikePatternTest.java | 147 +++++++++++++++++++++
.../java/org/apache/calcite/test/JdbcTest.java | 31 -----
site/_docs/history.md | 7 +
4 files changed, 240 insertions(+), 64 deletions(-)
diff --git
a/core/src/main/java/org/apache/calcite/schema/lookup/LikePattern.java
b/core/src/main/java/org/apache/calcite/schema/lookup/LikePattern.java
index c455fb360d..faedc65adc 100644
--- a/core/src/main/java/org/apache/calcite/schema/lookup/LikePattern.java
+++ b/core/src/main/java/org/apache/calcite/schema/lookup/LikePattern.java
@@ -18,13 +18,17 @@
import org.apache.calcite.linq4j.function.Predicate1;
-import java.util.regex.Pattern;
+import java.util.ArrayList;
+import java.util.BitSet;
+import java.util.List;
/**
* This class is used to hold a pattern, which is typically
* used in SQL LIKE statements.
*
- * <p>The pattern can contain wildcards (`%`) or character ranges (`[a-z]`).
+ * <p>The pattern can contain wildcards: {@code %}, which matches any sequence
+ * of characters, including none; and {@code _}, which matches exactly one
character.
+ * A wildcard can be escaped using {@code \}. All other characters match only
themselves.
*
* <p>The pattern can also be translated to a {@code Predicate1<String>} which
* can be used to do filtering inside java.
@@ -56,43 +60,92 @@ public static Predicate1<String> matcher(String
likePattern) {
if (likePattern == null || likePattern.equals(ANY)) {
return v1 -> true;
}
- final Pattern regex = likeToRegex(likePattern);
- return v1 -> regex.matcher(v1).matches();
+ final List<Segment> segments = segments(likePattern);
+ return v1 -> matches(segments, v1);
}
- /**
- * Converts a LIKE-style pattern (where '%' represents a wild-card, escaped
- * using '\') to a Java regex. It's always case sensitive.
- */
- public static Pattern likeToRegex(String pattern) {
- StringBuilder buf = new StringBuilder("^");
- char[] charArray = pattern.toCharArray();
- int slash = -2;
- for (int i = 0; i < charArray.length; i++) {
- char c = charArray[i];
- if (slash == i - 1) {
- buf.append('[').append(c).append(']');
+ /** Splits a LIKE-style pattern into the segments between its '%'
+ * wild-cards, resolving '\' escapes. Consecutive wild-cards act as
+ * one, so only the first and last segments can be empty: the first if
+ * the pattern starts with a wild-card, the last if it ends with one. */
+ private static List<Segment> segments(String pattern) {
+ final List<Segment> segments = new ArrayList<>();
+ final StringBuilder buf = new StringBuilder();
+ BitSet any = new BitSet();
+ for (int i = 0; i < pattern.length(); i++) {
+ final char c = pattern.charAt(i);
+ if (c == '\\') {
+ if (++i < pattern.length()) {
+ buf.append(pattern.charAt(i));
+ }
+ } else if (c == '%') {
+ if (buf.length() > 0 || segments.isEmpty()) {
+ segments.add(new Segment(buf.toString(), any));
+ }
+ buf.setLength(0);
+ any = new BitSet();
} else {
- switch (c) {
- case '\\':
- slash = i;
- break;
- case '%':
- buf.append(".*");
- break;
- case '[':
- buf.append("\\[");
- break;
- case ']':
- buf.append("\\]");
- break;
- default:
- buf.append('[').append(c).append(']');
+ if (c == '_') {
+ any.set(buf.length());
+ }
+ buf.append(c);
+ }
+ }
+ segments.add(new Segment(buf.toString(), any));
+ return segments;
+ }
+
+ /** Returns whether a string matches a pattern split by
+ * {@link #segments}. Each middle segment is matched at its leftmost
+ * position, which leaves the most room for the segments after it. */
+ private static boolean matches(List<Segment> segments, String s) {
+ final Segment first = segments.get(0);
+ if (segments.size() == 1) {
+ return s.length() == first.length() && first.matchesAt(s, 0);
+ }
+ final Segment last = segments.get(segments.size() - 1);
+ final int end = s.length() - last.length();
+ if (end < first.length() || !first.matchesAt(s, 0) || !last.matchesAt(s,
end)) {
+ return false;
+ }
+ int pos = first.length();
+ for (Segment segment : segments.subList(1, segments.size() - 1)) {
+ while (pos + segment.length() <= end && !segment.matchesAt(s, pos)) {
+ pos++;
+ }
+ if (pos + segment.length() > end) {
+ return false;
+ }
+ pos += segment.length();
+ }
+ return true;
+ }
+
+ /** Characters of a pattern between '%' wild-cards (or the start or end of
the pattern).
+ * A position in {@code any} holds an unescaped '_', which matches any one
character. */
+ private static class Segment {
+ final String chars;
+ final BitSet any;
+
+ Segment(String chars, BitSet any) {
+ this.chars = chars;
+ this.any = any;
+ }
+
+ int length() {
+ return chars.length();
+ }
+
+ /** Returns whether this segment matches {@code s} starting at
+ * {@code pos}; the caller ensures there are enough characters. */
+ boolean matchesAt(String s, int pos) {
+ for (int i = 0; i < chars.length(); i++) {
+ if (!any.get(i) && s.charAt(pos + i) != chars.charAt(i)) {
+ return false;
}
}
+ return true;
}
- buf.append("$");
- return Pattern.compile(buf.toString());
}
}
diff --git
a/core/src/test/java/org/apache/calcite/schema/lookup/LikePatternTest.java
b/core/src/test/java/org/apache/calcite/schema/lookup/LikePatternTest.java
new file mode 100644
index 0000000000..283156b9ce
--- /dev/null
+++ b/core/src/test/java/org/apache/calcite/schema/lookup/LikePatternTest.java
@@ -0,0 +1,147 @@
+/*
+ * 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.schema.lookup;
+
+import com.google.common.base.Strings;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+
+/**
+ * Test for {@link LikePattern}.
+ */
+class LikePatternTest {
+ @Test void testMatcher() {
+ checkMatcher(true, "%", "abc");
+ checkMatcher(true, "abc", "abc");
+ checkMatcher(false, "abc", "abcd"); // trailing char fails match
+ checkMatcher(false, "abc", "0abc"); // leading char fails match
+ checkMatcher(false, "abc", "aBc"); // case-sensitive match
+ checkMatcher(true, "a[b]c", "a[b]c"); // nothing special about brackets
+ checkMatcher(true, "a$c", "a$c"); // nothing special about dollar
+ checkMatcher(false, "a$", "a"); // nothing special about dollar
+ checkMatcher(true, "a%c", "ac");
+ checkMatcher(true, "a%c", "abbbc");
+ checkMatcher(false, "a%c", "acccd");
+ checkMatcher(true, "a^b", "a^b");
+ checkMatcher(true, "a[b", "a[b");
+ checkMatcher(true, "a\\\\b", "a\\b");
+
+ // wildcard _
+ checkMatcher(true, "a_c", "abc");
+ checkMatcher(false, "a_c", "ac");
+ checkMatcher(false, "a_c", "abbc");
+ checkMatcher(false, "%_", "");
+ checkMatcher(true, "_%_", "ab");
+ checkMatcher(true, "_%_", "axb");
+ checkMatcher(true, "_%_", "axxxxb");
+
+ checkMatcher(true, "_bc", "xbc");
+ checkMatcher(false, "_bc", "xybc");
+ checkMatcher(true, "ab_", "abx");
+ checkMatcher(false, "ab_", "abxy");
+
+ checkMatcher(true, "%a_c%", "xxabcx");
+ checkMatcher(false, "%a_c%", "xxacx");
+ checkMatcher(true, "%a_c%", "aaxc");
+ checkMatcher(true, "%a_c%", "axcc");
+
+ // escape using back-slash
+ checkMatcher(true, "a\\_c", "a_c");
+ checkMatcher(false, "a\\_c", "abc");
+ checkMatcher(true, "a\\%c", "a%c");
+ checkMatcher(false, "a\\%c", "abc");
+ checkMatcher(false, "a\\%c", "a\\%c");
+ checkMatcher(true, "%\\_%", "a_b");
+ checkMatcher(false, "%\\_%", "ab");
+
+ // multiple wild-cards
+ checkMatcher(true, "a%c%d", "abcdaaad");
+ checkMatcher(false, "a%c%d", "abcdc");
+ checkMatcher(true, "%", "anything");
+ checkMatcher(true, "meta%", "metadata");
+ checkMatcher(false, "meta%", "other");
+ checkMatcher(true, "%data", "metadata");
+ checkMatcher(true, "m%a", "metadata");
+ checkMatcher(false, "m%z", "metadata");
+ checkMatcher(true, "a%%b", "ab");
+ checkMatcher(true, "a%b%c", "axxbyyc");
+ checkMatcher(false, "a%b%c", "axxcyyb");
+ checkMatcher(true, "%a%", "a");
+ checkMatcher(true, "%a%", "aa");
+ checkMatcher(true, "%a%", "aaa");
+ checkMatcher(false, "a%a", "a");
+ checkMatcher(true, "%a%a", "aa");
+ checkMatcher(false, "a%a%a", "aa");
+ checkMatcher(true, "%a%ab%", "aab");
+ checkMatcher(true, "%a%ab%", "abab");
+ checkMatcher(true, "%ab%abc%", "ababc");
+ checkMatcher(true, "%ab%abc%", "aababc");
+ checkMatcher(true, "%ab%abc%", "abxabc");
+ checkMatcher(true, "%ab%abc%", "ababcd");
+ checkMatcher(false, "%ab%abc%", "ababxc");
+ checkMatcher(true, "%%a", "xa");
+ checkMatcher(false, "%%a", "ax");
+ checkMatcher(true, "a%%", "ax");
+ checkMatcher(false, "a%%", "xa");
+
+ // A run of '%' wild-cards is equivalent to a single one
+ checkMatcher(true, "%%", "");
+ checkMatcher(true, "m%%%a", "metadata");
+ checkMatcher(false, "m%%%z", "metadata");
+
+ // An escaped '%' is a literal, and does not end a run
+ checkMatcher(true, "100\\%", "100%");
+ checkMatcher(false, "100\\%", "100x");
+ checkMatcher(true, "%\\%%", "50% off");
+ checkMatcher(false, "%\\%%", "half off");
+ checkMatcher(true, "a\\%", "a%");
+ checkMatcher(false, "a\\%", "ab");
+ }
+
+ private void checkMatcher(boolean b, String pattern, String abc) {
+ assertEquals(b, LikePattern.matcher(pattern).apply(abc));
+ }
+
+ /** Test case for
+ * <a
href="https://issues.apache.org/jira/browse/CALCITE-7906">[CALCITE-7906]
+ * Improve LikePattern to support '_' wildcard and enhance the performance
+ * of '%' wildcards</a>. */
+ @Test void testMatcherManyWildcards() {
+ final String name = Strings.repeat("a", 40);
+ assertTimeoutPreemptively(Duration.ofSeconds(10), () -> {
+ // Consecutive wildcards
+ assertThat(
+ LikePattern.matcher(Strings.repeat("%", 10) + "x").apply(name),
+ is(false));
+ // Wildcards separated by literals
+ assertThat(
+ LikePattern.matcher(Strings.repeat("%a", 10) + "%x").apply(name),
+ is(false));
+ // Combination of wildcards
+ assertThat(
+ LikePattern.matcher(Strings.repeat("%_", 10) + "%x").apply(name),
+ is(false));
+ });
+ }
+}
diff --git a/core/src/test/java/org/apache/calcite/test/JdbcTest.java
b/core/src/test/java/org/apache/calcite/test/JdbcTest.java
index e95a29ed1b..a4544b1d58 100644
--- a/core/src/test/java/org/apache/calcite/test/JdbcTest.java
+++ b/core/src/test/java/org/apache/calcite/test/JdbcTest.java
@@ -151,7 +151,6 @@
import java.util.TimeZone;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
-import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.sql.DataSource;
@@ -1068,36 +1067,6 @@ private String mm(int majorVersion, int minorVersion) {
connection.close();
}
- /** Unit test for
- * {@link LikePattern#likeToRegex(org.apache.calcite.avatica.Meta.Pat)}. */
- @Test void testLikeToRegex() {
- checkLikeToRegex(true, "%", "abc");
- checkLikeToRegex(true, "abc", "abc");
- checkLikeToRegex(false, "abc", "abcd"); // trailing char fails match
- checkLikeToRegex(false, "abc", "0abc"); // leading char fails match
- checkLikeToRegex(false, "abc", "aBc"); // case-sensitive match
- checkLikeToRegex(true, "a[b]c", "a[b]c"); // nothing special about brackets
- checkLikeToRegex(true, "a$c", "a$c"); // nothing special about dollar
- checkLikeToRegex(false, "a$", "a"); // nothing special about dollar
- checkLikeToRegex(true, "a%c", "ac");
- checkLikeToRegex(true, "a%c", "abbbc");
- checkLikeToRegex(false, "a%c", "acccd");
-
- // escape using back-slash
- checkLikeToRegex(true, "a\\%c", "a%c");
- checkLikeToRegex(false, "a\\%c", "abc");
- checkLikeToRegex(false, "a\\%c", "a\\%c");
-
- // multiple wild-cards
- checkLikeToRegex(true, "a%c%d", "abcdaaad");
- checkLikeToRegex(false, "a%c%d", "abcdc");
- }
-
- private void checkLikeToRegex(boolean b, String pattern, String abc) {
- final Pattern regex = LikePattern.likeToRegex(pattern);
- assertTrue(b == regex.matcher(abc).matches());
- }
-
/** Tests driver's implementation of {@link DatabaseMetaData#getColumns},
* and also
* <a
href="https://issues.apache.org/jira/browse/CALCITE-1222">[CALCITE-1222]
diff --git a/site/_docs/history.md b/site/_docs/history.md
index 511cfd6b01..03b74344a6 100644
--- a/site/_docs/history.md
+++ b/site/_docs/history.md
@@ -150,6 +150,13 @@ #### Breaking Changes
`-Dcalcite.geode.pdxSerializablePackagePath.allowed=true`. A model that sets
the operand
without the opt-in will fail. Java callers of `GeodeUtils.createClientCache`
are unaffected.
+* [<a
href="https://issues.apache.org/jira/browse/CALCITE-7906">CALCITE-7906</a>]
+`LikePattern`, which handles the patterns passed to JDBC `DatabaseMetaData`
methods such as
+`getTables` and `getColumns` and to `Lookup.getNames`, now supports the `_`
wildcard (any single
+character) as well as `%`, as the JDBC specification requires. Previously `_`
matched only itself.
+For example, the pattern `EMP_DEPT` now also matches `EMPXDEPT`. To match a
literal `_`, escape it
+as `\_`. The public method `LikePattern.likeToRegex` has been removed as part
of this improvement.
+
#### New features
{: #new-features-1-43-0}