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 f357079d61f1..8da8cff99186 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 fieldNames; private final Set 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 000000000000..49c177b84709 --- /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. + * + *

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 411ffdbb65be..88898e6a0455 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 663aee63b0db..164f212c6c7a 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 6683054819ea..13314a1aeda1 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 7b4475283ea0..03d4d2c50454 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 44dc09078d02..2645b13a81bc 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 @@ -9478,6 +9478,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" @@ -10997,7 +11027,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 dafc0122d139..47d43728c620 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 000000000000..35f21d4f736b --- /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. + * + *

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 = } 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 bf42258edabc..12053da96f12 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 9c720aeb8442..f36b0dd6f5d3 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. - * - *

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 000000000000..f90bbb3cbd70 --- /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 f18cb80ae87c..62b247a756b8 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)