This is an automated email from the ASF dual-hosted git repository.

xuzifu666 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 843f468405 [CALCITE-7733] Validate identifiers and escape LOAD path 
when generating Pig Latin scripts
843f468405 is described below

commit 843f4684059f3878995cfb3b7705841c95d971b9
Author: Yu Xu <[email protected]>
AuthorDate: Wed Oct 7 23:28:50 2026 +0800

    [CALCITE-7733] Validate identifiers and escape LOAD path when generating 
Pig Latin scripts
---
 .../apache/calcite/adapter/pig/PigAggregate.java   | 13 +++-
 .../org/apache/calcite/adapter/pig/PigFilter.java  |  2 +-
 .../org/apache/calcite/adapter/pig/PigRel.java     | 12 ++-
 .../apache/calcite/adapter/pig/PigTableScan.java   |  7 +-
 .../org/apache/calcite/adapter/pig/PigUtils.java   | 88 +++++++++++++++++++++
 .../apache/calcite/adapter/pig/PigUtilsTest.java   | 90 ++++++++++++++++++++++
 .../org/apache/calcite/test/AbstractPigTest.java   | 14 ++++
 .../org/apache/calcite/test/PigAdapterTest.java    | 62 ++++++++++++---
 pig/src/test/resources/data'quote.txt              |  2 +
 pig/src/test/resources/model.json                  | 27 +++++++
 10 files changed, 299 insertions(+), 18 deletions(-)

diff --git a/pig/src/main/java/org/apache/calcite/adapter/pig/PigAggregate.java 
b/pig/src/main/java/org/apache/calcite/adapter/pig/PigAggregate.java
index 742c82816b..8250ae32cd 100644
--- a/pig/src/main/java/org/apache/calcite/adapter/pig/PigAggregate.java
+++ b/pig/src/main/java/org/apache/calcite/adapter/pig/PigAggregate.java
@@ -22,7 +22,6 @@
 import org.apache.calcite.rel.RelNode;
 import org.apache.calcite.rel.core.Aggregate;
 import org.apache.calcite.rel.core.AggregateCall;
-import org.apache.calcite.rel.type.RelDataTypeField;
 import org.apache.calcite.util.ImmutableBitSet;
 
 import org.apache.pig.scripting.Pig;
@@ -96,14 +95,13 @@ private String getPigAggregateStatement(Implementor 
implementor) {
    */
   private String getPigGroupBy(Implementor implementor) {
     final String relAlias = implementor.getPigRelationAlias(this);
-    final List<RelDataTypeField> allFields = 
getInput().getRowType().getFieldList();
     final List<Integer> groupedFieldIndexes = groupSet.asList();
     if (groupedFieldIndexes.size() < 1) {
       return relAlias + " = GROUP " + relAlias + " ALL;";
     } else {
       final List<String> groupedFieldNames = new 
ArrayList<>(groupedFieldIndexes.size());
       for (int fieldIndex : groupedFieldIndexes) {
-        groupedFieldNames.add(allFields.get(fieldIndex).getName());
+        groupedFieldNames.add(getInputFieldName(fieldIndex));
       }
       return relAlias + " = GROUP " + relAlias + " BY ("
           + String.join(", ", groupedFieldNames) + ");";
@@ -156,6 +154,9 @@ private List<String> getPigAggregateCalls(Implementor 
implementor) {
   private String getPigAggregateCall(String relAlias, AggregateCall aggCall) {
     final PigAggFunction aggFunc = toPigAggFunc(aggCall);
     final String alias = aggCall.getName();
+    // The alias is used after AS, where it cannot be quoted or escaped in
+    // Pig Latin
+    PigUtils.checkValidIdentifier(alias);
     final String fields = String.join(", ", getArgNames(relAlias, aggCall));
     return aggFunc.name() + "(" + fields + ") AS " + alias;
   }
@@ -212,6 +213,10 @@ private List<String> getDistinctCalls(Implementor 
implementor) {
   }
 
   private String getInputFieldName(int fieldIndex) {
-    return getInput().getRowType().getFieldList().get(fieldIndex).getName();
+    final String fieldName =
+        getInput().getRowType().getFieldList().get(fieldIndex).getName();
+    // Field names cannot be quoted or escaped in Pig Latin
+    PigUtils.checkValidIdentifier(fieldName);
+    return fieldName;
   }
 }
diff --git a/pig/src/main/java/org/apache/calcite/adapter/pig/PigFilter.java 
b/pig/src/main/java/org/apache/calcite/adapter/pig/PigFilter.java
index cc6a8a8c0f..c57ae3f5ff 100644
--- a/pig/src/main/java/org/apache/calcite/adapter/pig/PigFilter.java
+++ b/pig/src/main/java/org/apache/calcite/adapter/pig/PigFilter.java
@@ -139,7 +139,7 @@ private static String getLiteralAsString(RexLiteral 
literal) {
     // Escape before wrapping
     return '\''
         + (raw != null
-            ? raw.replace("\\", "\\\\").replace("'", "\\'")
+            ? PigUtils.escapeStringLiteral(raw)
             : null)
         + '\'';
   }
diff --git a/pig/src/main/java/org/apache/calcite/adapter/pig/PigRel.java 
b/pig/src/main/java/org/apache/calcite/adapter/pig/PigRel.java
index 6776b47a70..3941cf29e3 100644
--- a/pig/src/main/java/org/apache/calcite/adapter/pig/PigRel.java
+++ b/pig/src/main/java/org/apache/calcite/adapter/pig/PigRel.java
@@ -56,7 +56,11 @@ class Implementor {
 
     public String getTableName(RelNode input) {
       final List<String> qualifiedName = input.getTable().getQualifiedName();
-      return qualifiedName.get(qualifiedName.size() - 1);
+      final String tableName = qualifiedName.get(qualifiedName.size() - 1);
+      // The table name is used as a relation alias, which cannot be quoted
+      // or escaped in Pig Latin
+      PigUtils.checkValidIdentifier(tableName);
+      return tableName;
     }
 
     public String getPigRelationAlias(RelNode input) {
@@ -64,7 +68,11 @@ public String getPigRelationAlias(RelNode input) {
     }
 
     public String getFieldName(RelNode input, int index) {
-      return input.getRowType().getFieldList().get(index).getName();
+      final String fieldName =
+          input.getRowType().getFieldList().get(index).getName();
+      // Field names cannot be quoted or escaped in Pig Latin
+      PigUtils.checkValidIdentifier(fieldName);
+      return fieldName;
     }
 
     public void addStatement(String statement) {
diff --git a/pig/src/main/java/org/apache/calcite/adapter/pig/PigTableScan.java 
b/pig/src/main/java/org/apache/calcite/adapter/pig/PigTableScan.java
index d68a424307..6ae89c9b0f 100644
--- a/pig/src/main/java/org/apache/calcite/adapter/pig/PigTableScan.java
+++ b/pig/src/main/java/org/apache/calcite/adapter/pig/PigTableScan.java
@@ -51,8 +51,11 @@ public PigTableScan(RelOptCluster cluster, RelTraitSet 
traitSet, RelOptTable tab
     final String alias = implementor.getPigRelationAlias(this);
     final String schema = '(' + getSchemaForPigStatement(implementor)
         + ')';
-    final String statement = alias + " = LOAD '" + pigTable.getFilePath()
-        + "' USING PigStorage() AS " + schema + ';';
+    // Escape the file path so that quotes or backslashes in it (e.g. in a
+    // Windows path) cannot break out of the string literal
+    final String statement = alias + " = LOAD "
+        + PigUtils.quoteStringLiteral(pigTable.getFilePath())
+        + " USING PigStorage() AS " + schema + ';';
     implementor.addStatement(statement);
   }
 
diff --git a/pig/src/main/java/org/apache/calcite/adapter/pig/PigUtils.java 
b/pig/src/main/java/org/apache/calcite/adapter/pig/PigUtils.java
new file mode 100644
index 0000000000..8a6e6ab0fe
--- /dev/null
+++ b/pig/src/main/java/org/apache/calcite/adapter/pig/PigUtils.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.adapter.pig;
+
+import java.util.regex.Pattern;
+
+/**
+ * Utility methods for generating Pig Latin scripts.
+ *
+ * <p>Unlike SQL, Pig Latin has no mechanism to quote identifiers, so names
+ * that do not match its identifier syntax cannot be represented in a script
+ * and must be rejected; see
+ * <a href="https://pig.apache.org/docs/r0.18.0/basic.html#identifiers";>
+ * Pig Latin identifiers</a>.
+ */
+public final class PigUtils {
+
+  /** Private constructor to prevent instantiation. */
+  private PigUtils() {
+  }
+
+  /**
+   * Matches a valid Pig Latin identifier: an ASCII letter followed by any
+   * number of letters, digits or underscores.
+   */
+  private static final Pattern IDENTIFIER =
+      Pattern.compile("[A-Za-z][A-Za-z0-9_]*");
+
+  /**
+   * Validates that the given name is a valid Pig Latin identifier, throwing
+   * if it is not.
+   *
+   * <p>Identifiers are used in generated scripts as relation aliases, field
+   * names and {@code AS} aliases, where they cannot be quoted or escaped;
+   * an invalid name would produce a script that Pig cannot parse.
+   *
+   * @param name Name to validate
+   * @throws IllegalArgumentException if {@code name} is not a valid Pig Latin
+   *                                   identifier
+   */
+  public static void checkValidIdentifier(String name) {
+    if (!IDENTIFIER.matcher(name).matches()) {
+      throw new IllegalArgumentException(
+          "Invalid Pig Latin identifier: '" + name + "'. Pig Latin identifiers"
+              + " must start with a letter, followed by letters, digits or"
+              + " underscores");
+    }
+  }
+
+  /**
+   * Escapes the given string for use inside a Pig Latin string literal.
+   *
+   * <p>Backslashes and single quotes are escaped with a backslash, so that
+   * the value cannot break out of the {@code '...'} literal or be 
re-interpreted
+   * as a Pig Latin escape sequence such as {@code \t} or {@code \u0001}.
+   *
+   * @param s String to escape
+   * @return the escaped string
+   */
+  public static String escapeStringLiteral(String s) {
+    return s.replace("\\", "\\\\").replace("'", "\\'");
+  }
+
+  /**
+   * Converts the given string to a Pig Latin string literal, escaping it
+   * first and then wrapping it in single quotes.
+   *
+   * @param s String to quote
+   * @return the quoted and escaped string literal
+   */
+  public static String quoteStringLiteral(String s) {
+    return "'" + escapeStringLiteral(s) + "'";
+  }
+}
diff --git a/pig/src/test/java/org/apache/calcite/adapter/pig/PigUtilsTest.java 
b/pig/src/test/java/org/apache/calcite/adapter/pig/PigUtilsTest.java
new file mode 100644
index 0000000000..e26bd97b1a
--- /dev/null
+++ b/pig/src/test/java/org/apache/calcite/adapter/pig/PigUtilsTest.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.adapter.pig;
+
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Unit tests for {@link PigUtils}.
+ */
+class PigUtilsTest {
+
+  @Test void validIdentifiersAccepted() {
+    // Examples of valid identifiers from the Pig Latin reference
+    for (String name : new String[] {"A", "A123", "abc_123_BeX_", "t", "tc0"}) 
{
+      assertDoesNotThrow(() -> PigUtils.checkValidIdentifier(name),
+          () -> "expected '" + name + "' to be accepted");
+    }
+  }
+
+  @Test void invalidIdentifiersRejected() {
+    // Examples of invalid identifiers from the Pig Latin reference, plus
+    // names that would be mis-parsed as Pig Latin operators if passed through
+    for (String name : new String[] {"_A123", "abc_$", "A!B", "1abc", "",
+        "a b", "a-b", "a;b", "a=b", "a.b", "EXPR$0"}) {
+      final IllegalArgumentException e =
+          assertThrows(IllegalArgumentException.class,
+              () -> PigUtils.checkValidIdentifier(name),
+              () -> "expected '" + name + "' to be rejected");
+      assertThat(
+          e.getMessage(), is("Invalid Pig Latin identifier: '" + name
+          + "'. Pig Latin identifiers must start with a letter, followed by"
+          + " letters, digits or underscores"));
+    }
+  }
+
+  @Test void plainValueQuoted() {
+    assertThat(PigUtils.quoteStringLiteral("alice"), is("'alice'"));
+  }
+
+  @Test void valueWithBackslash() {
+    assertThat(PigUtils.quoteStringLiteral("a\\b"), is("'a\\\\b'"));
+  }
+
+  @Test void valueWithBackslashBeforeApostrophe() {
+    assertThat(PigUtils.quoteStringLiteral("a\\'b"), is("'a\\\\\\'b'"));
+  }
+
+  @Test void valueWithTrailingBackslash() {
+    assertThat(PigUtils.quoteStringLiteral("a\\"), is("'a\\\\'"));
+  }
+
+  @Test void valueWithApostrophe() {
+    assertThat(PigUtils.quoteStringLiteral("O'Brien"), is("'O\\'Brien'"));
+  }
+
+  @Test void valueWithApostropheAtTheEnd() {
+    assertThat(PigUtils.quoteStringLiteral("a'"), is("'a\\''"));
+  }
+
+  @Test void valueWithApostropheAtTheStart() {
+    assertThat(PigUtils.quoteStringLiteral("'a"), is("'\\'a'"));
+  }
+
+  @Test void valueWithMultipleApostrophes() {
+    assertThat(PigUtils.quoteStringLiteral("a''b"), is("'a\\'\\'b'"));
+  }
+
+  @Test void emptyValue() {
+    assertThat(PigUtils.quoteStringLiteral(""), is("''"));
+  }
+}
diff --git a/pig/src/test/java/org/apache/calcite/test/AbstractPigTest.java 
b/pig/src/test/java/org/apache/calcite/test/AbstractPigTest.java
index e3abc3072b..4b7f651df2 100644
--- a/pig/src/test/java/org/apache/calcite/test/AbstractPigTest.java
+++ b/pig/src/test/java/org/apache/calcite/test/AbstractPigTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.calcite.test;
 
+import org.apache.calcite.adapter.pig.PigUtils;
 import org.apache.calcite.util.Sources;
 
 import java.net.URL;
@@ -32,4 +33,17 @@ protected String getFullPathForTestDataFile(String fileName) 
{
     requireNonNull(url, "url");
     return Sources.of(url).file().getAbsolutePath();
   }
+
+  /**
+   * Returns the full path of the given test data file, escaped for inclusion
+   * in a Pig Latin string literal, as in the {@code LOAD} statement of an
+   * expected script.
+   *
+   * <p>Paths are platform-dependent (a Windows path contains backslashes), so
+   * expected scripts must build them with the same escaping as
+   * {@link PigUtils} uses when generating scripts.
+   */
+  protected String getEscapedFullPathForTestDataFile(String fileName) {
+    return PigUtils.escapeStringLiteral(getFullPathForTestDataFile(fileName));
+  }
 }
diff --git a/pig/src/test/java/org/apache/calcite/test/PigAdapterTest.java 
b/pig/src/test/java/org/apache/calcite/test/PigAdapterTest.java
index 18a61e468b..08c3178174 100644
--- a/pig/src/test/java/org/apache/calcite/test/PigAdapterTest.java
+++ b/pig/src/test/java/org/apache/calcite/test/PigAdapterTest.java
@@ -52,7 +52,7 @@ class PigAdapterTest extends AbstractPigTest {
         .runs()
         .queryContains(
             pigScriptChecker("t = LOAD '"
-                + getFullPathForTestDataFile("data.txt")
+                + getEscapedFullPathForTestDataFile("data.txt")
                 + "' USING PigStorage() AS (tc0:chararray, tc1:chararray);\n"
                 + "t = FILTER t BY (tc0 > 'abc');"));
   }
@@ -67,11 +67,55 @@ class PigAdapterTest extends AbstractPigTest {
         .runs()
         .queryContains(
             pigScriptChecker("t = LOAD '"
-                + getFullPathForTestDataFile("data.txt")
+                + getEscapedFullPathForTestDataFile("data.txt")
                 + "' USING PigStorage() AS (tc0:chararray, tc1:chararray);\n"
                 + "t = FILTER t BY (tc0 == 'a\\'b');"));
   }
 
+  @Test void testLoadPathWithSingleQuote() {
+    // A LOAD path containing a single quote must be escaped per Pig Latin
+    // string-literal rules so it does not break out of the '...' literal in
+    // the generated LOAD statement
+    CalciteAssert.that()
+        .with(MODEL)
+        .query("select * from \"tq\"")
+        .runs()
+        .queryContains(
+            pigScriptChecker("tq = LOAD '"
+                + getEscapedFullPathForTestDataFile("data'quote.txt")
+                + "' USING PigStorage() AS (tqc0:chararray, 
tqc1:chararray);"));
+  }
+
+  @Test void testInvalidTableName() {
+    // A table name is used as a relation alias, which cannot be quoted or
+    // escaped in Pig Latin, so script generation is rejected instead of
+    // producing a script that Pig cannot parse
+    CalciteAssert.that()
+        .with(MODEL)
+        .query("select * from \"bad-name\"")
+        .throws_("Invalid Pig Latin identifier: 'bad-name'");
+  }
+
+  @Test void testInvalidColumnName() {
+    // Field names cannot be quoted or escaped in Pig Latin, so script
+    // generation is rejected instead of producing a script that Pig cannot
+    // parse
+    CalciteAssert.that()
+        .with(MODEL)
+        .query("select * from \"badcolumn\"")
+        .throws_("Invalid Pig Latin identifier: 'bad$column'");
+  }
+
+  @Test void testUnaliasedAggregateCall() {
+    // 'EXPR$0' (the name Calcite derives for an unaliased aggregate call) is
+    // not a valid Pig Latin identifier and cannot be used after AS; the query
+    // has to be re-written with an explicit alias instead
+    CalciteAssert.that()
+        .with(MODEL)
+        .query("select count(\"tc0\") from \"t\"")
+        .throws_("Invalid Pig Latin identifier: 'EXPR$0'");
+  }
+
   @Test void testImplWithMultipleFilters() {
     CalciteAssert.that()
         .with(MODEL)
@@ -82,7 +126,7 @@ class PigAdapterTest extends AbstractPigTest {
         .runs()
         .queryContains(
             pigScriptChecker("t = LOAD '"
-                + getFullPathForTestDataFile("data.txt")
+                + getEscapedFullPathForTestDataFile("data.txt")
                 + "' USING PigStorage() AS (tc0:chararray, tc1:chararray);\n"
                 + "t = FILTER t BY (tc0 > 'abc') AND (tc1 == '3');"));
   }
@@ -97,7 +141,7 @@ class PigAdapterTest extends AbstractPigTest {
         .runs()
         .queryContains(
             pigScriptChecker("t = LOAD '"
-                + getFullPathForTestDataFile("data.txt")
+                + getEscapedFullPathForTestDataFile("data.txt")
                 + "' USING PigStorage() AS (tc0:chararray, tc1:chararray);\n"
                 + "t = GROUP t BY (tc0);\n"
                 + "t = FOREACH t {\n"
@@ -115,7 +159,7 @@ class PigAdapterTest extends AbstractPigTest {
         .runs()
         .queryContains(
             pigScriptChecker("t = LOAD '"
-                + getFullPathForTestDataFile("data.txt")
+                + getEscapedFullPathForTestDataFile("data.txt")
                 + "' USING PigStorage() AS (tc0:chararray, tc1:chararray);\n"
                 + "t = GROUP t ALL;\n"
                 + "t = FOREACH t {\n"
@@ -133,7 +177,7 @@ class PigAdapterTest extends AbstractPigTest {
         .runs()
         .queryContains(
             pigScriptChecker("t = LOAD '"
-                + getFullPathForTestDataFile("data.txt")
+                + getEscapedFullPathForTestDataFile("data.txt")
                 + "' USING PigStorage() AS (tc0:chararray, tc1:chararray);\n"
                 + "t = GROUP t BY (tc0, tc1);\n"
                 + "t = FOREACH t {\n"
@@ -151,7 +195,7 @@ class PigAdapterTest extends AbstractPigTest {
         .runs()
         .queryContains(
             pigScriptChecker("t = LOAD '"
-                + getFullPathForTestDataFile("data.txt")
+                + getEscapedFullPathForTestDataFile("data.txt")
                 + "' USING PigStorage() AS (tc0:chararray, tc1:chararray);\n"
                 + "t = GROUP t BY (tc1);\n"
                 + "t = FOREACH t {\n"
@@ -173,9 +217,9 @@ class PigAdapterTest extends AbstractPigTest {
         .runs()
         .queryContains(
             pigScriptChecker("t = LOAD '"
-                + getFullPathForTestDataFile("data.txt")
+                + getEscapedFullPathForTestDataFile("data.txt")
                 + "' USING PigStorage() AS (tc0:chararray, tc1:chararray);\n"
-                + "s = LOAD '" + getFullPathForTestDataFile("data2.txt")
+                + "s = LOAD '" + getEscapedFullPathForTestDataFile("data2.txt")
                 + "' USING PigStorage() AS (sc0:chararray, sc1:chararray);\n"
                 + "t = JOIN t BY tc1 , s BY sc0;"));
   }
diff --git a/pig/src/test/resources/data'quote.txt 
b/pig/src/test/resources/data'quote.txt
new file mode 100644
index 0000000000..2398dad264
--- /dev/null
+++ b/pig/src/test/resources/data'quote.txt
@@ -0,0 +1,2 @@
+a      1
+b      2
diff --git a/pig/src/test/resources/model.json 
b/pig/src/test/resources/model.json
index 851888e9af..05bd5a9b66 100644
--- a/pig/src/test/resources/model.json
+++ b/pig/src/test/resources/model.json
@@ -40,6 +40,33 @@
             "file": "data2.txt",
             "columns": ["sc0", "sc1"]
           }
+        },
+        {
+          "name": "tq",
+          "type": "custom",
+          "factory": "org.apache.calcite.adapter.pig.PigTableFactory",
+          "operand": {
+            "file": "data'quote.txt",
+            "columns": ["tqc0", "tqc1"]
+          }
+        },
+        {
+          "name": "bad-name",
+          "type": "custom",
+          "factory": "org.apache.calcite.adapter.pig.PigTableFactory",
+          "operand": {
+            "file": "data.txt",
+            "columns": ["bc0"]
+          }
+        },
+        {
+          "name": "badcolumn",
+          "type": "custom",
+          "factory": "org.apache.calcite.adapter.pig.PigTableFactory",
+          "operand": {
+            "file": "data.txt",
+            "columns": ["bad$column"]
+          }
         }
       ]
     }

Reply via email to