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 34db6c8cd8 [CALCITE-7781] Improve identifier and literal handling in 
SQL dialects and adapters
34db6c8cd8 is described below

commit 34db6c8cd895998f38175efc3e3306311fc7d061
Author: Ruben Quesada Lopez <[email protected]>
AuthorDate: Tue Sep 15 12:12:30 2026 +0100

    [CALCITE-7781] Improve identifier and literal handling in SQL dialects and 
adapters
---
 .../calcite/adapter/cassandra/CassandraTable.java  |  19 ++-
 .../cassandra/CassandraIdentifierQuotingTest.java  |  80 ++++++++++++
 .../calcite/config/CalciteSystemProperty.java      |  16 +++
 .../java/org/apache/calcite/sql/SqlDialect.java    |   8 ++
 .../apache/calcite/sql/SqlDialectFactoryImpl.java  |  33 ++++-
 .../calcite/sql/dialect/BigQuerySqlDialect.java    |   8 ++
 .../calcite/sql/dialect/ClickHouseSqlDialect.java  |  18 +++
 .../calcite/sql/dialect/FireboltSqlDialect.java    |   9 ++
 .../apache/calcite/sql/dialect/HiveSqlDialect.java |  10 ++
 .../calcite/sql/dialect/InfobrightSqlDialect.java  |  12 ++
 .../calcite/sql/dialect/NetezzaSqlDialect.java     |  13 ++
 .../calcite/sql/dialect/SnowflakeSqlDialect.java   |  10 ++
 .../calcite/sql/dialect/SparkSqlDialect.java       |  10 ++
 .../calcite/rel/rel2sql/RelToSqlConverterTest.java |  51 +++++++-
 .../org/apache/calcite/sql/SqlDialectsTest.java    |  26 ++++
 .../elasticsearch/ElasticsearchProject.java        |  34 +++--
 .../adapter/elasticsearch/ElasticsearchRules.java  |  59 ++++++++-
 .../elasticsearch/ElasticsearchRulesTest.java      | 139 +++++++++++++++++++++
 .../calcite/adapter/geode/rel/GeodeAggregate.java  |  13 +-
 .../calcite/adapter/geode/rel/GeodeProject.java    |   6 +-
 .../calcite/adapter/geode/rel/GeodeRules.java      |  64 +++++++++-
 .../calcite/adapter/geode/rel/GeodeSort.java       |   7 +-
 .../calcite/adapter/geode/rel/GeodeTable.java      |  91 +++++++++++++-
 .../calcite/adapter/geode/util/GeodeUtils.java     |  32 +++++
 .../calcite/adapter/geode/rel/GeodeRulesTest.java  | 137 ++++++++++++++++++++
 .../geode/rel/GeodeTableOqlValidationTest.java     | 116 +++++++++++++++++
 .../calcite/adapter/geode/util/GeodeUtilsTest.java | 128 +++++++++++++++++++
 .../calcite/adapter/mongodb/MongoAggregate.java    |   6 +-
 .../calcite/adapter/mongodb/MongoRulesTest.java    |  78 ++++++++++++
 site/_docs/history.md                              |   9 ++
 .../calcite/adapter/splunk/SplunkPushDownRule.java |  55 +++++++-
 .../adapter/splunk/SplunkPushDownRuleTest.java     | 107 ++++++++++++++++
 32 files changed, 1364 insertions(+), 40 deletions(-)

diff --git 
a/cassandra/src/main/java/org/apache/calcite/adapter/cassandra/CassandraTable.java
 
b/cassandra/src/main/java/org/apache/calcite/adapter/cassandra/CassandraTable.java
index c28ada76e9..de8464e25d 100644
--- 
a/cassandra/src/main/java/org/apache/calcite/adapter/cassandra/CassandraTable.java
+++ 
b/cassandra/src/main/java/org/apache/calcite/adapter/cassandra/CassandraTable.java
@@ -38,6 +38,7 @@
 import org.apache.calcite.sql.type.SqlTypeFactoryImpl;
 import org.apache.calcite.util.Util;
 
+import com.datastax.oss.driver.api.core.CqlIdentifier;
 import com.datastax.oss.driver.api.core.CqlSession;
 import com.datastax.oss.driver.api.core.cql.ResultSet;
 import com.google.common.collect.ImmutableList;
@@ -173,7 +174,13 @@ public Enumerable<Object> query(final CqlSession session, 
List<Map.Entry<String,
 
           @Override public String next() {
             Map.Entry<String, String> entry = selectIterator.next();
-            return entry.getKey() + " AS " + entry.getValue();
+            // Route both the column reference and the alias through
+            // CqlIdentifier so that a name containing punctuation or the
+            // {@code "} delimiter cannot shift the boundaries of the
+            // emitted CQL statement.
+            return CqlIdentifier.fromInternal(entry.getKey()).asCql(true)
+                + " AS "
+                + CqlIdentifier.fromInternal(entry.getValue()).asCql(true);
           }
 
           @Override public void remove() {
@@ -194,14 +201,16 @@ public Enumerable<Object> query(final CqlSession session, 
List<Map.Entry<String,
     StringBuilder queryBuilder = new StringBuilder("SELECT ");
     queryBuilder
         .append(selectString)
-        .append(" FROM \"");
+        .append(" FROM ");
 
+    // Route the keyspace and column-family names through CqlIdentifier so
+    // that a name containing punctuation or the {@code "} delimiter cannot
+    // shift the boundaries of the emitted CQL statement
     keyspace.ifPresent(s ->
-        queryBuilder.append(s).append("\".\""));
+        
queryBuilder.append(CqlIdentifier.fromInternal(s).asCql(true)).append("."));
 
     queryBuilder
-        .append(columnFamily)
-        .append("\"")
+        .append(CqlIdentifier.fromInternal(columnFamily).asCql(true))
         .append(whereClause);
 
     if (!order.isEmpty()) {
diff --git 
a/cassandra/src/test/java/org/apache/calcite/adapter/cassandra/CassandraIdentifierQuotingTest.java
 
b/cassandra/src/test/java/org/apache/calcite/adapter/cassandra/CassandraIdentifierQuotingTest.java
new file mode 100644
index 0000000000..a053d58975
--- /dev/null
+++ 
b/cassandra/src/test/java/org/apache/calcite/adapter/cassandra/CassandraIdentifierQuotingTest.java
@@ -0,0 +1,80 @@
+/*
+ * 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 com.datastax.oss.driver.api.core.CqlIdentifier;
+
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+/**
+ * Unit tests documenting how {@link CqlIdentifier#fromInternal} is used in
+ * {@link CassandraTable#query} to emit the keyspace, column-family, column
+ * and alias tokens of a generated CQL {@code SELECT} statement. Any of
+ * those pieces is copied verbatim into the query text, so a value that
+ * contains punctuation or the {@code "} delimiter must be routed through
+ * the driver so that it cannot alter the semantics of the emitted CQL.
+ */
+class CassandraIdentifierQuotingTest {
+
+  /** Plain names round-trip unchanged. */
+  @Test void plainNameUnquoted() {
+    assertThat(CqlIdentifier.fromInternal("field").asCql(true), is("field"));
+  }
+
+  /** An uppercase name gets a double-quoted rendering so its case is
+   * preserved on the server side. */
+  @Test void mixedCaseGetsQuoted() {
+    assertThat(CqlIdentifier.fromInternal("Field").asCql(true),
+        is("\"Field\""));
+  }
+
+  /** A name containing a space cannot be emitted verbatim; the driver
+   * wraps it in double quotes. */
+  @Test void nameWithSpaceGetsQuoted() {
+    assertThat(CqlIdentifier.fromInternal("a b").asCql(true),
+        is("\"a b\""));
+  }
+
+  /** A double quote inside the value would otherwise terminate the
+   * enclosing quoted identifier and shift the token boundary; the
+   * driver escapes it by doubling. */
+  @Test void embeddedDoubleQuoteIsDoubled() {
+    assertThat(CqlIdentifier.fromInternal("a\"b").asCql(true),
+        is("\"a\"\"b\""));
+  }
+
+  /** A trailing double quote must also be doubled or it would consume
+   * the closing quote of the enclosing identifier. */
+  @Test void trailingDoubleQuoteIsDoubled() {
+    assertThat(CqlIdentifier.fromInternal("a\"").asCql(true),
+        is("\"a\"\"\""));
+  }
+
+  /** Punctuation that would carry CQL structure (a comma, a
+   * semicolon, a parenthesis) is neutralised by the same quoting. */
+  @Test void punctuationIsQuoted() {
+    assertThat(CqlIdentifier.fromInternal("a,b").asCql(true),
+        is("\"a,b\""));
+    assertThat(CqlIdentifier.fromInternal("a;b").asCql(true),
+        is("\"a;b\""));
+    assertThat(CqlIdentifier.fromInternal("a(b)").asCql(true),
+        is("\"a(b)\""));
+  }
+}
diff --git 
a/core/src/main/java/org/apache/calcite/config/CalciteSystemProperty.java 
b/core/src/main/java/org/apache/calcite/config/CalciteSystemProperty.java
index e5680a2783..39ba86b011 100644
--- a/core/src/main/java/org/apache/calcite/config/CalciteSystemProperty.java
+++ b/core/src/main/java/org/apache/calcite/config/CalciteSystemProperty.java
@@ -121,6 +121,22 @@ public final class CalciteSystemProperty<T> {
   public static final CalciteSystemProperty<Boolean> UUID_UNSIGNED_COMPARISON =
       booleanProperty("calcite.uuid.unsigned.comparison", true);
 
+  /**
+   * Whether the fallback dialect that
+   * {@link org.apache.calcite.sql.SqlDialectFactory} uses for an
+   * unrecognized database product doubles backslashes when quoting string
+   * literals.
+   *
+   * <p>The unrecognized backend's string-lexing rules are unknown, so the
+   * default is to double backslashes: this keeps a value from terminating a
+   * generated string literal early on a backend that treats backslash as an
+   * in-string escape character (the MySQL family, among others). Set to
+   * "false" to restore the legacy behavior for backends known to follow the
+   * SQL standard.
+   */
+  public static final CalciteSystemProperty<Boolean> 
UNKNOWN_DIALECT_ESCAPES_BACKSLASH =
+      booleanProperty("calcite.sql.dialect.unknown.escapes.backslash", true);
+
   /**
    * Whether to include a GraphViz representation when dumping the state of the
    * Volcano planner.
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 06fd392a97..cfa2bc84ef 100644
--- a/core/src/main/java/org/apache/calcite/sql/SqlDialect.java
+++ b/core/src/main/java/org/apache/calcite/sql/SqlDialect.java
@@ -429,6 +429,14 @@ public final String quoteStringLiteral(String val) {
   }
 
   /** Appends a string literal to a buffer.
+   *
+   * <p>The default implementation escapes only the literal quote character
+   * (by doubling it). If the target backend also treats backslash as an
+   * in-string escape character (as MySQL, MariaDB and BigQuery do), the
+   * dialect must override this method and double backslashes as well via
+   * {@link #escapeBackslash(String)}; otherwise a value ending in a
+   * backslash escapes the closing quote, shifts the boundary of the emitted
+   * literal, and changes the structure of the generated statement.
    *
    * @param buf Buffer
    * @param charsetName Character set name, e.g. "utf16", or null
diff --git 
a/core/src/main/java/org/apache/calcite/sql/SqlDialectFactoryImpl.java 
b/core/src/main/java/org/apache/calcite/sql/SqlDialectFactoryImpl.java
index 3b8de0d65c..486442f72a 100644
--- a/core/src/main/java/org/apache/calcite/sql/SqlDialectFactoryImpl.java
+++ b/core/src/main/java/org/apache/calcite/sql/SqlDialectFactoryImpl.java
@@ -16,6 +16,7 @@
  */
 package org.apache.calcite.sql;
 
+import org.apache.calcite.config.CalciteSystemProperty;
 import org.apache.calcite.sql.dialect.AccessSqlDialect;
 import org.apache.calcite.sql.dialect.AnsiSqlDialect;
 import org.apache.calcite.sql.dialect.BigQuerySqlDialect;
@@ -168,10 +169,40 @@ public class SqlDialectFactoryImpl implements 
SqlDialectFactory {
     } else if (upperProductName.contains("SPARK")) {
       return new SparkSqlDialect(c);
     } else {
-      return new AnsiSqlDialect(c);
+      return ansiFallback(c);
     }
   }
 
+  /** Creates the dialect used when the database product name is not
+   * recognized.
+   *
+   * <p>Because the backend's string-literal lexing rules are unknown, the
+   * fallback doubles backslashes when quoting string literals by default:
+   * on a backend that treats {@code \} as an in-string escape character
+   * (the MySQL family, among others), an unescaped backslash at the end of
+   * a value such as {@code "x\"} would otherwise consume the closing quote
+   * and shift the string boundary, so the rest of the value would be parsed
+   * as SQL rather than data. On a backend that lexes string literals per
+   * the SQL standard, the doubled backslash is at worst rendered verbatim;
+   * it cannot change the structure of the generated statement.
+   *
+   * <p>Set the system property
+   * {@code calcite.sql.dialect.unknown.escapes.backslash} to {@code false}
+   * to restore the legacy behavior (no backslash escaping) for a backend
+   * known to follow the SQL standard; the robust fix is to add a proper
+   * {@link SqlDialect} for the product. */
+  static SqlDialect ansiFallback(SqlDialect.Context context) {
+    return new AnsiSqlDialect(context) {
+      @Override public void quoteStringLiteral(StringBuilder buf,
+          @Nullable String charsetName, String val) {
+        super.quoteStringLiteral(buf, charsetName,
+            CalciteSystemProperty.UNKNOWN_DIALECT_ESCAPES_BACKSLASH.value()
+                ? escapeBackslash(val)
+                : val);
+      }
+    };
+  }
+
   /** Returns a basic dialect for a given product, or null if none is known. */
   static @Nullable SqlDialect simple(SqlDialect.DatabaseProduct 
databaseProduct) {
     switch (databaseProduct) {
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 13314a1aed..66cc02688c 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
@@ -127,6 +127,14 @@ public BigQuerySqlDialect(SqlDialect.Context context) {
     super.quoteStringLiteral(buf, charsetName, escapeBackslash(val));
   }
 
+  @Override public StringBuilder quoteIdentifier(StringBuilder buf, String 
val) {
+    // BigQuery applies the same backslash escape sequences inside back-tick
+    // quoted identifiers as inside string literals, so a backslash in an
+    // identifier must be doubled or it would escape the closing back-tick and
+    // shift the identifier boundary.
+    return super.quoteIdentifier(buf, escapeBackslash(val));
+  }
+
   @Override public boolean supportsImplicitTypeCoercion(RexCall call) {
     return super.supportsImplicitTypeCoercion(call)
             && RexUtil.isLiteral(call.getOperands().get(0), false)
diff --git 
a/core/src/main/java/org/apache/calcite/sql/dialect/ClickHouseSqlDialect.java 
b/core/src/main/java/org/apache/calcite/sql/dialect/ClickHouseSqlDialect.java
index fd5a719e1a..d2c1df2c46 100644
--- 
a/core/src/main/java/org/apache/calcite/sql/dialect/ClickHouseSqlDialect.java
+++ 
b/core/src/main/java/org/apache/calcite/sql/dialect/ClickHouseSqlDialect.java
@@ -99,6 +99,24 @@ public ClickHouseSqlDialect(Context context) {
     return false;
   }
 
+  @Override public void quoteStringLiteral(StringBuilder buf,
+      @Nullable String charsetName, String val) {
+    // ClickHouse treats backslash as an escape character inside string
+    // literals, so a literal backslash must be doubled before the base method
+    // escapes the enclosing quote. Otherwise a value ending in a backslash
+    // terminates the literal early and the trailing text is parsed as SQL
+    // rather than data.
+    super.quoteStringLiteral(buf, charsetName, escapeBackslash(val));
+  }
+
+  @Override public StringBuilder quoteIdentifier(StringBuilder buf, String 
val) {
+    // ClickHouse applies the same backslash escape sequences inside
+    // back-tick-quoted identifiers as inside string literals, so a backslash
+    // in an identifier must be doubled or it would escape the closing
+    // back-tick and shift the identifier boundary.
+    return super.quoteIdentifier(buf, escapeBackslash(val));
+  }
+
   @Override public boolean supportsNestedAggregations() {
     return false;
   }
diff --git 
a/core/src/main/java/org/apache/calcite/sql/dialect/FireboltSqlDialect.java 
b/core/src/main/java/org/apache/calcite/sql/dialect/FireboltSqlDialect.java
index 0b2d9e0e34..8c6afcd613 100644
--- a/core/src/main/java/org/apache/calcite/sql/dialect/FireboltSqlDialect.java
+++ b/core/src/main/java/org/apache/calcite/sql/dialect/FireboltSqlDialect.java
@@ -100,6 +100,15 @@ public FireboltSqlDialect(Context context) {
     return false;
   }
 
+  @Override public void quoteStringLiteral(StringBuilder buf,
+      @Nullable String charsetName, String val) {
+    // Firebolt treats backslash as an escape character inside string literals,
+    // so a literal backslash must be doubled before the base method escapes 
the
+    // enclosing quote. Otherwise a value ending in a backslash terminates the
+    // literal early and the trailing text is parsed as SQL rather than data.
+    super.quoteStringLiteral(buf, charsetName, escapeBackslash(val));
+  }
+
   @Override public void unparseOffsetFetch(SqlWriter writer, @Nullable SqlNode 
offset,
       @Nullable SqlNode fetch) {
     unparseFetchUsingLimit(writer, offset, fetch);
diff --git 
a/core/src/main/java/org/apache/calcite/sql/dialect/HiveSqlDialect.java 
b/core/src/main/java/org/apache/calcite/sql/dialect/HiveSqlDialect.java
index 689ecc1ecf..2e2bcfcdf4 100644
--- a/core/src/main/java/org/apache/calcite/sql/dialect/HiveSqlDialect.java
+++ b/core/src/main/java/org/apache/calcite/sql/dialect/HiveSqlDialect.java
@@ -79,6 +79,16 @@ public HiveSqlDialect(Context context) {
     return false;
   }
 
+  @Override public void quoteStringLiteral(StringBuilder buf,
+      @Nullable String charsetName, String val) {
+    // Hive treats backslash as an escape character inside string literals
+    // (C-style escapes), so a literal backslash must be doubled before the
+    // base method escapes the enclosing quote. Otherwise a value ending in
+    // a backslash terminates the literal early and the trailing text is
+    // parsed as SQL rather than data.
+    super.quoteStringLiteral(buf, charsetName, escapeBackslash(val));
+  }
+
   @Override public void unparseOffsetFetch(SqlWriter writer, @Nullable SqlNode 
offset,
       @Nullable SqlNode fetch) {
     unparseFetchUsingLimit(writer, offset, fetch);
diff --git 
a/core/src/main/java/org/apache/calcite/sql/dialect/InfobrightSqlDialect.java 
b/core/src/main/java/org/apache/calcite/sql/dialect/InfobrightSqlDialect.java
index 9a0031d830..697bcd44a1 100644
--- 
a/core/src/main/java/org/apache/calcite/sql/dialect/InfobrightSqlDialect.java
+++ 
b/core/src/main/java/org/apache/calcite/sql/dialect/InfobrightSqlDialect.java
@@ -18,6 +18,8 @@
 
 import org.apache.calcite.sql.SqlDialect;
 
+import org.checkerframework.checker.nullness.qual.Nullable;
+
 /**
  * A <code>SqlDialect</code> implementation for the Infobright database.
  */
@@ -33,6 +35,16 @@ public InfobrightSqlDialect(Context context) {
     super(context);
   }
 
+  @Override public void quoteStringLiteral(StringBuilder buf,
+      @Nullable String charsetName, String val) {
+    // Infobright is MySQL-compatible and treats backslash as an escape
+    // character inside string literals, so a literal backslash must be doubled
+    // before the base method escapes the enclosing quote. Otherwise a value
+    // ending in a backslash terminates the literal early and the trailing text
+    // is parsed as SQL rather than data.
+    super.quoteStringLiteral(buf, charsetName, escapeBackslash(val));
+  }
+
   @Override public boolean supportsWindowFunctions() {
     return false;
   }
diff --git 
a/core/src/main/java/org/apache/calcite/sql/dialect/NetezzaSqlDialect.java 
b/core/src/main/java/org/apache/calcite/sql/dialect/NetezzaSqlDialect.java
index 21378cc344..7d7fba61b2 100644
--- a/core/src/main/java/org/apache/calcite/sql/dialect/NetezzaSqlDialect.java
+++ b/core/src/main/java/org/apache/calcite/sql/dialect/NetezzaSqlDialect.java
@@ -18,6 +18,8 @@
 
 import org.apache.calcite.sql.SqlDialect;
 
+import org.checkerframework.checker.nullness.qual.Nullable;
+
 /**
  * A <code>SqlDialect</code> implementation for the Netezza database.
  */
@@ -32,4 +34,15 @@ public class NetezzaSqlDialect extends SqlDialect {
   public NetezzaSqlDialect(Context context) {
     super(context);
   }
+
+  @Override public void quoteStringLiteral(StringBuilder buf,
+      @Nullable String charsetName, String val) {
+    // Netezza accepts C-style backslash escape sequences inside single-quoted
+    // string literals unless the session's standard-conforming-strings option
+    // is enabled, so a literal backslash must be doubled before the base
+    // method escapes the enclosing quote. Otherwise a value ending in a
+    // backslash terminates the literal early and the trailing text is parsed
+    // as SQL rather than data.
+    super.quoteStringLiteral(buf, charsetName, escapeBackslash(val));
+  }
 }
diff --git 
a/core/src/main/java/org/apache/calcite/sql/dialect/SnowflakeSqlDialect.java 
b/core/src/main/java/org/apache/calcite/sql/dialect/SnowflakeSqlDialect.java
index 9deb0836ee..7ecff7915b 100644
--- a/core/src/main/java/org/apache/calcite/sql/dialect/SnowflakeSqlDialect.java
+++ b/core/src/main/java/org/apache/calcite/sql/dialect/SnowflakeSqlDialect.java
@@ -48,6 +48,16 @@ public SnowflakeSqlDialect(Context context) {
     super(context);
   }
 
+  @Override public void quoteStringLiteral(StringBuilder buf,
+      @Nullable String charsetName, String val) {
+    // Snowflake recognizes backslash escape sequences inside single-quoted
+    // string constants, so a literal backslash must be doubled before the base
+    // method escapes the enclosing quote. Otherwise a value ending in a
+    // backslash terminates the literal early and the trailing text is parsed
+    // as SQL rather than data.
+    super.quoteStringLiteral(buf, charsetName, escapeBackslash(val));
+  }
+
   @Override public void unparseCall(final SqlWriter writer, final SqlCall 
call, final int leftPrec,
       final int rightPrec) {
     switch (call.getKind()) {
diff --git 
a/core/src/main/java/org/apache/calcite/sql/dialect/SparkSqlDialect.java 
b/core/src/main/java/org/apache/calcite/sql/dialect/SparkSqlDialect.java
index 6f6e3d3917..87432e52d3 100644
--- a/core/src/main/java/org/apache/calcite/sql/dialect/SparkSqlDialect.java
+++ b/core/src/main/java/org/apache/calcite/sql/dialect/SparkSqlDialect.java
@@ -72,6 +72,16 @@ public SparkSqlDialect(SqlDialect.Context context) {
     return false;
   }
 
+  @Override public void quoteStringLiteral(StringBuilder buf,
+      @Nullable String charsetName, String val) {
+    // Spark SQL treats backslash as an escape character inside string
+    // literals, so a literal backslash must be doubled before the base
+    // method escapes the enclosing quote. Otherwise a value ending in a
+    // backslash terminates the literal early and the trailing text is
+    // parsed as SQL rather than data.
+    super.quoteStringLiteral(buf, charsetName, escapeBackslash(val));
+  }
+
   @Override public JoinType emulateJoinTypeForCrossJoin() {
     return JoinType.CROSS;
   }
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 7b23fd5d0a..cf04d3a63a 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
@@ -3921,8 +3921,13 @@ private SqlDialect nonOrdinalDialect() {
   @Test void testHiveAndSparkTrimWithBothSpecialCharacter() {
     final String query = "SELECT TRIM(BOTH '$@*A' from 
'$@*AABC$@*AADCAA$@*A')\n"
         + "from \"foodmart\".\"reserve_employee\"";
+    // Hive and Spark recognize backslash escape sequences inside single-quoted
+    // string constants, so each backslash produced by escapeSpecialChar is
+    // doubled by the dialect. The emitted regex therefore contains \\$\\@\\*A
+    // rather than \$\@\*A; when Hive/Spark parse the literal they collapse
+    // it back to \$\@\*A, which is what the REGEXP_REPLACE engine sees.
     final String expected = "SELECT REGEXP_REPLACE('$@*AABC$@*AADCAA$@*A',"
-        + " '^(\\$\\@\\*A)*|(\\$\\@\\*A)*$', '')\n"
+        + " '^(\\\\$\\\\@\\\\*A)*|(\\\\$\\\\@\\\\*A)*$', '')\n"
         + "FROM `foodmart`.`reserve_employee`";
     sql(query)
         .withHive().ok(expected)
@@ -9310,6 +9315,20 @@ private void checkLiteral2(String expression, String 
expected) {
     sql(bitQuery).withClickHouse().ok(clickHouseExpected);
   }
 
+  /** ClickHouse applies the same backslash escape sequences inside
+   * back-tick-quoted identifiers as inside string literals, so
+   * {@link SqlDialect#quoteIdentifier(StringBuilder, String)} must double it. 
*/
+  @Test void testClickHouseQuoteIdentifierWithBackslash() {
+    final SqlDialect dialect = DatabaseProduct.CLICKHOUSE.getDialect();
+    assertThat(dialect.quoteIdentifier("x\\"), is("`x\\\\`"));
+    assertThat(dialect.quoteIdentifier("\\x"), is("`\\\\x`"));
+    assertThat(dialect.quoteIdentifier("x\\y"), is("`x\\\\y`"));
+    assertThat(dialect.quoteIdentifier("x\\\\"), is("`x\\\\\\\\`"));
+    // A back-tick is still escaped as `` after backslashes are doubled
+    assertThat(dialect.quoteIdentifier("x`y"), is("`x``y`"));
+    assertThat(dialect.quoteIdentifier("x\\`y"), is("`x\\\\``y`"));
+  }
+
   @Test void testClickHouseArrayFunctions() {
 
   }
@@ -9905,17 +9924,25 @@ 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
+  /** Dialects whose backend treats {@code \} as an in-string escape
    * character must double it in
-   * {@link SqlDialect#quoteStringLiteral(StringBuilder, String, String)}. */
+   * {@link SqlDialect#quoteStringLiteral(StringBuilder, String, String)},
+   * so that a value ending in a backslash cannot terminate the emitted
+   * literal early. */
   @Test void testDialectQuoteStringLiteralWithBackslash() {
     dialects().forEach((dialect, databaseProduct) -> {
       final boolean escapesBackslash =
           databaseProduct == DatabaseProduct.BIG_QUERY
               || databaseProduct == DatabaseProduct.MYSQL
               || databaseProduct == DatabaseProduct.STARROCKS
-              || databaseProduct == DatabaseProduct.DORIS;
+              || databaseProduct == DatabaseProduct.DORIS
+              || databaseProduct == DatabaseProduct.HIVE
+              || databaseProduct == DatabaseProduct.SPARK
+              || databaseProduct == DatabaseProduct.SNOWFLAKE
+              || databaseProduct == DatabaseProduct.FIREBOLT
+              || databaseProduct == DatabaseProduct.CLICKHOUSE
+              || databaseProduct == DatabaseProduct.NETEZZA
+              || databaseProduct == DatabaseProduct.INFOBRIGHT;
 
       // Trailing backslash
       assertThat(dialect.quoteStringLiteral("x\\"),
@@ -9935,6 +9962,20 @@ private void checkLiteral2(String expression, String 
expected) {
     });
   }
 
+  /** BigQuery applies the same backslash escape sequences inside
+   * back-tick-quoted identifiers as inside string literals, so
+   * {@link SqlDialect#quoteIdentifier(StringBuilder, String)} must double it. 
*/
+  @Test void testBigQueryQuoteIdentifierWithBackslash() {
+    final SqlDialect dialect = DatabaseProduct.BIG_QUERY.getDialect();
+    assertThat(dialect.quoteIdentifier("x\\"), is("`x\\\\`"));
+    assertThat(dialect.quoteIdentifier("\\x"), is("`\\\\x`"));
+    assertThat(dialect.quoteIdentifier("x\\y"), is("`x\\\\y`"));
+    assertThat(dialect.quoteIdentifier("x\\\\"), is("`x\\\\\\\\`"));
+    // A back-tick is still escaped as \` after backslashes are doubled
+    assertThat(dialect.quoteIdentifier("x`y"), is("`x\\`y`"));
+    assertThat(dialect.quoteIdentifier("x\\`y"), is("`x\\\\\\`y`"));
+  }
+
   @Test void testSelectCountStar() {
     final String query = "select count(*) from \"product\"";
     final String expected = "SELECT COUNT(*)\n"
diff --git a/core/src/test/java/org/apache/calcite/sql/SqlDialectsTest.java 
b/core/src/test/java/org/apache/calcite/sql/SqlDialectsTest.java
index d77f02d45c..10e6c14f52 100644
--- a/core/src/test/java/org/apache/calcite/sql/SqlDialectsTest.java
+++ b/core/src/test/java/org/apache/calcite/sql/SqlDialectsTest.java
@@ -17,6 +17,7 @@
 package org.apache.calcite.sql;
 
 import org.apache.calcite.jdbc.Driver;
+import org.apache.calcite.sql.dialect.AnsiSqlDialect;
 
 import org.junit.jupiter.api.Test;
 
@@ -43,4 +44,29 @@ public class SqlDialectsTest {
     assertThat(context.databaseMajorVersion(),
         is(metaData.getDatabaseMajorVersion()));
   }
+
+  /** The fallback dialect for an unrecognized database product doubles
+   * backslashes when quoting string literals: the backend's lexer is
+   * unknown, so a trailing backslash must not be able to consume the
+   * closing quote and shift the string boundary. */
+  @Test void testUnknownProductFallbackEscapesBackslash() {
+    SqlDialect dialect =
+        SqlDialectFactoryImpl.ansiFallback(AnsiSqlDialect.DEFAULT_CONTEXT);
+    // Quote doubling.
+    assertThat(dialect.quoteStringLiteral("can't run"), is("'can''t run'"));
+    // Backslashes are doubled defensively.
+    assertThat(dialect.quoteStringLiteral("x\\"), is("'x\\\\'"));
+    assertThat(dialect.quoteStringLiteral("x\\' extra"),
+        is("'x\\\\'' extra'"));
+  }
+
+  /** An unrecognized JDBC product name (Calcite's own driver has no entry in
+   * {@link SqlDialectFactoryImpl}) resolves to the fallback dialect. */
+  @Test void testFactoryFallbackEscapesBackslash() throws SQLException {
+    Connection connection =
+        DriverManager.getConnection(Driver.CONNECT_STRING_PREFIX);
+    SqlDialect dialect =
+        new SqlDialectFactoryImpl().create(connection.getMetaData());
+    assertThat(dialect.quoteStringLiteral("x\\"), is("'x\\\\'"));
+  }
 }
diff --git 
a/elasticsearch/src/main/java/org/apache/calcite/adapter/elasticsearch/ElasticsearchProject.java
 
b/elasticsearch/src/main/java/org/apache/calcite/adapter/elasticsearch/ElasticsearchProject.java
index 640b225434..7dd350f07b 100644
--- 
a/elasticsearch/src/main/java/org/apache/calcite/adapter/elasticsearch/ElasticsearchProject.java
+++ 
b/elasticsearch/src/main/java/org/apache/calcite/adapter/elasticsearch/ElasticsearchProject.java
@@ -88,15 +88,14 @@ public class ElasticsearchProject extends Project 
implements ElasticsearchRel {
       } else if (expr.equals(name)) {
         fields.add(name);
       } else if (expr.matches("\"literal\":.+")) {
-        scriptFields.add(ElasticsearchRules.quote(name)
+        scriptFields.add(quoteName(name)
                 + ":{\"script\": "
                 + expr.split(":")[1] + "}");
       } else {
-        scriptFields.add(ElasticsearchRules.quote(name)
+        scriptFields.add(quoteName(name)
                 + ":{\"script\":"
                 // _source (ES2) vs params._source (ES5)
-                + "\"" + implementor.elasticsearchTable.scriptedFieldPrefix() 
+ "."
-                + expr.replace("\"", "") + "\"}");
+                + quoteScript(implementor, expr.replace("\"", "")) + "}");
       }
     }
 
@@ -111,7 +110,7 @@ public class ElasticsearchProject extends Project 
implements ElasticsearchRel {
       List<String> newList = fields.stream()
           // _id field is available implicitly
           .filter(f -> !ElasticsearchConstants.ID.equals(f))
-          .map(ElasticsearchRules::quote)
+          .map(ElasticsearchProject::quoteName)
           .collect(Collectors.toList());
 
       final String findString = String.join(", ", newList);
@@ -119,10 +118,9 @@ public class ElasticsearchProject extends Project 
implements ElasticsearchRel {
     } else {
       // if scripted fields are present, ES ignores _source attribute
       for (String field : fields) {
-        scriptFields.add(ElasticsearchRules.quote(field) + ":{\"script\": "
+        scriptFields.add(quoteName(field) + ":{\"script\": "
                 // _source (ES2) vs params._source (ES5)
-                + "\"" + implementor.elasticsearchTable.scriptedFieldPrefix() 
+ "."
-                + field + "\"}");
+                + quoteScript(implementor, field) + "}");
       }
       query.append("\"script_fields\": {")
           .append(String.join(", ", scriptFields))
@@ -132,4 +130,24 @@ public class ElasticsearchProject extends Project 
implements ElasticsearchRel {
     implementor.list.removeIf(l -> l.startsWith("\"_source\""));
     implementor.add("{" + query + "}");
   }
+
+  /** Renders a name as a JSON string. The value is routed through
+   * {@link ElasticsearchRules#escapeJsonString} so that a name
+   * containing {@code "} or {@code \} stays confined to the surrounding
+   * JSON string. */
+  private static String quoteName(String name) {
+    return ElasticsearchRules.quote(ElasticsearchRules.escapeJsonString(name));
+  }
+
+  /** Renders the scripted-field script that reads {@code path} from the
+   * document source as a JSON string. The path is routed through
+   * {@link ElasticsearchRules#scriptedFieldAccess} so that a field name
+   * containing script punctuation is emitted as a subscript string rather
+   * than concatenated into the script source. */
+  private static String quoteScript(Implementor implementor, String path) {
+    return ElasticsearchRules.quote(
+        ElasticsearchRules.escapeJsonString(
+            ElasticsearchRules.scriptedFieldAccess(
+                implementor.elasticsearchTable.scriptedFieldPrefix(), path)));
+  }
 }
diff --git 
a/elasticsearch/src/main/java/org/apache/calcite/adapter/elasticsearch/ElasticsearchRules.java
 
b/elasticsearch/src/main/java/org/apache/calcite/adapter/elasticsearch/ElasticsearchRules.java
index 3c44992b0a..b4c78dc471 100644
--- 
a/elasticsearch/src/main/java/org/apache/calcite/adapter/elasticsearch/ElasticsearchRules.java
+++ 
b/elasticsearch/src/main/java/org/apache/calcite/adapter/elasticsearch/ElasticsearchRules.java
@@ -48,6 +48,8 @@
 
 import java.util.AbstractList;
 import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 /**
  * Rules and relational operators for
@@ -136,10 +138,59 @@ static String stripQuotes(String s) {
         ? s.substring(1, s.length() - 1) : s;
   }
 
-  private static String escapeSpecialSymbols(String s) {
+  /** Escapes the two characters that would terminate a JSON string. A SQL
+   * name or alias concatenated between double quotes stays a single JSON
+   * string only if backslash and double quote are doubled. Callers that
+   * embed the value inside a JSON body must route it through this helper
+   * before calling {@link #quote}. */
+  static String escapeJsonString(String s) {
     return s.replace("\\", "\\\\").replace("\"", "\\\"");
   }
 
+  /** Shape of field paths that may be embedded in a scripted-field
+   * script using the historical dot notation, for example {@code city},
+   * {@code b.a} or {@code loc[0]}. */
+  private static final Pattern SIMPLE_SCRIPT_PATH =
+      // Use possessive quantifier *+ instead of greedy ones to avoid 
backtracking on inputs
+      // that almost, but do not quite, match (avoiding potential catastrophic 
backtracking)
+      Pattern.compile("[A-Za-z_][A-Za-z0-9_]*+"
+          + "(?:\\.[A-Za-z_][A-Za-z0-9_]*+)*+(?:\\[[0-9]++\\])*+");
+
+  /** Pattern matching trailing subscripts of a field path, for example {@code 
[0]}
+   * in {@code loc[0]}. */
+  private static final Pattern TRAILING_SUBSCRIPTS =
+      // Possessive quantifiers as above
+      Pattern.compile("(?:\\[[0-9]++\\])++$");
+
+  /** Builds the script snippet (Painless/Groovy) that reads {@code path}
+   * from the document source, for example {@code params._source.city}.
+   *
+   * <p>Simple paths keep the historical dot notation. Any other path is
+   * accessed via map subscripts (for example
+   * {@code params._source['a-b']}), with the field name confined to a
+   * single-quoted script string literal (backslashes and quotes escaped),
+   * so that a field name containing script punctuation is always treated
+   * as data instead of being parsed as Painless/Groovy code. */
+  static String scriptedFieldAccess(String prefix, String path) {
+    if (SIMPLE_SCRIPT_PATH.matcher(path).matches()) {
+      return prefix + "." + path;
+    }
+    String name = path;
+    String subscripts = "";
+    final Matcher matcher = TRAILING_SUBSCRIPTS.matcher(path);
+    if (matcher.find()) {
+      name = path.substring(0, matcher.start());
+      subscripts = path.substring(matcher.start());
+    }
+    final StringBuilder builder = new StringBuilder(prefix);
+    for (String step : name.split("\\.", -1)) {
+      builder.append("['")
+          .append(step.replace("\\", "\\\\").replace("'", "\\'"))
+          .append("']");
+    }
+    return builder.append(subscripts).toString();
+  }
+
   /**
    * Translator from {@link RexNode} to strings in Elasticsearch's expression
    * language.
@@ -160,9 +211,9 @@ static class RexToElasticsearchTranslator extends 
RexVisitorImpl<String> {
       }
       return "\"literal\":"
           + quote(
-          escapeSpecialSymbols(
-              RexToLixTranslator.translateLiteral(literal, literal.getType(),
-                  typeFactory, RexImpTable.NullAs.NOT_POSSIBLE).toString()));
+              escapeJsonString(
+                  RexToLixTranslator.translateLiteral(literal, 
literal.getType(),
+                      typeFactory, 
RexImpTable.NullAs.NOT_POSSIBLE).toString()));
     }
 
     @Override public String visitInputRef(RexInputRef inputRef) {
diff --git 
a/elasticsearch/src/test/java/org/apache/calcite/adapter/elasticsearch/ElasticsearchRulesTest.java
 
b/elasticsearch/src/test/java/org/apache/calcite/adapter/elasticsearch/ElasticsearchRulesTest.java
new file mode 100644
index 0000000000..d543cb2a89
--- /dev/null
+++ 
b/elasticsearch/src/test/java/org/apache/calcite/adapter/elasticsearch/ElasticsearchRulesTest.java
@@ -0,0 +1,139 @@
+/*
+ * 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.elasticsearch;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+/**
+ * Unit tests for the escaping helpers in {@link ElasticsearchRules} that
+ * {@link ElasticsearchProject} uses when field names are concatenated into
+ * the JSON search request.
+ *
+ * <p>Field names and column aliases are arbitrary SQL-controlled strings;
+ * without escaping, a value containing {@code "} or {@code \} would close
+ * the surrounding JSON string and let sibling top-level keys leak into
+ * the request body, or be executed as script code inside a scripted
+ * field. The tests exercise the helpers directly and do not require a
+ * running Elasticsearch cluster.
+ */
+class ElasticsearchRulesTest {
+
+  private final ObjectMapper mapper = new ObjectMapper();
+
+  @Test void quoteWrapsInDoubleQuotes() {
+    // quote() is deliberately raw: callers that embed the value inside a
+    // JSON body route it through escapeSpecialSymbols first.
+    assertThat(ElasticsearchRules.quote("field"), is("\"field\""));
+    assertThat(ElasticsearchRules.quote(""), is("\"\""));
+  }
+
+  @Test void plainNamesUnchangedByEscaping() {
+    assertThat(ElasticsearchRules.escapeJsonString("city"), is("city"));
+    assertThat(ElasticsearchRules.escapeJsonString("b.a"), is("b.a"));
+  }
+
+  @Test void quotesAndBackslashesEscaped() {
+    // Double quote and backslash are the two characters that would let
+    // the value terminate the surrounding JSON string.
+    assertThat(ElasticsearchRules.escapeJsonString("a\"b"), is("a\\\"b"));
+    assertThat(ElasticsearchRules.escapeJsonString("a\\b"), is("a\\\\b"));
+    assertThat(ElasticsearchRules.escapeJsonString("a\\"), is("a\\\\"));
+    // Order matters: escape backslash first, then double quote, so
+    // "a\"b" doesn't become "a\\\"b" with a lost closing quote.
+    assertThat(ElasticsearchRules.escapeJsonString("a\\\"b"),
+        is("a\\\\\\\"b"));
+  }
+
+  @Test void namesWithJsonMetacharactersStayASingleSourceField() throws 
Exception {
+    // A name that tries to close the "_source" array and add a sibling
+    // "query" key must stay confined to the string, so the emitted JSON
+    // remains a single top-level object with one member.
+    final String awkward =
+        "a\"], \"query\": {\"bool\": {\"filter\": {\"script\": "
+            + "{\"script\": \"while(true){}\"}}}}, \"docvalue_fields\": [\"b";
+    final String json = "{\"_source\" : ["
+        + ElasticsearchRules.quote(
+            ElasticsearchRules.escapeJsonString(awkward)) + "]}";
+    final JsonNode node = mapper.readTree(json);
+    final int rootMembers = node.size();
+    final int sourceEntries = node.get("_source").size();
+    assertThat(rootMembers, is(1));
+    assertThat(sourceEntries, is(1));
+    assertThat(node.get("_source").get(0).asText(), is(awkward));
+  }
+
+  @Test void simplePathsKeepDotNotation() {
+    // Historical dot notation stays in place for identifier-shaped paths
+    // so existing scripted fields render exactly as before.
+    assertThat(ElasticsearchRules.scriptedFieldAccess("params._source", 
"city"),
+        is("params._source.city"));
+    assertThat(ElasticsearchRules.scriptedFieldAccess("params._source", "b.a"),
+        is("params._source.b.a"));
+    assertThat(ElasticsearchRules.scriptedFieldAccess("params._source", 
"loc[0]"),
+        is("params._source.loc[0]"));
+  }
+
+  @Test void nonIdentifierPathsBecomeMapSubscripts() {
+    // Any path that does not fit the historical dot notation is rendered
+    // via map subscripts so the segments stay data.
+    assertThat(ElasticsearchRules.scriptedFieldAccess("params._source", 
"@timestamp"),
+        is("params._source['@timestamp']"));
+    assertThat(ElasticsearchRules.scriptedFieldAccess("params._source", "we 
ird[3]"),
+        is("params._source['we ird'][3]"));
+    assertThat(ElasticsearchRules.scriptedFieldAccess("params._source", "a.b 
c"),
+        is("params._source['a']['b c']"));
+  }
+
+  @Test void fieldNameWithScriptPunctuationIsSubscripted() {
+    // A field name that contains {@code '} or {@code ;} would otherwise
+    // be spliced into script source and parsed as Painless/Groovy code;
+    // the map-subscript form quotes those characters so the whole name
+    // stays a script string literal.
+    assertThat(
+        ElasticsearchRules.scriptedFieldAccess("params._source",
+            "a'; long x=0; for(int i=0;i<10;i++){x+=i}; '"),
+        is("params._source['a\\'; long x=0; for(int i=0;i<10;i++){x+=i}; 
\\'']"));
+  }
+
+  @Test void scriptFieldEntryStaysWellFormedJsonForAwkwardName() throws 
Exception {
+    // End-to-end: an entry built the way ElasticsearchProject builds one
+    // must still parse as a single-member JSON object with the value
+    // recovered exactly.
+    final String awkward = "a\"}}, \"other\": {\"script\": \"boom";
+    final String entry =
+        ElasticsearchRules.quote(ElasticsearchRules.escapeJsonString(awkward))
+            + ":{\"script\": "
+            + ElasticsearchRules.quote(
+                ElasticsearchRules.escapeJsonString(
+                    ElasticsearchRules.scriptedFieldAccess("params._source",
+                        awkward)))
+            + "}";
+    final JsonNode node = mapper.readTree("{\"script_fields\": {" + entry + 
"}}");
+    final int rootMembers = node.size();
+    final int scriptFieldsMembers = node.get("script_fields").size();
+    assertThat(rootMembers, is(1));
+    assertThat(scriptFieldsMembers, is(1));
+    assertThat(node.get("script_fields").get(awkward).get("script").asText(),
+        is("params._source['a\"}}, \"other\": {\"script\": \"boom']"));
+  }
+}
diff --git 
a/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeAggregate.java 
b/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeAggregate.java
index baf850e0f7..af21090a7d 100644
--- 
a/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeAggregate.java
+++ 
b/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeAggregate.java
@@ -16,6 +16,7 @@
  */
 package org.apache.calcite.adapter.geode.rel;
 
+import org.apache.calcite.adapter.geode.util.GeodeUtils;
 import org.apache.calcite.plan.RelOptCluster;
 import org.apache.calcite.plan.RelOptCost;
 import org.apache.calcite.plan.RelOptPlanner;
@@ -100,7 +101,10 @@ public GeodeAggregate(RelOptCluster cluster,
     List<String> groupByFields = new ArrayList<>();
 
     for (int group : groupSet) {
-      groupByFields.add(inputFields.get(group));
+      // OQL provides no way to quote identifiers, so the group key must
+      // match a strict allowlist before it can be copied verbatim into the
+      // generated statement.
+      
groupByFields.add(GeodeUtils.checkSafeOqlIdentifier(inputFields.get(group)));
     }
 
     geodeImplementContext.addGroupBy(groupByFields);
@@ -111,7 +115,7 @@ public GeodeAggregate(RelOptCluster cluster,
 
       List<String> aggCallFieldNames = new ArrayList<>();
       for (int i : aggCall.getArgList()) {
-        aggCallFieldNames.add(inputFields.get(i));
+        
aggCallFieldNames.add(GeodeUtils.checkSafeOqlIdentifier(inputFields.get(i)));
       }
       String functionName = aggCall.getAggregation().getName();
 
@@ -119,13 +123,14 @@ public GeodeAggregate(RelOptCluster cluster,
       // 'count(*)' but allows it for count('any column name'). So we are
       // converting the count(*) into count (first input ColumnName).
       if ("COUNT".equalsIgnoreCase(functionName) && 
aggCallFieldNames.isEmpty()) {
-        aggCallFieldNames.add(inputFields.get(0));
+        
aggCallFieldNames.add(GeodeUtils.checkSafeOqlIdentifier(inputFields.get(0)));
       }
 
       String oqlAggregateCall =
           Util.toString(aggCallFieldNames, functionName + "(", ", ", ")");
 
-      aggregateFunctionMap.put(aggCall.getName(), oqlAggregateCall);
+      
aggregateFunctionMap.put(GeodeUtils.checkSafeOqlIdentifier(aggCall.getName()),
+          oqlAggregateCall);
     }
 
     geodeImplementContext.addAggregateFunctions(aggregateFunctionMap.build());
diff --git 
a/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeProject.java 
b/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeProject.java
index 9cdcfa3eaf..b11a2e1e3b 100644
--- a/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeProject.java
+++ b/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeProject.java
@@ -17,6 +17,7 @@
 package org.apache.calcite.adapter.geode.rel;
 
 import org.apache.calcite.adapter.geode.rel.GeodeRules.RexToGeodeTranslator;
+import org.apache.calcite.adapter.geode.util.GeodeUtils;
 import org.apache.calcite.plan.RelOptCluster;
 import org.apache.calcite.plan.RelOptCost;
 import org.apache.calcite.plan.RelOptPlanner;
@@ -72,7 +73,10 @@ public class GeodeProject extends Project implements 
GeodeRel {
             GeodeRules.geodeFieldNames(getInput().getRowType()));
     final Map<String, String> fields = new LinkedHashMap<>();
     for (Pair<RexNode, String> pair : getNamedProjects()) {
-      final String name = pair.right;
+      // OQL provides no way to quote identifiers, so the projection alias
+      // must match a strict allowlist before it can be copied verbatim into
+      // the generated statement.
+      final String name = GeodeUtils.checkSafeOqlIdentifier(pair.right);
       final String originalName = pair.left.accept(translator);
       fields.put(originalName, name);
     }
diff --git 
a/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeRules.java 
b/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeRules.java
index b8beb854e0..e6a8491590 100644
--- a/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeRules.java
+++ b/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeRules.java
@@ -39,6 +39,7 @@
 import org.apache.calcite.sql.fun.SqlStdOperatorTable;
 import org.apache.calcite.sql.type.SqlTypeName;
 import org.apache.calcite.sql.validate.SqlValidatorUtil;
+import org.apache.calcite.util.Util;
 
 import com.google.common.collect.ImmutableList;
 
@@ -47,6 +48,7 @@
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.regex.Pattern;
 
 import static com.google.common.base.Preconditions.checkArgument;
 
@@ -90,6 +92,44 @@ static List<String> geodeFieldNames(final RelDataType 
rowType) {
     return SqlValidatorUtil.uniquify(rowType.getFieldNames(), true);
   }
 
+  /** Shape of ITEM string keys that may be embedded in a {@code path.key}
+   * OQL expression. OQL has no way to quote identifiers, so any other key
+   * (quotes, spaces, operators, parentheses) could change the structure of
+   * the statement sent to Geode and must never be pushed down. */
+  private static final Pattern OQL_ITEM_KEY =
+      Pattern.compile("[A-Za-z_$][A-Za-z0-9_$]*");
+
+  /** Returns whether an ITEM string key may be embedded in OQL text. */
+  static boolean isSafeItemKey(String key) {
+    return OQL_ITEM_KEY.matcher(key).matches();
+  }
+
+  /** Returns whether every ITEM call (if any) inside {@code node} uses a
+   * string key that is safe to embed in OQL text. Push-down rules use this
+   * to keep expressions with unsafe keys on the Calcite side, where they
+   * are evaluated after the scan instead of being concatenated into the
+   * OQL statement. */
+  static boolean hasOnlySafeItemKeys(RexNode node) {
+    try {
+      node.accept(new RexVisitorImpl<Void>(true) {
+        @Override public Void visitCall(RexCall call) {
+          if (call.getOperator() == SqlStdOperatorTable.ITEM) {
+            final RexNode op1 = call.getOperands().get(1);
+            if (op1 instanceof RexLiteral
+                && ((RexLiteral) op1).getValue2() instanceof String
+                && !isSafeItemKey((String) ((RexLiteral) op1).getValue2())) {
+              throw Util.FoundOne.NULL;
+            }
+          }
+          return super.visitCall(call);
+        }
+      });
+      return true;
+    } catch (Util.FoundOne e) {
+      return false;
+    }
+  }
+
   /**
    * Translator from {@link RexNode} to strings in Geode's expression language.
    */
@@ -115,7 +155,16 @@ protected RexToGeodeTranslator(List<String> inFields) {
           if (op1.getType().getSqlTypeName() == SqlTypeName.INTEGER) {
             return stripQuotes(strings.get(0)) + "[" + ((RexLiteral) 
op1).getValue2() + "]";
           } else if (op1.getType().getSqlTypeName() == SqlTypeName.CHAR) {
-            return stripQuotes(strings.get(0)) + "." + ((RexLiteral) 
op1).getValue2();
+            final String key = String.valueOf(((RexLiteral) op1).getValue2());
+            // The key becomes part of the OQL text, so restrict it to a
+            // plain identifier; anything else could alter the structure of
+            // the statement. The push-down rules refuse to match such
+            // expressions, so this check is defense-in-depth.
+            if (!isSafeItemKey(key)) {
+              throw new IllegalArgumentException("Geode adapter cannot push "
+                  + "ITEM key '" + key + "' into OQL: not a plain identifier");
+            }
+            return stripQuotes(strings.get(0)) + "." + key;
           }
         }
       }
@@ -149,6 +198,12 @@ protected GeodeProjectRule(Config config) {
           // For spatial Functions Drop to Calcite Enumerable
           return false;
         }
+        if (!hasOnlySafeItemKeys(e)) {
+          // An ITEM key inside this projection would be concatenated into
+          // the OQL SELECT list; keep the projection on the Calcite side,
+          // where it is evaluated after the scan.
+          return false;
+        }
       }
       return project.getVariablesSet().isEmpty();
     }
@@ -268,6 +323,13 @@ protected GeodeFilterRule(GeodeFilterRuleConfig config) {
       LogicalFilter filter = call.rel(0);
       RexNode condition = filter.getCondition();
 
+      if (!hasOnlySafeItemKeys(condition)) {
+        // An ITEM key inside the condition would be concatenated into the
+        // OQL WHERE clause; keep the filter on the Calcite side, where it
+        // is evaluated after the scan.
+        return false;
+      }
+
       List<String> fieldNames = 
GeodeRules.geodeFieldNames(filter.getInput().getRowType());
 
       List<RexNode> disjunctions = RelOptUtil.disjunctions(condition);
diff --git 
a/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeSort.java 
b/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeSort.java
index c9b33252d9..41d850a521 100644
--- a/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeSort.java
+++ b/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeSort.java
@@ -16,6 +16,7 @@
  */
 package org.apache.calcite.adapter.geode.rel;
 
+import org.apache.calcite.adapter.geode.util.GeodeUtils;
 import org.apache.calcite.plan.RelOptCluster;
 import org.apache.calcite.plan.RelOptCost;
 import org.apache.calcite.plan.RelOptPlanner;
@@ -79,7 +80,11 @@ public class GeodeSort extends Sort implements GeodeRel {
       List<String> orderByFields = new ArrayList<>();
 
       for (RelFieldCollation fieldCollation : sortCollations) {
-        final String name = fieldName(fieldCollation.getFieldIndex());
+        // OQL provides no way to quote identifiers, so the ORDER BY field
+        // name must match a strict allowlist before it can be copied
+        // verbatim into the generated statement.
+        final String name =
+            
GeodeUtils.checkSafeOqlIdentifier(fieldName(fieldCollation.getFieldIndex()));
         orderByFields.add(name + " " + 
direction(fieldCollation.getDirection()));
       }
       geodeImplementContext.addOrderByFields(orderByFields);
diff --git 
a/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeTable.java 
b/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeTable.java
index 584b5cacae..20222c496c 100644
--- a/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeTable.java
+++ b/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeTable.java
@@ -53,6 +53,7 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.regex.Pattern;
 
 import static java.util.Objects.requireNonNull;
 
@@ -63,6 +64,30 @@ public class GeodeTable extends AbstractQueryableTable 
implements TranslatableTa
 
   private static final Logger LOGGER = 
LoggerFactory.getLogger(GeodeTable.class.getName());
 
+  /** An OQL identifier: the only shape accepted for a SQL-side alias that
+   * becomes part of the OQL statement text. */
+  private static final Pattern OQL_IDENTIFIER =
+      Pattern.compile("[A-Za-z_$][A-Za-z0-9_$]*");
+
+  /** A field path as produced by {@link GeodeRules.RexToGeodeTranslator},
+   * for example {@code primaryAddress.postalCode} or {@code loc[0]}.
+   * Possessive quantifiers avoid backtracking on inputs that almost, but
+   * do not quite, match. */
+  private static final Pattern OQL_FIELD_PATH =
+      Pattern.compile("[A-Za-z_$][A-Za-z0-9_$]*+"
+          + "(?:\\.[A-Za-z_$][A-Za-z0-9_$]*+|\\[[0-9]++\\])*+");
+
+  /** An aggregate call as produced by {@link GeodeAggregate},
+   * for example {@code SUM(pop)}. */
+  private static final Pattern OQL_AGGREGATE_CALL =
+      Pattern.compile("[A-Za-z_$][A-Za-z0-9_$]*\\((?:" + 
OQL_FIELD_PATH.pattern()
+          + "(?:, " + OQL_FIELD_PATH.pattern() + ")*)?\\)");
+
+  /** An ORDER BY entry as produced by {@link GeodeSort},
+   * for example {@code state ASC}. */
+  private static final Pattern OQL_ORDER_BY_ENTRY =
+      Pattern.compile(OQL_FIELD_PATH.pattern() + " (?:ASC|DESC)");
+
   private final String regionName;
   private final RelDataType rowType;
 
@@ -76,6 +101,51 @@ public class GeodeTable extends AbstractQueryableTable 
implements TranslatableTa
     return "GeodeTable {" + regionName + "}";
   }
 
+  /** Checks that a string that is about to be concatenated into the OQL
+   * statement matches the expected shape, and returns it.
+   *
+   * <p>SQL quoted identifiers may contain arbitrary characters, so an
+   * unvalidated alias would let arbitrary OQL text (for example a nested
+   * query on a region that is not exposed by the schema, or a method
+   * invocation on a server object) be concatenated into the emitted
+   * statement. OQL has no identifier quoting mechanism, therefore values
+   * that do not look like a plain identifier/path are rejected rather than
+   * escaped.
+   *
+   * <p>The value must be non-null; a null argument is a caller bug and
+   * surfaces as {@link NullPointerException}.
+   *
+   * @throws IllegalArgumentException if the value does not match
+   */
+  private static String checkOql(Pattern pattern, String kind, String value) {
+    requireNonNull(value, kind);
+    if (!pattern.matcher(value).matches()) {
+      throw new IllegalArgumentException("Cannot use " + kind + " '" + value
+          + "' in a Geode OQL query; only plain identifiers are supported");
+    }
+    return value;
+  }
+
+  /** Validates an alias (output name) used in the OQL select list. */
+  static String checkOqlIdentifier(String alias) {
+    return checkOql(OQL_IDENTIFIER, "alias", alias);
+  }
+
+  /** Validates a field path used in the OQL select or GROUP BY list. */
+  static String checkOqlFieldPath(String field) {
+    return checkOql(OQL_FIELD_PATH, "field", field);
+  }
+
+  /** Validates an aggregate call used in the OQL select list. */
+  static String checkOqlAggregateCall(String aggregateCall) {
+    return checkOql(OQL_AGGREGATE_CALL, "aggregate call", aggregateCall);
+  }
+
+  /** Validates an ORDER BY entry ({@code field ASC|DESC}). */
+  static String checkOqlOrderByEntry(String entry) {
+    return checkOql(OQL_ORDER_BY_ENTRY, "ORDER BY field", entry);
+  }
+
   /**
    * Executes an OQL query on the underlying table.
    *
@@ -130,24 +200,30 @@ public Enumerable<Object> query(final GemFireCache 
clientCache,
       aggFuncMap = aggFuncMapBuilder.build();
     }
 
-    // Construct the list of fields to project
+    // Construct the list of fields to project. OQL provides no way to quote
+    // identifiers, so every field name, alias, and GROUP BY key that is copied
+    // verbatim into the generated statement is validated against a strict
+    // allowlist; anything else refuses pushdown.
     ImmutableList.Builder<String> selectBuilder = ImmutableList.builder();
     if (!groupByFields.isEmpty()) {
       // manually add GROUP BY to select clause (GeodeProjection was not 
visited)
       for (String groupByField : groupByFields) {
+        checkOqlFieldPath(groupByField);
         selectBuilder.add(groupByField + " AS " + groupByField);
       }
 
       if (!aggFuncMap.isEmpty()) {
         for (Map.Entry<String, String> e : aggFuncMap.entrySet()) {
-          selectBuilder.add(e.getValue() + " AS " + e.getKey());
+          selectBuilder.add(checkOqlAggregateCall(e.getValue())
+              + " AS " + checkOqlIdentifier(e.getKey()));
         }
       }
     } else {
       if (selectFields.isEmpty()) {
         if (!aggFuncMap.isEmpty()) {
           for (Map.Entry<String, String> e : aggFuncMap.entrySet()) {
-            selectBuilder.add(e.getValue() + " AS " + e.getKey());
+            selectBuilder.add(checkOqlAggregateCall(e.getValue())
+                + " AS " + checkOqlIdentifier(e.getKey()));
           }
         } else {
           selectBuilder.add("*");
@@ -155,11 +231,13 @@ public Enumerable<Object> query(final GemFireCache 
clientCache,
       } else {
         if (!aggFuncMap.isEmpty()) {
           for (Map.Entry<String, String> e : aggFuncMap.entrySet()) {
-            selectBuilder.add(e.getValue() + " AS " + e.getKey());
+            selectBuilder.add(checkOqlAggregateCall(e.getValue())
+                + " AS " + checkOqlIdentifier(e.getKey()));
           }
         } else {
           for (Map.Entry<String, String> field : selectFields) {
-            selectBuilder.add(field.getKey() + " AS " + field.getValue());
+            selectBuilder.add(checkOqlFieldPath(field.getKey())
+                + " AS " + checkOqlIdentifier(field.getValue()));
           }
         }
       }
@@ -186,6 +264,9 @@ public Enumerable<Object> query(final GemFireCache 
clientCache,
     }
 
     if (!orderByFields.isEmpty()) {
+      for (String orderByField : orderByFields) {
+        checkOqlOrderByEntry(orderByField);
+      }
       queryBuilder.append(Util.toString(orderByFields, " ORDER BY ", ", ", 
""));
     }
     if (limit != null) {
diff --git 
a/geode/src/main/java/org/apache/calcite/adapter/geode/util/GeodeUtils.java 
b/geode/src/main/java/org/apache/calcite/adapter/geode/util/GeodeUtils.java
index 3cc43ca4b2..1464330348 100644
--- a/geode/src/main/java/org/apache/calcite/adapter/geode/util/GeodeUtils.java
+++ b/geode/src/main/java/org/apache/calcite/adapter/geode/util/GeodeUtils.java
@@ -46,6 +46,7 @@
 import java.util.Locale;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Pattern;
 
 import static java.util.Objects.requireNonNull;
 
@@ -338,6 +339,37 @@ public static RelDataType 
autodetectRelTypeFromRegion(Region<?, ?> region) {
     return createRelDataType(entry);
   }
 
+  /**
+   * OQL has no way to escape identifiers, so an identifier that is copied
+   * verbatim into the generated query must be a simple alphanumeric name (with
+   * optional dotted path or bracketed integer subscript for nested access).
+   * Anything else could shift the boundaries of the emitted statement.
+   */
+  private static final Pattern SAFE_OQL_IDENTIFIER =
+      Pattern.compile(
+          
"[A-Za-z_$][A-Za-z0-9_$]*+(?:\\.[A-Za-z_$][A-Za-z0-9_$]*+|\\[[0-9]++\\])*+");
+
+  /**
+   * Returns whether {@code name} may be emitted verbatim as an OQL identifier
+   * (a field name, alias, or dotted path). Since OQL provides no way to quote
+   * an identifier, callers should refuse to push down when this returns false.
+   */
+  public static boolean isSafeOqlIdentifier(String name) {
+    return name != null && SAFE_OQL_IDENTIFIER.matcher(name).matches();
+  }
+
+  /**
+   * Validates that {@code name} may be emitted verbatim into a generated OQL
+   * statement, throwing a {@link RuntimeException} otherwise.
+   */
+  public static String checkSafeOqlIdentifier(String name) {
+    if (!isSafeOqlIdentifier(name)) {
+      throw new RuntimeException(
+          "Cannot push down expression: not a simple OQL identifier: " + name);
+    }
+    return name;
+  }
+
   // Create Relational Type by inferring a Geode entry or response instance.
   private static RelDataType createRelDataType(Object regionEntry) {
     JavaTypeFactoryExtImpl typeFactory = new JavaTypeFactoryExtImpl();
diff --git 
a/geode/src/test/java/org/apache/calcite/adapter/geode/rel/GeodeRulesTest.java 
b/geode/src/test/java/org/apache/calcite/adapter/geode/rel/GeodeRulesTest.java
new file mode 100644
index 0000000000..2164947913
--- /dev/null
+++ 
b/geode/src/test/java/org/apache/calcite/adapter/geode/rel/GeodeRulesTest.java
@@ -0,0 +1,137 @@
+/*
+ * 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.Constructor;
+import java.util.Collections;
+import java.util.List;
+
+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 GeodeRules} covering the ITEM-key plan-time
+ * short-circuit and the dotted-path emission for {@code ITEM(field, 'key')}
+ * calls.
+ *
+ * <p>OQL provides no way to quote identifiers, so a CHAR literal that
+ * becomes part of a dotted field path must match a strict allowlist before
+ * it can be copied verbatim into the generated statement; any other key
+ * keeps the expression on the Calcite side.
+ */
+class GeodeRulesTest {
+
+  private static final RelDataTypeFactory TYPE_FACTORY = new 
JavaTypeFactoryImpl();
+  private static final RexBuilder REX_BUILDER = new RexBuilder(TYPE_FACTORY);
+
+  /** Builds a translator over a single-column ANY-typed row so ITEM
+   * calls type-check. */
+  private static GeodeRules.RexToGeodeTranslator translator(List<String> 
fields)
+      throws Exception {
+    Constructor<GeodeRules.RexToGeodeTranslator> ctor =
+        GeodeRules.RexToGeodeTranslator.class
+            .getDeclaredConstructor(List.class);
+    ctor.setAccessible(true);
+    return ctor.newInstance(fields);
+  }
+
+  private static RexNode itemCall(String key) {
+    RelDataType any = TYPE_FACTORY.createSqlType(SqlTypeName.ANY);
+    RexInputRef ref = REX_BUILDER.makeInputRef(any, 0);
+    RexNode lit = REX_BUILDER.makeLiteral(key);
+    return REX_BUILDER.makeCall(SqlStdOperatorTable.ITEM, ref, lit);
+  }
+
+  @Test void plainKeyAccepted() throws Exception {
+    GeodeRules.RexToGeodeTranslator t =
+        translator(Collections.singletonList("root"));
+    assertThat(itemCall("child").accept(t), is("root.child"));
+  }
+
+  @Test void syntheticExprKeyAccepted() throws Exception {
+    GeodeRules.RexToGeodeTranslator t =
+        translator(Collections.singletonList("root"));
+    assertThat(itemCall("EXPR$0").accept(t), is("root.EXPR$0"));
+  }
+
+  @Test void keyWithSpaceRejected() throws Exception {
+    GeodeRules.RexToGeodeTranslator t =
+        translator(Collections.singletonList("root"));
+    IllegalArgumentException e =
+        assertThrows(IllegalArgumentException.class,
+            () -> itemCall("a b").accept(t));
+    assertThat(e.getMessage(), containsString("not a plain identifier"));
+  }
+
+  @Test void keyWithSingleQuoteRejected() throws Exception {
+    // An apostrophe in the key would terminate the surrounding OQL string
+    // token and shift the boundaries of the emitted statement.
+    GeodeRules.RexToGeodeTranslator t =
+        translator(Collections.singletonList("root"));
+    assertThrows(IllegalArgumentException.class,
+        () -> itemCall("a'b").accept(t));
+  }
+
+  @Test void keyWithPunctuationRejected() throws Exception {
+    GeodeRules.RexToGeodeTranslator t =
+        translator(Collections.singletonList("root"));
+    assertThrows(IllegalArgumentException.class,
+        () -> itemCall("a,b").accept(t));
+    assertThrows(IllegalArgumentException.class,
+        () -> itemCall("a(b)").accept(t));
+    assertThrows(IllegalArgumentException.class,
+        () -> itemCall("a.b").accept(t));
+  }
+
+  @Test void safeItemKeyPredicate() {
+    assertThat(GeodeRules.isSafeItemKey("postalCode"), is(true));
+    assertThat(GeodeRules.isSafeItemKey("_key$1"), is(true));
+    assertThat(GeodeRules.isSafeItemKey("EXPR$0"), is(true));
+    assertThat(GeodeRules.isSafeItemKey(""), is(false));
+    assertThat(GeodeRules.isSafeItemKey("a b"), is(false));
+    assertThat(GeodeRules.isSafeItemKey("a.b"), is(false));
+    assertThat(GeodeRules.isSafeItemKey("e.getClass()"), is(false));
+  }
+
+  @Test void hasOnlySafeItemKeysInspectsWholeTree() {
+    // A plain expression walks through as safe.
+    RexNode safe =
+        REX_BUILDER.makeCall(SqlStdOperatorTable.EQUALS, itemCall("k"),
+            REX_BUILDER.makeLiteral("v"));
+    assertThat(GeodeRules.hasOnlySafeItemKeys(safe), is(true));
+
+    // An unsafe key anywhere in the tree flips the answer.
+    RexNode unsafe =
+        REX_BUILDER.makeCall(SqlStdOperatorTable.EQUALS,
+            itemCall("k' OR '1' = '1"),
+            REX_BUILDER.makeLiteral("v"));
+    assertThat(GeodeRules.hasOnlySafeItemKeys(unsafe), is(false));
+  }
+}
diff --git 
a/geode/src/test/java/org/apache/calcite/adapter/geode/rel/GeodeTableOqlValidationTest.java
 
b/geode/src/test/java/org/apache/calcite/adapter/geode/rel/GeodeTableOqlValidationTest.java
new file mode 100644
index 0000000000..aba600f95b
--- /dev/null
+++ 
b/geode/src/test/java/org/apache/calcite/adapter/geode/rel/GeodeTableOqlValidationTest.java
@@ -0,0 +1,116 @@
+/*
+ * 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.junit.jupiter.api.Test;
+
+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 the identifier validation {@link GeodeTable} applies to
+ * every SQL-controlled name (aliases, field paths, aggregate calls, ORDER BY
+ * entries) before concatenating it into an OQL statement.
+ *
+ * <p>SQL quoted identifiers may contain arbitrary characters, and OQL has
+ * no identifier quoting mechanism, so anything that is not a plain
+ * identifier/path must be rejected: otherwise a SQL-supplied alias could
+ * shift the structure of the emitted OQL (for example a nested query on a
+ * region that is not exposed by the schema). The tests do not require a
+ * running Geode cluster.
+ */
+class GeodeTableOqlValidationTest {
+
+  @Test void plainAliasesAccepted() {
+    assertThat(GeodeTable.checkOqlIdentifier("state"), is("state"));
+    assertThat(GeodeTable.checkOqlIdentifier("_id"), is("_id"));
+    // Calcite-generated aliases contain '$'
+    assertThat(GeodeTable.checkOqlIdentifier("EXPR$0"), is("EXPR$0"));
+  }
+
+  @Test void aliasWithEmbeddedOqlRejected() {
+    // OQL supports method invocation: without validation a SQL-supplied
+    // alias could call methods on server-side objects.
+    IllegalArgumentException e =
+        assertThrows(IllegalArgumentException.class, () ->
+            GeodeTable.checkOqlIdentifier(
+                "f, e.getClass().getClassLoader() AS leak"));
+    assertThat(e.getMessage(), containsString("only plain identifiers"));
+  }
+
+  @Test void aliasWithNestedQueryRejected() {
+    assertThrows(IllegalArgumentException.class, () ->
+        GeodeTable.checkOqlIdentifier(
+            "x FROM (SELECT s.ssn FROM /SecretRegion s)"));
+  }
+
+  @Test void aliasWithWhitespaceOrEmptyRejected() {
+    assertThrows(IllegalArgumentException.class, () ->
+        GeodeTable.checkOqlIdentifier("a b"));
+    assertThrows(IllegalArgumentException.class, () ->
+        GeodeTable.checkOqlIdentifier(""));
+    // Null is a caller bug and surfaces as NullPointerException, not as a 
shape violation
+    assertThrows(NullPointerException.class, () ->
+        GeodeTable.checkOqlIdentifier(null));
+  }
+
+  @Test void fieldPathsAccepted() {
+    assertThat(GeodeTable.checkOqlFieldPath("pop"), is("pop"));
+    assertThat(GeodeTable.checkOqlFieldPath("primaryAddress.postalCode"),
+        is("primaryAddress.postalCode"));
+    assertThat(GeodeTable.checkOqlFieldPath("loc[0]"), is("loc[0]"));
+  }
+
+  @Test void fieldPathWithEmbeddedPredicateRejected() {
+    assertThrows(IllegalArgumentException.class, () ->
+        GeodeTable.checkOqlFieldPath(
+            "pop AND (SELECT COUNT(*) FROM /SecretRegion) > 0"));
+    assertThrows(IllegalArgumentException.class, () ->
+        GeodeTable.checkOqlFieldPath("loc['0']"));
+  }
+
+  @Test void aggregateCallsAccepted() {
+    assertThat(GeodeTable.checkOqlAggregateCall("SUM(pop)"), is("SUM(pop)"));
+    assertThat(GeodeTable.checkOqlAggregateCall("COUNT(itemNumber)"),
+        is("COUNT(itemNumber)"));
+    assertThat(GeodeTable.checkOqlAggregateCall("AVG(loc[1])"),
+        is("AVG(loc[1])"));
+  }
+
+  @Test void aggregateCallWithExtraContentRejected() {
+    assertThrows(IllegalArgumentException.class, () ->
+        GeodeTable.checkOqlAggregateCall("SUM(pop), e.getClass()"));
+    assertThrows(IllegalArgumentException.class, () ->
+        GeodeTable.checkOqlAggregateCall("SUM(pop) FROM /Other"));
+  }
+
+  @Test void orderByEntriesAccepted() {
+    assertThat(GeodeTable.checkOqlOrderByEntry("state ASC"), is("state ASC"));
+    assertThat(GeodeTable.checkOqlOrderByEntry("loc[0] DESC"),
+        is("loc[0] DESC"));
+  }
+
+  @Test void orderByEntryWithExtraContentRejected() {
+    assertThrows(IllegalArgumentException.class, () ->
+        GeodeTable.checkOqlOrderByEntry(
+            "state ASC LIMIT 1 -- extra DESC"));
+    assertThrows(IllegalArgumentException.class, () ->
+        GeodeTable.checkOqlOrderByEntry("state"));
+  }
+}
diff --git 
a/geode/src/test/java/org/apache/calcite/adapter/geode/util/GeodeUtilsTest.java 
b/geode/src/test/java/org/apache/calcite/adapter/geode/util/GeodeUtilsTest.java
new file mode 100644
index 0000000000..3fb844ed39
--- /dev/null
+++ 
b/geode/src/test/java/org/apache/calcite/adapter/geode/util/GeodeUtilsTest.java
@@ -0,0 +1,128 @@
+/*
+ * 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.util;
+
+import org.junit.jupiter.api.Test;
+
+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 the OQL identifier allowlist in {@link GeodeUtils}.
+ *
+ * <p>OQL provides no way to quote an identifier, so a name that is
+ * copied verbatim into a generated query must match a strict
+ * allowlist; otherwise it could shift the boundaries of the emitted
+ * statement.
+ */
+class GeodeUtilsTest {
+
+  @Test void simpleNameAccepted() {
+    assertThat(GeodeUtils.isSafeOqlIdentifier("field"), is(true));
+    assertThat(GeodeUtils.isSafeOqlIdentifier("_field"), is(true));
+    assertThat(GeodeUtils.isSafeOqlIdentifier("$field"), is(true));
+    assertThat(GeodeUtils.isSafeOqlIdentifier("Field1"), is(true));
+  }
+
+  @Test void syntheticExprNameAccepted() {
+    // Calcite emits synthetic aggregate column names like EXPR$0; the
+    // pattern must accept them so a pushed-down aggregate is not
+    // rejected on legitimate input.
+    assertThat(GeodeUtils.isSafeOqlIdentifier("EXPR$0"), is(true));
+    assertThat(GeodeUtils.isSafeOqlIdentifier("EXPR$10"), is(true));
+  }
+
+  @Test void dottedPathAccepted() {
+    assertThat(GeodeUtils.isSafeOqlIdentifier("a.b"), is(true));
+    assertThat(GeodeUtils.isSafeOqlIdentifier("a.b.c"), is(true));
+    assertThat(GeodeUtils.isSafeOqlIdentifier("a.$b"), is(true));
+  }
+
+  @Test void bracketedIndexAccepted() {
+    assertThat(GeodeUtils.isSafeOqlIdentifier("a[0]"), is(true));
+    assertThat(GeodeUtils.isSafeOqlIdentifier("a[123]"), is(true));
+    assertThat(GeodeUtils.isSafeOqlIdentifier("a.b[7]"), is(true));
+    // A subscript may be followed by a further dotted field.
+    assertThat(GeodeUtils.isSafeOqlIdentifier("a[0].c"), is(true));
+  }
+
+  @Test void nullRejected() {
+    assertThat(GeodeUtils.isSafeOqlIdentifier(null), is(false));
+  }
+
+  @Test void emptyRejected() {
+    assertThat(GeodeUtils.isSafeOqlIdentifier(""), is(false));
+  }
+
+  @Test void leadingDigitRejected() {
+    assertThat(GeodeUtils.isSafeOqlIdentifier("1field"), is(false));
+  }
+
+  @Test void whitespaceRejected() {
+    assertThat(GeodeUtils.isSafeOqlIdentifier("a b"), is(false));
+    assertThat(GeodeUtils.isSafeOqlIdentifier("a\tb"), is(false));
+    assertThat(GeodeUtils.isSafeOqlIdentifier("a\nb"), is(false));
+  }
+
+  @Test void quotesRejected() {
+    // A value containing a single or double quote would let a fragment
+    // pushed as an alias terminate the surrounding OQL token.
+    assertThat(GeodeUtils.isSafeOqlIdentifier("a'b"), is(false));
+    assertThat(GeodeUtils.isSafeOqlIdentifier("a\"b"), is(false));
+  }
+
+  @Test void commaRejected() {
+    // A comma in an alias would split the select list.
+    assertThat(GeodeUtils.isSafeOqlIdentifier("a,b"), is(false));
+  }
+
+  @Test void punctuationRejected() {
+    assertThat(GeodeUtils.isSafeOqlIdentifier("a-b"), is(false));
+    assertThat(GeodeUtils.isSafeOqlIdentifier("a+b"), is(false));
+    assertThat(GeodeUtils.isSafeOqlIdentifier("a b"), is(false));
+    assertThat(GeodeUtils.isSafeOqlIdentifier("(a)"), is(false));
+  }
+
+  @Test void bracketedNonIntegerRejected() {
+    // Only integer subscripts are safe; anything else could carry
+    // OQL punctuation into the emitted statement.
+    assertThat(GeodeUtils.isSafeOqlIdentifier("a[b]"), is(false));
+    assertThat(GeodeUtils.isSafeOqlIdentifier("a['b']"), is(false));
+    assertThat(GeodeUtils.isSafeOqlIdentifier("a[]"), is(false));
+    assertThat(GeodeUtils.isSafeOqlIdentifier("a[0"), is(false));
+  }
+
+  @Test void checkSafeOqlIdentifierReturnsName() {
+    assertThat(GeodeUtils.checkSafeOqlIdentifier("field"), is("field"));
+    assertThat(GeodeUtils.checkSafeOqlIdentifier("EXPR$0"), is("EXPR$0"));
+  }
+
+  @Test void checkSafeOqlIdentifierThrowsOnUnsafe() {
+    RuntimeException e =
+        assertThrows(RuntimeException.class,
+            () -> GeodeUtils.checkSafeOqlIdentifier("a b"));
+    assertThat(e.getMessage(),
+        containsString("not a simple OQL identifier"));
+  }
+
+  @Test void checkSafeOqlIdentifierThrowsOnNull() {
+    assertThrows(RuntimeException.class,
+        () -> GeodeUtils.checkSafeOqlIdentifier(null));
+  }
+}
diff --git 
a/mongodb/src/main/java/org/apache/calcite/adapter/mongodb/MongoAggregate.java 
b/mongodb/src/main/java/org/apache/calcite/adapter/mongodb/MongoAggregate.java
index 4e368607de..d9a4358bc6 100644
--- 
a/mongodb/src/main/java/org/apache/calcite/adapter/mongodb/MongoAggregate.java
+++ 
b/mongodb/src/main/java/org/apache/calcite/adapter/mongodb/MongoAggregate.java
@@ -109,7 +109,11 @@ public MongoAggregate(RelOptCluster cluster, RelTraitSet 
traitSet,
       List<String> keys = new ArrayList<>();
       for (int group : groupSet) {
         final String inName = inNames.get(group);
-        keys.add(inName + ": " + MongoRules.quote("$" + inName));
+        // The $group _id key is emitted verbatim into the BSON document, so
+        // route the key through the same quoting helper as the aggregate
+        // output names to prevent a value containing punctuation from
+        // shifting the document boundaries.
+        keys.add(MongoRules.maybeQuote(inName) + ": " + MongoRules.quote("$" + 
inName));
         ++i;
       }
       list.add("_id: " + Util.toString(keys, "{", ", ", "}"));
diff --git 
a/mongodb/src/test/java/org/apache/calcite/adapter/mongodb/MongoRulesTest.java 
b/mongodb/src/test/java/org/apache/calcite/adapter/mongodb/MongoRulesTest.java
new file mode 100644
index 0000000000..970536b075
--- /dev/null
+++ 
b/mongodb/src/test/java/org/apache/calcite/adapter/mongodb/MongoRulesTest.java
@@ -0,0 +1,78 @@
+/*
+ * 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.mongodb;
+
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+/**
+ * Unit tests for the BSON identifier-quoting helpers in
+ * {@link MongoRules}. Field names emitted verbatim into a BSON
+ * document (as {@code $group._id} keys or aggregate output names)
+ * must be quoted whenever they contain a character that BsonDocument's
+ * parser would otherwise treat as part of a different token, so a
+ * value containing punctuation cannot shift the document boundaries.
+ */
+class MongoRulesTest {
+
+  @Test void plainNameNotQuoted() {
+    assertThat(MongoRules.maybeQuote("field"), is("field"));
+    assertThat(MongoRules.maybeQuote("_field"), is("_field"));
+    assertThat(MongoRules.maybeQuote("Field1"), is("Field1"));
+  }
+
+  @Test void dollarNameQuoted() {
+    // A leading {@code $} would otherwise be parsed by BsonDocument
+    // as an operator reference; quote it so the name stays literal.
+    assertThat(MongoRules.maybeQuote("$field"), is("'$field'"));
+  }
+
+  @Test void syntheticExprNameQuoted() {
+    // Calcite emits synthetic aggregate column names like EXPR$0.
+    // The $ makes them fail the "plain identifier" check, so
+    // maybeQuote must wrap them in quotes.
+    assertThat(MongoRules.maybeQuote("EXPR$0"), is("'EXPR$0'"));
+  }
+
+  @Test void nameWithPunctuationQuoted() {
+    assertThat(MongoRules.maybeQuote("a b"), is("'a b'"));
+    assertThat(MongoRules.maybeQuote("a.b"), is("'a.b'"));
+    assertThat(MongoRules.maybeQuote("a,b"), is("'a,b'"));
+    assertThat(MongoRules.maybeQuote("a:b"), is("'a:b'"));
+  }
+
+  @Test void quoteEscapesBackslashAndApostrophe() {
+    // Inside the quoted BSON literal a backslash or an apostrophe
+    // would otherwise let the string terminate early and shift the
+    // document boundaries; both must be escaped.
+    assertThat(MongoRules.quote("a\\b"), is("'a\\\\b'"));
+    assertThat(MongoRules.quote("a'b"), is("'a\\'b'"));
+    assertThat(MongoRules.quote("a\\'b"), is("'a\\\\\\'b'"));
+  }
+
+  @Test void quoteTrailingBackslash() {
+    // A trailing backslash must be doubled or it would otherwise
+    // consume the closing apostrophe.
+    assertThat(MongoRules.quote("a\\"), is("'a\\\\'"));
+  }
+
+  @Test void quoteApostropheOnly() {
+    assertThat(MongoRules.quote("'"), is("'\\''"));
+  }
+}
diff --git a/site/_docs/history.md b/site/_docs/history.md
index cc4e1e9ebb..0b6d23450b 100644
--- a/site/_docs/history.md
+++ b/site/_docs/history.md
@@ -119,6 +119,15 @@ #### Breaking Changes
 `-Dcalcite.enable.spark=true`. The `spark` connection property alone no longer
 activates it; a connection using `spark=true` without the opt-in will fail.
 
+* [<a 
href="https://issues.apache.org/jira/browse/CALCITE-7781";>CALCITE-7781</a>]
+The fallback `SqlDialect` for an unrecognized JDBC product name now doubles
+backslashes when quoting string literals, so a value cannot terminate the
+literal early on a backend that treats `\` as an in-string escape character
+(the MySQL family, among others). Deployments whose unrecognized backend
+follows SQL-standard literal lexing may restore the legacy behavior with
+`-Dcalcite.sql.dialect.unknown.escapes.backslash=false`; the robust fix is
+to add a `SqlDialect` implementation for the product.
+
 * [<a 
href="https://issues.apache.org/jira/browse/CALCITE-7787";>CALCITE-7787</a>]
 The Geode schema operand `pdxSerializablePackagePath` (honored by 
`GeodeSchemaFactory`
 and `GeodeSimpleSchemaFactory`) now requires the operator-level opt-in system 
property
diff --git 
a/splunk/src/main/java/org/apache/calcite/adapter/splunk/SplunkPushDownRule.java
 
b/splunk/src/main/java/org/apache/calcite/adapter/splunk/SplunkPushDownRule.java
index 44400635eb..ec5a68437d 100644
--- 
a/splunk/src/main/java/org/apache/calcite/adapter/splunk/SplunkPushDownRule.java
+++ 
b/splunk/src/main/java/org/apache/calcite/adapter/splunk/SplunkPushDownRule.java
@@ -42,12 +42,14 @@
 
 import com.google.common.collect.ImmutableSet;
 
+import org.checkerframework.checker.nullness.qual.Nullable;
 import org.immutables.value.Value;
 import org.slf4j.Logger;
 
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
+import java.util.regex.Pattern;
 
 /**
  * Planner rule to push filters and projections to Splunk.
@@ -58,6 +60,25 @@ public class SplunkPushDownRule
   private static final Logger LOGGER =
       StringUtils.getClassTracer(SplunkPushDownRule.class);
 
+  /**
+   * Shape of field names that may be copied verbatim into the SPL search
+   * string. SPL has no generic identifier-quoting mechanism, so a name
+   * outside this set is never pushed down; the corresponding filter or
+   * projection is evaluated by Calcite instead. The set is deliberately
+   * broad enough to cover the common Splunk shapes (leading underscore as
+   * in {@code _raw}, dotted paths, {@code {}} for multivalued fields,
+   * {@code $} in Calcite-generated aliases such as {@code EXPR$0}, and
+   * {@code -} in host-style names) so pushdown is preserved for legitimate
+   * inputs.
+   */
+  private static final Pattern SAFE_FIELD_NAME =
+      Pattern.compile("[A-Za-z0-9_.:@#{}$\\-]+");
+
+  /** Returns whether a field name may be embedded verbatim in SPL text. */
+  static boolean isSafeFieldName(String name) {
+    return name != null && SAFE_FIELD_NAME.matcher(name).matches();
+  }
+
   private static final Set<SqlKind> SUPPORTED_OPS =
       ImmutableSet.of(
           SqlKind.CAST,
@@ -193,10 +214,15 @@ protected SplunkPushDownRule(RelOptRuleOperand operand,
     }
     LOGGER.debug("pre transformTo fieldNames: {}", getFieldsString(topRow));
 
-    call.transformTo(
+    final RelNode rel =
         appendSearchString(
             filterString, splunkRel, topProj, bottomProj,
-            topRow, null));
+            topRow, null);
+    if (rel == null) {
+      // A field-rename side is not a safe SPL name; leave the plan in place.
+      return;
+    }
+    call.transformTo(rel);
   }
 
   /**
@@ -206,8 +232,12 @@ protected SplunkPushDownRule(RelOptRuleOperand operand,
    * @param splunkRel Relational expression
    * @param topProj Top projection
    * @param bottomProj Bottom projection
+   *
+   * @return the rewritten scan, or null if a field rename involves a name
+   *     that cannot be safely embedded in SPL text (see
+   *     {@link #isSafeFieldName})
    */
-  protected RelNode appendSearchString(
+  protected @Nullable RelNode appendSearchString(
       String toAppend,
       SplunkTableScan splunkRel,
       LogicalProject topProj,
@@ -269,6 +299,16 @@ protected RelNode appendSearchString(
     }
 
     if (!renames.isEmpty()) {
+      // SPL provides no generic identifier-quoting mechanism, so refuse to
+      // push down a rename when either side would not be a safe SPL name
+      // (see isSafeFieldName). Returning null signals the caller to leave
+      // the original plan in place.
+      for (int r = 0; r < renames.size(); r++) {
+        if (!isSafeFieldName(renames.left(r))
+            || !isSafeFieldName(renames.right(r))) {
+          return null;
+        }
+      }
       updateSearchStr.append("| rename ");
       renames.forEach((left, right) ->
           updateSearchStr.append(left).append(" AS ")
@@ -309,7 +349,7 @@ private static RelNode addProjectionRule(LogicalProject 
proj, RelNode rel) {
   // TODO: refactor this to use more tree like parsing, need to also
   //      make sure we use parens properly - currently precedence
   //      rules are simply left to right
-  private static boolean getFilter(SqlOperator op, List<RexNode> operands,
+  static boolean getFilter(SqlOperator op, List<RexNode> operands,
       StringBuilder s, List<String> fieldNames) {
     if (!valid(op.getKind())) {
       return false;
@@ -364,6 +404,13 @@ private static boolean asd(boolean like, List<RexNode> 
operands, StringBuilder s
         }
         int fieldIndex = ((RexInputRef) operand).getIndex();
         String name = fieldNames.get(fieldIndex);
+        // Refuse pushdown when the field name is not a safe SPL name (see
+        // isSafeFieldName): SPL provides no generic identifier-quoting
+        // mechanism, so anything else could shift the boundaries of the
+        // emitted command.
+        if (!isSafeFieldName(name)) {
+          return false;
+        }
         s.append(name);
       } else { // RexLiteral
         String tmp = toString(like, (RexLiteral) operand);
diff --git 
a/splunk/src/test/java/org/apache/calcite/adapter/splunk/SplunkPushDownRuleTest.java
 
b/splunk/src/test/java/org/apache/calcite/adapter/splunk/SplunkPushDownRuleTest.java
new file mode 100644
index 0000000000..c3129d714e
--- /dev/null
+++ 
b/splunk/src/test/java/org/apache/calcite/adapter/splunk/SplunkPushDownRuleTest.java
@@ -0,0 +1,107 @@
+/*
+ * 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.splunk;
+
+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 com.google.common.collect.ImmutableList;
+
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.nullValue;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+/**
+ * Unit tests for the field-name allowlist inside
+ * {@link SplunkPushDownRule}. SPL has no generic identifier-quoting
+ * mechanism, so a field name that is copied verbatim into the generated
+ * search must match a strict allowlist; anything else could shift the
+ * boundaries of the emitted command, and the corresponding filter or
+ * rename is evaluated by Calcite instead.
+ *
+ * <p>The tests exercise the filter translation directly and do not
+ * require a running Splunk instance.
+ */
+class SplunkPushDownRuleTest {
+
+  private static final RelDataTypeFactory TYPE_FACTORY = new 
JavaTypeFactoryImpl();
+  private static final RexBuilder REX_BUILDER = new RexBuilder(TYPE_FACTORY);
+
+  /** Translates {@code <field> = 'foo'} via the rule's (private)
+   * {@code getFilter}, returning the SPL string, or null if the rule
+   * refuses the push down. */
+  private static String filterString(String fieldName) throws Throwable {
+    RelDataType varchar = TYPE_FACTORY.createSqlType(SqlTypeName.VARCHAR);
+    RexInputRef ref = REX_BUILDER.makeInputRef(varchar, 0);
+    RexNode lit = REX_BUILDER.makeLiteral("foo");
+    StringBuilder buf = new StringBuilder();
+    final boolean ok =
+        SplunkPushDownRule.getFilter(SqlStdOperatorTable.EQUALS, 
ImmutableList.of(ref, lit), buf,
+            ImmutableList.of(fieldName));
+    return ok ? buf.toString() : null;
+  }
+
+  @Test void plainFieldNamePushedDown() throws Throwable {
+    assertThat(filterString("source"), is("source = foo"));
+    assertThat(filterString("date_hour"), is("date_hour = foo"));
+  }
+
+  @Test void splunkStyleFieldNamesAccepted() {
+    // Real-world Splunk field-name shapes: the allowlist must not
+    // reject these, or legitimate pushdown would stop working.
+    assertThat(SplunkPushDownRule.isSafeFieldName("_raw"), is(true));
+    assertThat(SplunkPushDownRule.isSafeFieldName("props{}.value"), is(true));
+    assertThat(SplunkPushDownRule.isSafeFieldName("EXPR$0"), is(true));
+    assertThat(SplunkPushDownRule.isSafeFieldName("host-name"), is(true));
+    assertThat(SplunkPushDownRule.isSafeFieldName("a.b.c"), is(true));
+    assertThat(SplunkPushDownRule.isSafeFieldName("date_hour"), is(true));
+  }
+
+  @Test void aliasWithPipeNotPushedDown() throws Throwable {
+    // A pipe character starts a new SPL command; a rename containing
+    // one would chain unintended pipeline stages onto the emitted
+    // search. The rule must therefore refuse this push down.
+    assertThat(filterString("x | delete | search a"), nullValue());
+  }
+
+  @Test void namesWithSplMetacharactersRejected() {
+    // Characters that would let a rename fragment or field reference
+    // shift the boundaries of the emitted SPL command.
+    assertThat(SplunkPushDownRule.isSafeFieldName("a b"), is(false));
+    assertThat(SplunkPushDownRule.isSafeFieldName("a\"b"), is(false));
+    assertThat(SplunkPushDownRule.isSafeFieldName("a'b"), is(false));
+    assertThat(SplunkPushDownRule.isSafeFieldName("a=b"), is(false));
+    assertThat(SplunkPushDownRule.isSafeFieldName("a[b]"), is(false));
+    assertThat(SplunkPushDownRule.isSafeFieldName("a`b"), is(false));
+    assertThat(SplunkPushDownRule.isSafeFieldName("a\\b"), is(false));
+    assertThat(SplunkPushDownRule.isSafeFieldName("a,b"), is(false));
+    assertThat(SplunkPushDownRule.isSafeFieldName("(a)"), is(false));
+  }
+
+  @Test void nullAndEmptyRejected() {
+    assertThat(SplunkPushDownRule.isSafeFieldName(null), is(false));
+    assertThat(SplunkPushDownRule.isSafeFieldName(""), is(false));
+  }
+}

Reply via email to