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 fbfdf89206 [CALCITE-7667] Improve string-literal encoding in pushdown 
translators
fbfdf89206 is described below

commit fbfdf89206a9c65f64355e6fdeac05f025a471dd
Author: Ruben Quesada Lopez <[email protected]>
AuthorDate: Fri Jul 24 12:53:21 2026 +0100

    [CALCITE-7667] Improve string-literal encoding in pushdown translators
    
    Co-authored-by: bibi samina <[email protected]>
---
 .../calcite/adapter/cassandra/CassandraFilter.java |  22 +++-
 .../cassandra/CassandraFilterTranslatorTest.java   | 138 +++++++++++++++++++++
 .../apache/calcite/test/CassandraAdapterTest.java  |   9 ++
 .../java/org/apache/calcite/sql/SqlDialect.java    |   7 ++
 .../calcite/sql/dialect/BigQuerySqlDialect.java    |   2 +-
 .../calcite/sql/dialect/MysqlSqlDialect.java       |  10 ++
 .../calcite/rel/rel2sql/RelToSqlConverterTest.java |  32 ++++-
 .../calcite/adapter/geode/rel/GeodeFilter.java     |   4 +-
 .../geode/rel/GeodeFilterTranslatorTest.java       |  98 +++++++++++++++
 .../calcite/adapter/geode/rel/GeodeZipsTest.java   |  11 ++
 .../org/apache/calcite/adapter/pig/PigFilter.java  |   7 +-
 .../adapter/pig/PigFilterLiteralEscapeTest.java    |  79 ++++++++++++
 .../org/apache/calcite/test/PigAdapterTest.java    |  15 +++
 13 files changed, 426 insertions(+), 8 deletions(-)

diff --git 
a/cassandra/src/main/java/org/apache/calcite/adapter/cassandra/CassandraFilter.java
 
b/cassandra/src/main/java/org/apache/calcite/adapter/cassandra/CassandraFilter.java
index f357079d61..8da8cff991 100644
--- 
a/cassandra/src/main/java/org/apache/calcite/adapter/cassandra/CassandraFilter.java
+++ 
b/cassandra/src/main/java/org/apache/calcite/adapter/cassandra/CassandraFilter.java
@@ -47,6 +47,7 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
+import java.util.regex.Pattern;
 
 import static 
org.apache.calcite.util.DateTimeStringUtils.ISO_DATETIME_FRACTIONAL_SECOND_FORMAT;
 import static org.apache.calcite.util.DateTimeStringUtils.getDateFormatter;
@@ -126,6 +127,11 @@ public RelCollation getImplicitCollation() {
 
   /** Translates {@link RexNode} expressions into Cassandra expression 
strings. */
   static class Translator {
+    /** Canonical UUID form: 8-4-4-4-12 hex digits (case-insensitive). */
+    private static final Pattern UUID_PATTERN =
+        Pattern.compile("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}"
+            + "-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}");
+
     private final RelDataType rowType;
     private final List<String> fieldNames;
     private final Set<String> partitionKeys;
@@ -301,8 +307,20 @@ private String translateOp2(String op, String name, 
RexLiteral right) {
         RelDataTypeField field =
             requireNonNull(rowType.getField(name, true, false));
         SqlTypeName typeName = field.getType().getSqlTypeName();
-        if (typeName != SqlTypeName.CHAR) {
-          valueString = "'" + valueString + "'";
+        if (typeName == SqlTypeName.CHAR) {
+          // Cassandra UUID and TIMEUUID columns are mapped to
+          // SqlTypeName.CHAR (see CqlToSqlTypeConversionRules),
+          // CQL accepts UUIDs as bare 8-4-4-4-12 hex literals
+          if (!UUID_PATTERN.matcher(valueString).matches()) {
+            throw new IllegalArgumentException(
+                "Cannot push down filter on Cassandra uuid/timeuuid column '"
+                    + name + "': value is not a well-formed UUID");
+          }
+          // valueString is a validated UUID; safe to emit unquoted.
+        } else {
+          // CQL string literals use `''` to represent a single `'` inside a
+          // `'...'` literal, so double any embedded `'` before wrapping
+          valueString = "'" + valueString.replace("'", "''") + "'";
         }
       }
       return name + " " + op + " " + valueString;
diff --git 
a/cassandra/src/test/java/org/apache/calcite/adapter/cassandra/CassandraFilterTranslatorTest.java
 
b/cassandra/src/test/java/org/apache/calcite/adapter/cassandra/CassandraFilterTranslatorTest.java
new file mode 100644
index 0000000000..49c177b847
--- /dev/null
+++ 
b/cassandra/src/test/java/org/apache/calcite/adapter/cassandra/CassandraFilterTranslatorTest.java
@@ -0,0 +1,138 @@
+/*
+ * 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.adapter.cassandra;
+
+import org.apache.calcite.jdbc.JavaTypeFactoryImpl;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rel.type.RelDataTypeFactory;
+import org.apache.calcite.rex.RexBuilder;
+import org.apache.calcite.rex.RexInputRef;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.sql.fun.SqlStdOperatorTable;
+import org.apache.calcite.sql.type.SqlTypeName;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.Collections;
+
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Unit tests for {@link CassandraFilter.Translator} covering CQL literal 
serialization.
+ *
+ * <p>The tests exercise the translator directly against a synthetic row
+ * type; they do not require a running Cassandra cluster.
+ */
+class CassandraFilterTranslatorTest {
+
+  private static final RelDataTypeFactory TYPE_FACTORY = new 
JavaTypeFactoryImpl();
+  private static final RexBuilder REX_BUILDER = new RexBuilder(TYPE_FACTORY);
+
+  /** Two-column row type mirroring how CqlToSqlTypeConversionRules
+   * maps Cassandra types: a CHAR column stands in for `uuid`/`timeuuid`
+   * and a VARCHAR column stands in for `text`. */
+  private static RelDataType rowType() {
+    return TYPE_FACTORY.builder()
+        .add("id", SqlTypeName.CHAR, 36)
+        .add("name", SqlTypeName.VARCHAR)
+        .build();
+  }
+
+  /** Invokes the (private) translator's translateMatch entry point via 
reflection.
+   * Any exception is unwrapped from InvocationTargetException so callers can
+   * see the real cause. */
+  private static String translate(RexNode condition) throws Throwable {
+    CassandraFilter.Translator t =
+        new CassandraFilter.Translator(rowType(),
+            Collections.singletonList("id"),
+            Collections.emptyList(),
+            Collections.emptyList());
+    Method m = CassandraFilter.Translator.class
+        .getDeclaredMethod("translateMatch", RexNode.class);
+    m.setAccessible(true);
+    try {
+      return (String) m.invoke(t, condition);
+    } catch (InvocationTargetException e) {
+      throw e.getCause();
+    }
+  }
+
+  private static RexNode eqLit(int fieldIndex, SqlTypeName fieldType,
+      int precision, String value) {
+    RelDataType t = precision > 0
+        ? TYPE_FACTORY.createSqlType(fieldType, precision)
+        : TYPE_FACTORY.createSqlType(fieldType);
+    RexInputRef ref = REX_BUILDER.makeInputRef(t, fieldIndex);
+    RexNode lit = REX_BUILDER.makeLiteral(value, t, false);
+    return REX_BUILDER.makeCall(SqlStdOperatorTable.EQUALS, ref, lit);
+  }
+
+  @Test void uuidColumnValidUuidEmittedUnquoted() throws Throwable {
+    String cql =
+        translate(eqLit(0, SqlTypeName.CHAR, 36, 
"037f7c30-abcd-11ee-8000-000000000001"));
+    assertThat(cql, is("id = 037f7c30-abcd-11ee-8000-000000000001"));
+  }
+
+  @Test void uuidColumnNonUuidValueRejected() {
+    IllegalArgumentException e =
+        assertThrows(
+            IllegalArgumentException.class, () -> translate(
+                eqLit(0, SqlTypeName.CHAR, 36, "not-a-uuid")));
+    assertThat(e.getMessage(), containsString("not a well-formed UUID"));
+  }
+
+  @Test void uuidColumnEmptyRejected() {
+    assertThrows(IllegalArgumentException.class,
+        () -> translate(eqLit(0, SqlTypeName.CHAR, 36, "")));
+  }
+
+  @Test void uuidColumnAlmostUuidRejected() {
+    assertThrows(IllegalArgumentException.class,
+        () -> translate(
+            eqLit(0, SqlTypeName.CHAR, 36, 
"037f7c30-abcd-11ee-8000-000000000001x")));
+  }
+
+  @Test void varcharColumnPlainValueQuoted() throws Throwable {
+    String cql = translate(eqLit(1, SqlTypeName.VARCHAR, -1, "alice"));
+    assertThat(cql, is("name = 'alice'"));
+  }
+
+  @Test void varcharColumnValueWithApostrophe() throws Throwable {
+    String cql = translate(eqLit(1, SqlTypeName.VARCHAR, -1, "O'Brien"));
+    assertThat(cql, is("name = 'O''Brien'"));
+  }
+
+  @Test void varcharColumnValueWithMultipleApostrophes() throws Throwable {
+    String cql = translate(eqLit(1, SqlTypeName.VARCHAR, -1, "a''b"));
+    assertThat(cql, is("name = 'a''''b'"));
+  }
+
+  @Test void varcharColumnValueWithApostropheAtTheStart() throws Throwable {
+    String cql = translate(eqLit(1, SqlTypeName.VARCHAR, -1, "'a"));
+    assertThat(cql, is("name = '''a'"));
+  }
+
+  @Test void varcharColumnValueWithApostropheAtTheEnd() throws Throwable {
+    String cql = translate(eqLit(1, SqlTypeName.VARCHAR, -1, "a'"));
+    assertThat(cql, is("name = 'a'''"));
+  }
+}
diff --git 
a/cassandra/src/test/java/org/apache/calcite/test/CassandraAdapterTest.java 
b/cassandra/src/test/java/org/apache/calcite/test/CassandraAdapterTest.java
index 411ffdbb65..88898e6a04 100644
--- a/cassandra/src/test/java/org/apache/calcite/test/CassandraAdapterTest.java
+++ b/cassandra/src/test/java/org/apache/calcite/test/CassandraAdapterTest.java
@@ -71,6 +71,15 @@ static void load(CqlSession session) {
            + "    CassandraTableScan(table=[[twissandra, userline]]");
   }
 
+  @Test void testFilterWithSingleQuote() {
+    // A string literal containing a single quote must be escaped so it does 
not
+    // break out of the CQL string literal in the generated query.
+    CalciteAssert.that()
+        .with(TWISSANDRA)
+        .query("select * from \"userline\" where \"username\" = 'a''b'")
+        .returnsCount(0);
+  }
+
   @Test void testFilterUUID() {
     CalciteAssert.that()
         .with(TWISSANDRA)
diff --git a/core/src/main/java/org/apache/calcite/sql/SqlDialect.java 
b/core/src/main/java/org/apache/calcite/sql/SqlDialect.java
index 663aee63b0..164f212c6c 100644
--- a/core/src/main/java/org/apache/calcite/sql/SqlDialect.java
+++ b/core/src/main/java/org/apache/calcite/sql/SqlDialect.java
@@ -445,6 +445,13 @@ public void quoteStringLiteral(StringBuilder buf, 
@Nullable String charsetName,
     buf.append(literalEndQuoteString);
   }
 
+  /** Doubles every backslash in {@code val}, for dialects whose backend
+   * treats {@code \} as an in-string escape character (e.g. MySQL,
+   * MariaDB, BigQuery). */
+  protected static String escapeBackslash(String val) {
+    return val.replace("\\", "\\\\");
+  }
+
   public void unparseCall(SqlWriter writer, SqlCall call, int leftPrec,
       int rightPrec) {
     SqlOperator operator = call.getOperator();
diff --git 
a/core/src/main/java/org/apache/calcite/sql/dialect/BigQuerySqlDialect.java 
b/core/src/main/java/org/apache/calcite/sql/dialect/BigQuerySqlDialect.java
index 6683054819..13314a1aed 100644
--- a/core/src/main/java/org/apache/calcite/sql/dialect/BigQuerySqlDialect.java
+++ b/core/src/main/java/org/apache/calcite/sql/dialect/BigQuerySqlDialect.java
@@ -124,7 +124,7 @@ public BigQuerySqlDialect(SqlDialect.Context context) {
     // enclosing quote as \'. Otherwise a value containing a backslash (e.g.
     // "x\" or "\'; ...") terminates the literal early and the trailing text is
     // parsed as SQL rather than data.
-    super.quoteStringLiteral(buf, charsetName, val.replace("\\", "\\\\"));
+    super.quoteStringLiteral(buf, charsetName, escapeBackslash(val));
   }
 
   @Override public boolean supportsImplicitTypeCoercion(RexCall call) {
diff --git 
a/core/src/main/java/org/apache/calcite/sql/dialect/MysqlSqlDialect.java 
b/core/src/main/java/org/apache/calcite/sql/dialect/MysqlSqlDialect.java
index 7b4475283e..03d4d2c504 100644
--- a/core/src/main/java/org/apache/calcite/sql/dialect/MysqlSqlDialect.java
+++ b/core/src/main/java/org/apache/calcite/sql/dialect/MysqlSqlDialect.java
@@ -126,6 +126,16 @@ public MysqlSqlDialect(Context context) {
     return false;
   }
 
+  @Override public void quoteStringLiteral(StringBuilder buf,
+      @Nullable String charsetName, String val) {
+    // MySQL treats backslash as an escape character inside string literals,
+    // so a literal backslash must be doubled before the base method escapes 
the
+    // enclosing quote as \'. Otherwise a value containing a backslash (e.g.
+    // "x\" or "\'; ...") terminates the literal early and the trailing text is
+    // parsed as SQL rather than data.
+    super.quoteStringLiteral(buf, charsetName, escapeBackslash(val));
+  }
+
   @Override public boolean requiresAliasForFromItems() {
     return true;
   }
diff --git 
a/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java 
b/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java
index 279454f6b3..c04da0a26b 100644
--- 
a/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java
+++ 
b/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java
@@ -9510,6 +9510,36 @@ private void checkLiteral2(String expression, String 
expected) {
     });
   }
 
+  /** Test case for the MySQL-family backslash-escape bypass:
+   * dialects whose backend treats {@code \} as an in-string escape
+   * character must double it in
+   * {@link SqlDialect#quoteStringLiteral(StringBuilder, String, String)}. */
+  @Test void testDialectQuoteStringLiteralWithBackslash() {
+    dialects().forEach((dialect, databaseProduct) -> {
+      final boolean escapesBackslash =
+          databaseProduct == DatabaseProduct.BIG_QUERY
+              || databaseProduct == DatabaseProduct.MYSQL
+              || databaseProduct == DatabaseProduct.STARROCKS
+              || databaseProduct == DatabaseProduct.DORIS;
+
+      // Trailing backslash
+      assertThat(dialect.quoteStringLiteral("x\\"),
+          escapesBackslash ? is("'x\\\\'") : is("'x\\'"));
+
+      // Leading backslash
+      assertThat(dialect.quoteStringLiteral("\\x"),
+          escapesBackslash ? is("'\\\\x'") : is("'\\x'"));
+
+      // Backslash followed by content
+      assertThat(dialect.quoteStringLiteral("x\\y"),
+          escapesBackslash ? is("'x\\\\y'") : is("'x\\y'"));
+
+      // Two consecutive backslashes must both be doubled
+      assertThat(dialect.quoteStringLiteral("x\\\\"),
+          escapesBackslash ? is("'x\\\\\\\\'") : is("'x\\\\'"));
+    });
+  }
+
   @Test void testSelectCountStar() {
     final String query = "select count(*) from \"product\"";
     final String expected = "SELECT COUNT(*)\n"
@@ -11166,7 +11196,7 @@ private void checkLiteral2(String expression, String 
expected) {
     final String query = "SELECT TRIM(BOTH '$@*A' from 
'$@*AABC$@*AADCAA$@*A')\n"
         + "from \"foodmart\".\"reserve_employee\"";
     final String expectedStarRocks = "SELECT 
REGEXP_REPLACE('$@*AABC$@*AADCAA$@*A',"
-        + " '^(\\$\\@\\*A)*|(\\$\\@\\*A)*$', '')\n"
+        + " '^(\\\\$\\\\@\\\\*A)*|(\\\\$\\\\@\\\\*A)*$', '')\n"
         + "FROM `foodmart`.`reserve_employee`";
     sql(query).withStarRocks().ok(expectedStarRocks)
         .withDoris().ok(expectedStarRocks);
diff --git 
a/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeFilter.java 
b/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeFilter.java
index dafc0122d1..47d43728c6 100644
--- a/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeFilter.java
+++ b/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeFilter.java
@@ -395,7 +395,9 @@ private String translateBinary2(String op, RexNode left,
     private static String quoteCharLiteral(RexLiteral literal) {
       String value = literalValue(literal);
       if (literal.getTypeName() == CHAR) {
-        value = "'" + value + "'";
+        // OQL string literals use `''` to represent a single `'` inside
+        // a `'...'` literal, so double any embedded `'` before wrapping
+        value = "'" + value.replace("'", "''") + "'";
       }
       return value;
     }
diff --git 
a/geode/src/test/java/org/apache/calcite/adapter/geode/rel/GeodeFilterTranslatorTest.java
 
b/geode/src/test/java/org/apache/calcite/adapter/geode/rel/GeodeFilterTranslatorTest.java
new file mode 100644
index 0000000000..35f21d4f73
--- /dev/null
+++ 
b/geode/src/test/java/org/apache/calcite/adapter/geode/rel/GeodeFilterTranslatorTest.java
@@ -0,0 +1,98 @@
+/*
+ * 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.adapter.geode.rel;
+
+import org.apache.calcite.jdbc.JavaTypeFactoryImpl;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rel.type.RelDataTypeFactory;
+import org.apache.calcite.rex.RexBuilder;
+import org.apache.calcite.rex.RexInputRef;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.sql.fun.SqlStdOperatorTable;
+import org.apache.calcite.sql.type.SqlTypeName;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+/**
+ * Unit tests for {@link GeodeFilter.Translator} covering OQL literal 
serialization.
+ *
+ * <p>The tests exercise the translator directly against a synthetic
+ * row type; they do not require a running Geode cluster.
+ */
+class GeodeFilterTranslatorTest {
+
+  private static final RelDataTypeFactory TYPE_FACTORY = new 
JavaTypeFactoryImpl();
+  private static final RexBuilder REX_BUILDER = new RexBuilder(TYPE_FACTORY);
+
+  private static RelDataType rowType() {
+    return TYPE_FACTORY.builder()
+        .add("code", SqlTypeName.CHAR, 32)
+        .build();
+  }
+
+  /** Invokes the (private) translator's translateMatch entry
+   * point via reflection so this test can live in the same package
+   * without widening the Translator's visibility. Any exception the
+   * target throws is unwrapped from InvocationTargetException. */
+  private static String translate(RexNode condition) throws Throwable {
+    GeodeFilter.Translator t =
+        new GeodeFilter.Translator(rowType(), REX_BUILDER);
+    Method m = GeodeFilter.Translator.class
+        .getDeclaredMethod("translateMatch", RexNode.class);
+    m.setAccessible(true);
+    try {
+      return (String) m.invoke(t, condition);
+    } catch (InvocationTargetException e) {
+      throw e.getCause();
+    }
+  }
+
+  /** Builds a {@code code = <value>} predicate where the literal is
+   * typed as {@code CHAR(N)} matching {@code value.length()} exactly. */
+  private static RexNode eqLit(String value) {
+    RelDataType t = TYPE_FACTORY.createSqlType(SqlTypeName.CHAR, 
value.length());
+    RexInputRef ref = REX_BUILDER.makeInputRef(t, 0);
+    RexNode lit = REX_BUILDER.makeLiteral(value, t, false);
+    return REX_BUILDER.makeCall(SqlStdOperatorTable.EQUALS, ref, lit);
+  }
+
+  @Test void charColumnPlainValueQuoted() throws Throwable {
+    assertThat(translate(eqLit("alpha")), is("code = 'alpha'"));
+  }
+
+  @Test void charColumnValueWithApostrophe() throws Throwable {
+    assertThat(translate(eqLit("O'Brien")), is("code = 'O''Brien'"));
+  }
+
+  @Test void charColumnValueWithMultipleApostrophes() throws Throwable {
+    assertThat(translate(eqLit("a''b")), is("code = 'a''''b'"));
+  }
+
+  @Test void charColumnValueWithApostropheAtTheStart() throws Throwable {
+    assertThat(translate(eqLit("'a")), is("code = '''a'"));
+  }
+
+  @Test void charColumnValueWithApostropheAtTheEnd() throws Throwable {
+    assertThat(translate(eqLit("a'")), is("code = 'a'''"));
+  }
+}
diff --git 
a/geode/src/test/java/org/apache/calcite/adapter/geode/rel/GeodeZipsTest.java 
b/geode/src/test/java/org/apache/calcite/adapter/geode/rel/GeodeZipsTest.java
index bf42258eda..12053da96f 100644
--- 
a/geode/src/test/java/org/apache/calcite/adapter/geode/rel/GeodeZipsTest.java
+++ 
b/geode/src/test/java/org/apache/calcite/adapter/geode/rel/GeodeZipsTest.java
@@ -253,6 +253,17 @@ public void testJoin() {
             GeodeAssertions.query(expectedQuery));
   }
 
+  @Test void testFilterWithSingleQuoteLiteral() {
+    String expectedQuery = "SELECT city AS city FROM /zips "
+        + "WHERE city = 'a''b'";
+    calciteAssert()
+        .query("SELECT city as city "
+            + "FROM view WHERE city = 'a''b'")
+        .returnsCount(0)
+        .queryContains(
+            GeodeAssertions.query(expectedQuery));
+  }
+
   @Test void testSqlSingleStringWhereFilter() {
     String expectedQuery = "SELECT state AS state FROM /zips "
         + "WHERE state = 'NY'";
diff --git a/pig/src/main/java/org/apache/calcite/adapter/pig/PigFilter.java 
b/pig/src/main/java/org/apache/calcite/adapter/pig/PigFilter.java
index 9c720aeb84..f36b0dd6f5 100644
--- a/pig/src/main/java/org/apache/calcite/adapter/pig/PigFilter.java
+++ b/pig/src/main/java/org/apache/calcite/adapter/pig/PigFilter.java
@@ -133,10 +133,11 @@ private static boolean containsOnlyConjunctions(RexNode 
condition) {
 
   /**
    * Converts a literal to a Pig Latin string literal.
-   *
-   * <p>TODO: do proper literal to string conversion + escaping
    */
   private static String getLiteralAsString(RexLiteral literal) {
-    return '\'' + RexLiteral.stringValue(literal) + '\'';
+    // Pig Latin string literals use `''` to represent a single `'` inside
+    // a `'...'` literal, so double any embedded `'` before wrapping
+    final String raw = RexLiteral.stringValue(literal);
+    return '\'' + (raw != null ? raw.replace("'", "''") : null) + '\'';
   }
 }
diff --git 
a/pig/src/test/java/org/apache/calcite/adapter/pig/PigFilterLiteralEscapeTest.java
 
b/pig/src/test/java/org/apache/calcite/adapter/pig/PigFilterLiteralEscapeTest.java
new file mode 100644
index 0000000000..f90bbb3cbd
--- /dev/null
+++ 
b/pig/src/test/java/org/apache/calcite/adapter/pig/PigFilterLiteralEscapeTest.java
@@ -0,0 +1,79 @@
+/*
+ * 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.adapter.pig;
+
+import org.apache.calcite.jdbc.JavaTypeFactoryImpl;
+import org.apache.calcite.rel.type.RelDataTypeFactory;
+import org.apache.calcite.rex.RexBuilder;
+import org.apache.calcite.rex.RexLiteral;
+import org.apache.calcite.sql.type.SqlTypeName;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+/**
+ * Unit tests for {@link PigFilter}'s literal-to-Pig-Latin serialization.
+ */
+class PigFilterLiteralEscapeTest {
+
+  private static final RelDataTypeFactory TYPE_FACTORY = new 
JavaTypeFactoryImpl();
+  private static final RexBuilder REX_BUILDER = new RexBuilder(TYPE_FACTORY);
+
+  private static RexLiteral charLiteral(String value) {
+    return (RexLiteral) REX_BUILDER.makeLiteral(value,
+        TYPE_FACTORY.createSqlType(SqlTypeName.CHAR, value.length()), false);
+  }
+
+  private static String call(RexLiteral literal) throws Throwable {
+    Method m = PigFilter.class.getDeclaredMethod("getLiteralAsString", 
RexLiteral.class);
+    m.setAccessible(true);
+    try {
+      return (String) m.invoke(null, literal);
+    } catch (InvocationTargetException e) {
+      throw e.getCause();
+    }
+  }
+
+  @Test void plainValueQuoted() throws Throwable {
+    assertThat(call(charLiteral("alice")), is("'alice'"));
+  }
+
+  @Test void valueWithApostrophe() throws Throwable {
+    assertThat(call(charLiteral("O'Brien")), is("'O''Brien'"));
+  }
+
+  @Test void valueWithApostropheAtTheEnd() throws Throwable {
+    assertThat(call(charLiteral("a'")), is("'a'''"));
+  }
+
+  @Test void valueWithApostropheAtTheStart() throws Throwable {
+    assertThat(call(charLiteral("'a")), is("'''a'"));
+  }
+
+  @Test void valueWithMultipleApostrophes() throws Throwable {
+    assertThat(call(charLiteral("a''b")), is("'a''''b'"));
+  }
+
+  @Test void emptyValue() throws Throwable {
+    assertThat(call(charLiteral("")), is("''"));
+  }
+}
diff --git a/pig/src/test/java/org/apache/calcite/test/PigAdapterTest.java 
b/pig/src/test/java/org/apache/calcite/test/PigAdapterTest.java
index f18cb80ae8..62b247a756 100644
--- a/pig/src/test/java/org/apache/calcite/test/PigAdapterTest.java
+++ b/pig/src/test/java/org/apache/calcite/test/PigAdapterTest.java
@@ -57,6 +57,21 @@ class PigAdapterTest extends AbstractPigTest {
                 + "t = FILTER t BY (tc0 > 'abc');"));
   }
 
+  @Test void testFilterWithSingleQuote() {
+    // A string literal containing a single quote must be doubled per Pig Latin
+    // string-literal rules so it does not break out of the '...' literal in
+    // the generated FILTER statement.
+    CalciteAssert.that()
+        .with(MODEL)
+        .query("select * from \"t\" where \"tc0\" = 'a''b'")
+        .runs()
+        .queryContains(
+            pigScriptChecker("t = LOAD '"
+                + getFullPathForTestDataFile("data.txt")
+                + "' USING PigStorage() AS (tc0:chararray, tc1:chararray);\n"
+                + "t = FILTER t BY (tc0 == 'a''b');"));
+  }
+
   @Test void testImplWithMultipleFilters() {
     CalciteAssert.that()
         .with(MODEL)

Reply via email to