This is an automated email from the ASF dual-hosted git repository.
mihaibudiu 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 0c28ff9627 [CALCITE-7654] Lambda functions handle incorrectly field
accesses
0c28ff9627 is described below
commit 0c28ff962775c2d03273b7094fc92589bfb3ae6a
Author: Mihai Budiu <[email protected]>
AuthorDate: Mon Jul 13 20:12:12 2026 -0700
[CALCITE-7654] Lambda functions handle incorrectly field accesses
Signed-off-by: Mihai Budiu <[email protected]>
---
.../org/apache/calcite/sql/fun/SqlDotOperator.java | 8 ++++
.../calcite/sql/validate/SqlLambdaScope.java | 21 +++++-----
.../calcite/sql/validate/SqlValidatorImpl.java | 8 ++++
.../calcite/sql/validate/SqlValidatorUtil.java | 21 ++++++++++
.../org/apache/calcite/test/SqlValidatorTest.java | 49 ++++++++++++++++++++++
5 files changed, 97 insertions(+), 10 deletions(-)
diff --git a/core/src/main/java/org/apache/calcite/sql/fun/SqlDotOperator.java
b/core/src/main/java/org/apache/calcite/sql/fun/SqlDotOperator.java
index 7c9a231579..9d9749c3e5 100644
--- a/core/src/main/java/org/apache/calcite/sql/fun/SqlDotOperator.java
+++ b/core/src/main/java/org/apache/calcite/sql/fun/SqlDotOperator.java
@@ -107,6 +107,14 @@ public class SqlDotOperator extends SqlSpecialOperator {
return validator.getTypeFactory().createTypeWithNullability(nodeType,
true);
}
+ if (nodeType.getSqlTypeName() == SqlTypeName.ANY
+ && SqlValidatorUtil.inLambdaWithUntypedParameters(scope)) {
+ // The lambda parameters' types are not known until the enclosing call
+ // has inferred its operand types. Field resolution happens when the
+ // lambda type checker re-validates the lambda body.
+ return validator.getTypeFactory().createTypeWithNullability(nodeType,
true);
+ }
+
if (!nodeType.isStruct()) {
throw SqlUtil.newContextException(operand.getParserPosition(),
Static.RESOURCE.incompatibleTypes());
diff --git
a/core/src/main/java/org/apache/calcite/sql/validate/SqlLambdaScope.java
b/core/src/main/java/org/apache/calcite/sql/validate/SqlLambdaScope.java
index 0db4b45711..a4ba93d56a 100644
--- a/core/src/main/java/org/apache/calcite/sql/validate/SqlLambdaScope.java
+++ b/core/src/main/java/org/apache/calcite/sql/validate/SqlLambdaScope.java
@@ -62,16 +62,17 @@ public boolean isParameter(SqlIdentifier id) {
}
@Override public SqlQualified fullyQualify(SqlIdentifier identifier) {
- if (identifier.isSimple()) {
- final SqlNameMatcher nameMatcher = validator.catalogReader.nameMatcher();
- final String name = identifier.getSimple();
- boolean found = lambdaExpr.getParameters()
- .stream()
- .anyMatch(param ->
- nameMatcher.matches(((SqlIdentifier) param).getSimple(), name));
- if (found) {
- return SqlQualified.create(this, 1, null, identifier);
- }
+ final SqlNameMatcher nameMatcher = validator.catalogReader.nameMatcher();
+ final String name = identifier.names.get(0);
+ boolean found = lambdaExpr.getParameters()
+ .stream()
+ .anyMatch(param ->
+ nameMatcher.matches(((SqlIdentifier) param).getSimple(), name));
+ if (found) {
+ // If the first component names a parameter, in a compound identifier
+ // such as 'x.name' the remaining components are fields of the
+ // parameter's struct.
+ return SqlQualified.create(this, 1, null, identifier);
}
if (!validator.config().conformance().allowLambdaClosure()) {
throw validator.newValidationError(identifier,
diff --git
a/core/src/main/java/org/apache/calcite/sql/validate/SqlValidatorImpl.java
b/core/src/main/java/org/apache/calcite/sql/validate/SqlValidatorImpl.java
index 5d667fe502..023de1b77f 100644
--- a/core/src/main/java/org/apache/calcite/sql/validate/SqlValidatorImpl.java
+++ b/core/src/main/java/org/apache/calcite/sql/validate/SqlValidatorImpl.java
@@ -8034,6 +8034,14 @@ private class DeriveTypeVisitor implements
SqlVisitor<RelDataType> {
// Resolve rest of identifier
for (; i < id.names.size(); i++) {
+ if (type.getSqlTypeName() == SqlTypeName.ANY
+ && SqlValidatorUtil.inLambdaWithUntypedParameters(scope)) {
+ // The lambda parameters' types are not known until the enclosing
+ // call has inferred its operand types. Field resolution happens
+ // when the lambda type checker re-validates the lambda body.
+ return typeFactory.createTypeWithNullability(
+ typeFactory.createSqlType(SqlTypeName.ANY), true);
+ }
String name = id.names.get(i);
final RelDataTypeField field;
if (name.isEmpty()) {
diff --git
a/core/src/main/java/org/apache/calcite/sql/validate/SqlValidatorUtil.java
b/core/src/main/java/org/apache/calcite/sql/validate/SqlValidatorUtil.java
index 1c5bfcf9a6..3b67e5bbc3 100644
--- a/core/src/main/java/org/apache/calcite/sql/validate/SqlValidatorUtil.java
+++ b/core/src/main/java/org/apache/calcite/sql/validate/SqlValidatorUtil.java
@@ -57,6 +57,7 @@
import org.apache.calcite.sql.SqlUtil;
import org.apache.calcite.sql.fun.SqlStdOperatorTable;
import org.apache.calcite.sql.parser.SqlParserPos;
+import org.apache.calcite.sql.type.SqlTypeName;
import org.apache.calcite.sql.type.SqlTypeUtil;
import org.apache.calcite.util.ImmutableBitSet;
import org.apache.calcite.util.Litmus;
@@ -1678,6 +1679,26 @@ private static class ExplicitTableSchema extends
AbstractSchema {
}
}
+ /** Returns whether {@code scope} is enclosed in the scope of a lambda
+ * expression whose parameter types have not yet been inferred.
+ *
+ * <p>Un-inferred parameters have type ANY, and so does any expression
+ * computed from them, whatever its shape; field resolution on such
+ * values must be deferred until the enclosing call has inferred the
+ * parameter types and re-validates the lambda body (see
+ * {@link SqlValidator#validateLambda}). */
+ public static boolean inLambdaWithUntypedParameters(SqlValidatorScope scope)
{
+ for (SqlValidatorScope s = scope; s instanceof DelegatingScope;
+ s = ((DelegatingScope) s).getParent()) {
+ if (s instanceof SqlLambdaScope
+ && ((SqlLambdaScope) s).getParameterTypes().values().stream()
+ .anyMatch(t -> t.getSqlTypeName() == SqlTypeName.ANY)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
/** Flattens any FILTER, WITHIN DISTINCT, WITHIN GROUP surrounding a call to
* an aggregate function. */
public static class FlatAggregate {
diff --git a/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java
b/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java
index 228d90026b..cecb57385e 100644
--- a/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java
+++ b/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java
@@ -8776,6 +8776,55 @@ void testGroupExpressionEquivalenceParams() {
+ "reference to 'EMP.DEPTNO' from enclosing scope");
}
+ /** Test case for
+ * <a
href="https://issues.apache.org/jira/browse/CALCITE-7654">[CALCITE-7654]
+ * Lambda functions handle incorrectly field accesses</a>. */
+ @Test void testLambdaStructFieldAccess() {
+ SqlOperatorTable chain =
+ SqlOperatorTables.chain(
+ SqlOperatorTables.of(SqlLibraryOperators.EXISTS),
+ SqlStdOperatorTable.instance());
+ final SqlValidatorFixture s = fixture().withOperatorTable(chain);
+ // EMPLOYEES is ARRAY<ROW(EMPNO, ENAME, DETAIL)>.
+ // Parenthesized form: parses as DOT(e, empno).
+ s.withSql("select \"EXISTS\"(employees, e -> (e).empno > 0)\n"
+ + "from dept_nested")
+ .columnType("BOOLEAN");
+ // Unparenthesized form: parses as compound identifier e.empno.
+ s.withSql("select \"EXISTS\"(employees, e -> e.empno > 0)\n"
+ + "from dept_nested")
+ .columnType("BOOLEAN");
+ // Nested field access.
+ s.withSql("select \"EXISTS\"(employees, e -> (e).detail.skills is not
null)\n"
+ + "from dept_nested")
+ .columnType("BOOLEAN");
+ // Field access through an array index: DETAIL.SKILLS is
+ // ARRAY<ROW(TYPE, DESC, OTHERS)>.
+ s.withSql("select \"EXISTS\"(employees,\n"
+ + " e -> (e).detail.skills[1].\"TYPE\" = 'a')\n"
+ + "from dept_nested")
+ .columnType("BOOLEAN");
+ // Same, starting from a compound identifier.
+ s.withSql("select \"EXISTS\"(employees,\n"
+ + " e -> e.detail.skills[1].\"TYPE\" = 'a')\n"
+ + "from dept_nested")
+ .columnType("BOOLEAN");
+ // A field that does not exist in the parameter's type.
+ s.withSql("select \"EXISTS\"(employees, e -> e.^bad^ > 0)\n"
+ + "from dept_nested")
+ .fails("Unknown field 'BAD'");
+ // An unknown field behind an array index produces an error.
+ s.withSql("select \"EXISTS\"(employees,\n"
+ + " e -> e.detail.skills[1].^bad^ = 'a')\n"
+ + "from dept_nested")
+ .fails("Unknown field 'BAD'");
+ // Field access on an expression that is not a simple access chain.
+ s.withSql("select \"EXISTS\"(employees,\n"
+ + " e -> coalesce((e).detail, e.detail).skills is not null)\n"
+ + "from dept_nested")
+ .columnType("BOOLEAN");
+ }
+
/** Test case for <a
href="https://issues.apache.org/jira/browse/CALCITE-7193">[CALCITE-7193]
* In an aggregation validator treats lambda variable names as column
names</a>. */
@Test void testGroupByLambda() {