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}
 

Reply via email to