This is an automated email from the ASF dual-hosted git repository.

xuzifu666 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 0211999427 [CALCITE-7673] Add support for the LIKE operator to the 
MongoDB adapter
0211999427 is described below

commit 0211999427e294114db8f6b58cad95870cc13ccb
Author: Yu Xu <[email protected]>
AuthorDate: Mon Jul 27 17:31:12 2026 +0800

    [CALCITE-7673] Add support for the LIKE operator to the MongoDB adapter
---
 .../calcite/adapter/mongodb/MongoFilter.java       | 182 +++++++++++++++++++++
 .../calcite/adapter/mongodb/MongoAdapterTest.java  | 164 +++++++++++++++++++
 2 files changed, 346 insertions(+)

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 00a26c5fe3..c3d12a84be 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.sql.SqlKind;
 import org.apache.calcite.sql.type.SqlTypeName;
 import org.apache.calcite.util.JsonBuilder;
 import org.apache.calcite.util.Pair;
@@ -222,6 +223,10 @@ private Void translateMatch2(RexNode node, 
List<Map<String, Object>> orMapList,
         return translateUnary("$ne", (RexCall) node, multimap, eqMap);
       case IS_NULL:
         return translateUnary("$eq", (RexCall) node, multimap, eqMap);
+      case LIKE:
+        return translateLike((RexCall) node, multimap);
+      case NOT:
+        return translateNot((RexCall) node, orMapList);
       default:
         throw new AssertionError("cannot translate " + node);
       }
@@ -302,5 +307,182 @@ private Void translateUnary(String op, RexCall call,
       translateBinary2(op, left, right, multimap, eqMap);
       return null;
     }
+
+    /** Translates LIKE to {$regex: ...}. */
+    private Void translateLike(RexCall call,
+        Multimap<String, Pair<String, RexLiteral>> multimap) {
+      final RexNode left = stripCast(call.operands.get(0));
+      final RexNode right = call.operands.get(1);
+
+      // LIKE must have a literal on the right side
+      if (right.getKind() != SqlKind.LITERAL) {
+        throw new AssertionError("cannot translate LIKE with non-literal 
pattern: " + call);
+      }
+      final RexLiteral patternLiteral = (RexLiteral) right;
+      final String sqlPattern = patternLiteral.getValue2().toString();
+
+      final @Nullable Character escapeChar = escapeChar(call);
+      final String finalRegex = sqlLikeToMongoRegex(sqlPattern, escapeChar);
+
+      switch (left.getKind()) {
+      case INPUT_REF:
+        final RexInputRef left1 = (RexInputRef) left;
+        String name = fieldNames.get(left1.getIndex());
+        multimap.put(name, Pair.of("$regex", 
rexBuilder.makeLiteral(finalRegex)));
+        return null;
+      case ITEM:
+        String itemName = MongoRules.isItem((RexCall) left);
+        if (itemName != null) {
+          multimap.put(itemName, Pair.of("$regex", 
rexBuilder.makeLiteral(finalRegex)));
+          return null;
+        }
+        // fall through
+      default:
+        throw new AssertionError("cannot translate LIKE " + call);
+      }
+    }
+
+    /** Translates NOT to a MongoDB $nor expression. */
+    private Void translateNot(RexCall call, List<Map<String, Object>> 
orMapList) {
+      final RexNode operand = call.operands.get(0);
+      switch (operand.getKind()) {
+      case LIKE:
+        return translateNotLike((RexCall) operand, orMapList);
+      default:
+        throw new AssertionError("cannot translate NOT " + call);
+      }
+    }
+
+    /** Translates NOT LIKE to {$nor: [{field: {$regex: ...}}]}. */
+    private Void translateNotLike(RexCall call, List<Map<String, Object>> 
orMapList) {
+      final RexNode left = stripCast(call.operands.get(0));
+      final RexNode right = call.operands.get(1);
+
+      if (right.getKind() != SqlKind.LITERAL) {
+        throw new AssertionError("cannot translate NOT LIKE with non-literal 
pattern: " + 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 String name;
+      switch (left.getKind()) {
+      case INPUT_REF:
+        final RexInputRef left1 = (RexInputRef) left;
+        name = fieldNames.get(left1.getIndex());
+        break;
+      case ITEM:
+        String itemName = MongoRules.isItem((RexCall) left);
+        if (itemName != null) {
+          name = itemName;
+          break;
+        }
+        // fall through
+      default:
+        throw new AssertionError("cannot translate NOT LIKE " + call);
+      }
+
+      Map<String, Object> regexMap = builder.map();
+      Map<String, Object> regexOp = builder.map();
+      regexOp.put("$regex", finalRegex);
+      regexMap.put(name, regexOp);
+      List<Object> norList = builder.list();
+      norList.add(regexMap);
+      Map<String, Object> norMap = builder.map();
+      norMap.put("$nor", norList);
+      orMapList.add(norMap);
+      return null;
+    }
+
+    /** Strips a leading CAST, if any. MongoDB is implicitly typed. */
+    private static RexNode stripCast(RexNode node) {
+      if (node.getKind() == SqlKind.CAST) {
+        return ((RexCall) node).operands.get(0);
+      }
+      return node;
+    }
+
+    /** Returns the escape character declared in a LIKE expression, or null. */
+    private static @Nullable Character escapeChar(RexCall call) {
+      if (call.operands.size() != 3) {
+        return null;
+      }
+      final RexNode escapeNode = call.operands.get(2);
+      if (escapeNode.getKind() != SqlKind.LITERAL) {
+        throw new AssertionError("cannot translate LIKE with non-literal 
escape: " + call);
+      }
+      final String escape = ((RexLiteral) escapeNode).getValue2().toString();
+      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.
+     *
+     * <p>SQL: {@code %} matches zero or more characters, {@code _} matches a 
single
+     * character. MongoDB: {@code .*} matches zero or more characters, {@code 
.}
+     * matches a single character.
+     *
+     * <p>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);
+      }
+    }
   }
 }
diff --git 
a/mongodb/src/test/java/org/apache/calcite/adapter/mongodb/MongoAdapterTest.java
 
b/mongodb/src/test/java/org/apache/calcite/adapter/mongodb/MongoAdapterTest.java
index c4fef104f8..aa5dc299ca 100644
--- 
a/mongodb/src/test/java/org/apache/calcite/adapter/mongodb/MongoAdapterTest.java
+++ 
b/mongodb/src/test/java/org/apache/calcite/adapter/mongodb/MongoAdapterTest.java
@@ -1211,4 +1211,168 @@ private static Consumer<List> mongoChecker(final 
String... expected) {
             "CITY_SUBSTRING=RA",
             "CITY_SUBSTRING=UT");
   }
+
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7673";>[CALCITE-7673]
+   * MongoDB Adapter can not support LIKE operator</a>. */
+  @Test void testLikePrefix() {
+    // Test LIKE on state field - matches states starting with 'A'
+    assertModel(MODEL)
+        .query("select state, city from zips where state like 'A%' order by 
state")
+        .returnsUnordered(
+            "STATE=AK; CITY=ANCHORAGE",
+            "STATE=AK; CITY=FAIRBANKS",
+            "STATE=AK; CITY=JUNEAU",
+            "STATE=AL; CITY=CENTER POINT",
+            "STATE=AL; CITY=TUSCALOOSA",
+            "STATE=AL; CITY=SOUTHSIDE",
+            "STATE=AR; CITY=CONWAY",
+            "STATE=AR; CITY=GRAVEL RIDGE",
+            "STATE=AR; CITY=JONESBORO",
+            "STATE=AZ; CITY=MESA",
+            "STATE=AZ; CITY=PHOENIX",
+            "STATE=AZ; CITY=YUMA");
+  }
+
+  /** Test case for LIKE operator with suffix pattern (ends with). */
+  @Test void testLikeSuffix() {
+    assertModel(MODEL)
+        .query("select state, city from zips where city like '%TON' order by 
state, city")
+        .limit(5)
+        .returnsOrdered(
+            "STATE=DC; CITY=WASHINGTON",
+            "STATE=KY; CITY=HATTON",
+            "STATE=MA; CITY=BROCKTON",
+            "STATE=ME; CITY=LEWISTON",
+            "STATE=MN; CITY=NEW BRIGHTON");
+  }
+
+  /** Test case for LIKE operator with contains pattern. */
+  @Test void testLikeContains() {
+    assertModel(MODEL)
+        .query("select state, city from zips where city like '%ING%' order by 
state")
+        .limit(5)
+        .returnsOrdered(
+            "STATE=DC; CITY=WASHINGTON",
+            "STATE=MA; CITY=FRAMINGHAM",
+            "STATE=MO; CITY=JENNINGS",
+            "STATE=MT; CITY=BILLINGS",
+            "STATE=NC; CITY=LEXINGTON");
+  }
+
+  /** Test case for LIKE operator with single character wildcard. */
+  @Test void testLikeSingleChar() {
+    // Pattern 'NEW ______' matches cities starting with 'NEW ' followed by 
exactly 6 characters
+    // NEW IBERIA: "NEW " + "IBERIA" (6 chars) = matches
+    // NEW ORLEANS: "NEW " + "ORLEANS" (7 chars) = does not match
+    // NEW YORK: "NEW " + "YORK" (4 chars) = does not match
+    assertModel(MODEL)
+        .query("select city, state from zips where city like 'NEW ______' 
order by city")
+        .returnsOrdered(
+            "CITY=NEW IBERIA; STATE=LA");
+  }
+
+  /** Test case for LIKE operator combined with other filters. */
+  @Test void testLikeCombinedWithOtherFilters() {
+    assertModel(MODEL)
+        .query("select city, state from zips where city like 'L%' and state = 
'CA' order by city")
+        .returnsOrdered(
+            "CITY=LOS ANGELES; STATE=CA");
+  }
+
+  /** Test case for LIKE operator verifying the generated MongoDB regex. */
+  @Test void testLikeGeneratedRegex() {
+    assertModel(MODEL)
+        .query("select state from zips where city like 'A%'")
+        .queryContains(
+            mongoChecker(
+                "{$match: {city: {$regex: '^A.*$'}}}",
+                "{$project: {STATE: '$state'}}"))
+        .returnsUnordered(
+            "STATE=AK",
+            "STATE=IA",
+            "STATE=SC",
+            "STATE=SD",
+            "STATE=TX");
+  }
+
+  /** Test case for LIKE operator with escape character on underscore. */
+  @Test void testLikeEscapeUnderscore() {
+    // Without escape, '_' matches a single character.
+    assertModel(MODEL)
+        .query("select city from zips where city like 'BROOKLY_'")
+        .returnsUnordered("CITY=BROOKLYN");
+    // With escape, '_' is a literal character and does not match BROOKLYN.
+    assertModel(MODEL)
+        .query("select city from zips where city like 'BROOKLY\\_' ESCAPE 
'\\'")
+        .returnsUnordered();
+  }
+
+  /** Test case for LIKE operator with escape character on percent. */
+  @Test void testLikeEscapePercent() {
+    // Without escape, '%' matches zero or more characters.
+    assertModel(MODEL)
+        .query("select city from zips where city like 'BROOKLYN%'")
+        .returnsUnordered("CITY=BROOKLYN");
+    // With escape, '%' is a literal character and does not match BROOKLYN.
+    assertModel(MODEL)
+        .query("select city from zips where city like 'BROOKLYN\\%' ESCAPE 
'\\'")
+        .returnsUnordered();
+  }
+
+  /** Test case for LIKE operator without a default escape character. */
+  @Test void testLikeNoDefaultEscape() {
+    // Without ESCAPE, '\' is an ordinary character; 'A\%' matches cities
+    // starting with 'A%' and should return nothing.
+    assertModel(MODEL)
+        .query("select city from zips where city like 'A\\%'")
+        .returnsUnordered();
+  }
+
+  /** Test case for LIKE operator escaping regex special characters. */
+  @Test void testLikeRegexSpecialChar() {
+    // '.' is an ordinary SQL LIKE character and must be escaped in the
+    // generated MongoDB regex; otherwise it would match arbitrary characters.
+    assertModel(MODEL)
+        .query("select city from zips where city like 'A.B%'")
+        .returnsUnordered();
+  }
+
+  /** Test case for LIKE operator with a custom escape character. */
+  @Test void testLikeCustomEscapeChar() {
+    // Use '!' as the escape character. Here '%' is a wildcard.
+    assertModel(MODEL)
+        .query("select city from zips where city like 'BROOKLYN%' ESCAPE '!'")
+        .returnsUnordered("CITY=BROOKLYN");
+    // '!%' makes '%' a literal character, so it does not match BROOKLYN.
+    assertModel(MODEL)
+        .query("select city from zips where city like 'BROOKLYN!%' ESCAPE '!'")
+        .returnsUnordered();
+  }
+
+  /** Test case for LIKE operator verifying the generated regex with escapes. 
*/
+  @Test void testLikeGeneratedRegexWithEscape() {
+    assertModel(MODEL)
+        .query("select state from zips where city like 'A\\_B\\%C%' ESCAPE 
'\\'")
+        .queryContains(
+            mongoChecker(
+                "{$match: {city: {$regex: '^A_B%C.*$'}}}",
+                "{$project: {STATE: '$state'}}"))
+        .returnsUnordered();
+  }
+
+  /** Test case for NOT LIKE operator. */
+  @Test void testNotLike() {
+    assertModel(MODEL)
+        .query("select city from zips where city not like 'A%'")
+        .returnsCount(144);
+  }
+
+  /** Test case for LIKE operator on ITEM (_MAP) access. */
+  @Test void testLikeItem() {
+    assertModel(MODEL)
+        .query("select cast(_MAP['city'] as varchar) from 
\"mongo_raw\".\"zips\" "
+            + "where _MAP['city'] like 'A%'")
+        .returnsCount(5);
+  }
 }

Reply via email to