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)