Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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'''"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions core/src/main/java/org/apache/calcite/sql/SqlDialect.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this test was wrong?
I hope that this one was validated somehow.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems so. The test was "correct" for the "TRIM BOTH into REGEXP_REPLACE" part, but it was lacking the backslash escape.

+ "FROM `foodmart`.`reserve_employee`";
sql(query).withStarRocks().ok(expectedStarRocks)
.withDoris().ok(expectedStarRocks);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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'''"));
}
}
Loading
Loading