HeartSaVioR commented on code in PR #59909:
URL: https://github.com/apache/spark/pull/59909#discussion_r4419731803


##########
common/utils/src/main/resources/error/error-conditions.json:
##########
@@ -2350,6 +2350,50 @@
     ],
     "sqlState" : "KD010"
   },
+  "DATA_SOURCE_EXTERNAL_LOOKUP_NOT_SUPPORTED" : {
+    "message" : [
+      "<description>: External lookup is not supported."
+    ],
+    "sqlState" : "0A000"
+  },
+  "DATA_SOURCE_INVALID_EXTERNAL_LOOKUP" : {
+    "message" : [
+      "Data source table <table> violates the external lookup contract:"
+    ],
+    "subClass" : {
+      "DUPLICATE_SCAN_FIELD" : {
+        "message" : [
+          "The lookup scan returned the field <field> more than once."
+        ]
+      },
+      "DUPLICATE_SELECTED_PREDICATE" : {
+        "message" : [
+          "`selectLookupPredicates()` returned the predicate <predicate> more 
than once."
+        ]
+      },
+      "NON_POSITIVE_MAX_PARAMETER_ROWS" : {
+        "message" : [
+          "`maxParameterRowsPerRequest()` returned <value>, but it must be 
positive."
+        ]
+      },
+      "NULL_PREDICATE_SELECTION" : {
+        "message" : [
+          "`selectLookupPredicates()` returned null or an array containing 
null."
+        ]
+      },
+      "UNKNOWN_SCAN_FIELD" : {
+        "message" : [
+          "The lookup scan returned the field <field>, which is not in the 
table output."

Review Comment:
   > [!NOTE]
   > **DISCLAIMER:** This comment was written by an LLM, Claude Opus 5.5 
(`claude-opus-5-5`, 1M context), posting on behalf of @HeartSaVioR.
   
   Fixed in 
[73b62b7c559](https://github.com/apache/spark/commit/73b62b7c559b90caf5ac9088417679591118c5d5).
 The message now reads "The lookup scan returned the field <field>, which 
matches no table column by name and data type." The suite tests the 
type-mismatch case (`value INT` for a `STRING` column).
   



##########
common/utils/src/main/resources/error/error-conditions.json:
##########
@@ -2817,6 +2861,59 @@
     ],
     "sqlState" : "42822"
   },
+  "EXTERNAL_LOOKUP_JOIN_UNSUPPORTED" : {
+    "message" : [
+      "The join cannot be executed as an external lookup join:"
+    ],
+    "subClass" : {
+      "CONFLICTING_JOIN_STRATEGY" : {
+        "message" : [
+          "The join strategy hint <strategy> cannot be honored by an external 
lookup join over <relation>. Remove the <strategy> hint, or the 
`EXTERNAL_LOOKUP` hint if the table also supports batch reads."
+        ]
+      },
+      "JOIN_TYPE" : {
+        "message" : [
+          "The join type <joinType> is not supported by an external lookup 
join over <relation>. Supported join types are <supportedJoinTypes>."
+        ]
+      },
+      "LOOKUP_PLAN" : {
+        "message" : [
+          "The lookup side must be a DSv2 table, optionally followed by 
projections and filters."
+        ]
+      },
+      "LOOKUP_TABLE_ON_LEFT" : {
+        "message" : [
+          "The lookup-only table <relation> must be on the right side of the 
join."
+        ]
+      },
+      "MISSING_JOIN_CONDITION" : {
+        "message" : [
+          "The join has no join condition."
+        ]
+      },
+      "NO_LOOKUP_PREDICATE" : {
+        "message" : [
+          "The data source of table <table> did not select any lookup 
predicate from the join condition."

Review Comment:
   > [!NOTE]
   > **DISCLAIMER:** This comment was written by an LLM, Claude Opus 5.5 
(`claude-opus-5-5`, 1M context), posting on behalf of @HeartSaVioR.
   
   Fixed in 
[73b62b7c559](https://github.com/apache/spark/commit/73b62b7c559b90caf5ac9088417679591118c5d5),
 using the second option. When no conjunct has the form `lookupColumn = 
<stream-side expression>` with a bare lookup column, the rule fails with the 
new `EXTERNAL_LOOKUP_JOIN_UNSUPPORTED.NO_LOOKUP_CANDIDATE` before it calls the 
connector, including before `newScanBuilder`. The message names the accepted 
form. It also says that when the lookup column is cast to match the stream 
side, the stream-side expression should be cast to the lookup column's type 
instead. `NO_LOOKUP_PREDICATE` is now raised only when the connector selects 
none of a non-empty offer, and its text says the predicates were offered. The 
test joins a BIGINT stream key with an INT lookup column (`s.id = CAST(t.id AS 
BIGINT)`) through a stub that fails the test if its selection is called.
   



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/PrepareExternalLookupJoinSuite.scala:
##########
@@ -0,0 +1,120 @@
+/*
+ * 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.spark.sql.connector
+
+import org.apache.spark.SparkException
+import org.apache.spark.sql.AnalysisException
+import org.apache.spark.sql.QueryTest
+import org.apache.spark.sql.catalyst.analysis.ResolveExternalLookupJoin
+import org.apache.spark.sql.catalyst.expressions.{And, AttributeReference, 
EqualTo, Literal}
+import org.apache.spark.sql.catalyst.plans.{FullOuter, Inner, LeftOuter}
+import org.apache.spark.sql.catalyst.plans.logical.{Join, JoinHint, 
LocalRelation,
+  PreparedExternalLookupJoin, Project}
+import org.apache.spark.sql.connector.catalog.{SupportsRead, Table, 
TableCapability}
+import org.apache.spark.sql.connector.catalog.TableCapability.EXTERNAL_LOOKUP
+import org.apache.spark.sql.connector.expressions.FieldReference
+import org.apache.spark.sql.connector.expressions.filter.Predicate
+import org.apache.spark.sql.connector.read.{ExternalLookup, 
ExternalLookupReaderFactory, Scan,
+  ScanBuilder, SupportsExternalLookup}
+import org.apache.spark.sql.execution.datasources.v2.{DataSourceV2Relation,
+  PrepareExternalLookupJoin}
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.{IntegerType, StructType}
+import org.apache.spark.sql.util.CaseInsensitiveStringMap
+
+class PrepareExternalLookupJoinSuite extends QueryTest with SharedSparkSession 
{

Review Comment:
   > [!NOTE]
   > **DISCLAIMER:** This comment was written by an LLM, Claude Opus 5.5 
(`claude-opus-5-5`, 1M context), posting on behalf of @HeartSaVioR.
   
   Fixed in 
[73b62b7c559](https://github.com/apache/spark/commit/73b62b7c559b90caf5ac9088417679591118c5d5).
 The suite now extends `PlanTest` and doesn't start a session.
   



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/PrepareExternalLookupJoinSuite.scala:
##########
@@ -0,0 +1,120 @@
+/*
+ * 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.spark.sql.connector
+
+import org.apache.spark.SparkException
+import org.apache.spark.sql.AnalysisException
+import org.apache.spark.sql.QueryTest
+import org.apache.spark.sql.catalyst.analysis.ResolveExternalLookupJoin
+import org.apache.spark.sql.catalyst.expressions.{And, AttributeReference, 
EqualTo, Literal}
+import org.apache.spark.sql.catalyst.plans.{FullOuter, Inner, LeftOuter}
+import org.apache.spark.sql.catalyst.plans.logical.{Join, JoinHint, 
LocalRelation,
+  PreparedExternalLookupJoin, Project}
+import org.apache.spark.sql.connector.catalog.{SupportsRead, Table, 
TableCapability}
+import org.apache.spark.sql.connector.catalog.TableCapability.EXTERNAL_LOOKUP
+import org.apache.spark.sql.connector.expressions.FieldReference
+import org.apache.spark.sql.connector.expressions.filter.Predicate
+import org.apache.spark.sql.connector.read.{ExternalLookup, 
ExternalLookupReaderFactory, Scan,
+  ScanBuilder, SupportsExternalLookup}
+import org.apache.spark.sql.execution.datasources.v2.{DataSourceV2Relation,
+  PrepareExternalLookupJoin}
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.{IntegerType, StructType}
+import org.apache.spark.sql.util.CaseInsensitiveStringMap
+
+class PrepareExternalLookupJoinSuite extends QueryTest with SharedSparkSession 
{
+
+  test("a lookup-only relation that no lookup join reads is rejected") {
+    val left = LocalRelation(Seq(AttributeReference("id", IntegerType)()), 
isStreaming = true)
+    val relation = DataSourceV2Relation.create(
+      PlanningExternalLookupTable, None, None, CaseInsensitiveStringMap.empty)
+    val lookupJoin = ResolveExternalLookupJoin(Join(
+      left, relation, Inner, Some(EqualTo(left.output.head, 
relation.output.head)), JoinHint.NONE))
+
+    
assert(PrepareExternalLookupJoin(lookupJoin).isInstanceOf[PreparedExternalLookupJoin])
+    checkError(
+      exception = intercept[AnalysisException] {
+        PrepareExternalLookupJoin(Project(relation.output, relation))
+      },
+      condition = "UNSUPPORTED_FEATURE.TABLE_OPERATION",
+      parameters = Map(
+        "tableName" -> "`PlanningExternalLookupTable`",
+        "operation" -> "batch scan"))
+  }
+
+  test("PrepareExternalLookupJoin binds the selected predicates and keeps the 
rest as residual") {
+    val left = LocalRelation(Seq(AttributeReference("id", IntegerType)()), 
isStreaming = true)
+    val relation = DataSourceV2Relation.create(
+      PlanningExternalLookupTable, None, None, CaseInsensitiveStringMap.empty)
+    val Seq(id, value) = relation.output
+    // Offered to the connector as `value = ?`, but not selected.
+    val unselected = EqualTo(value, Literal("a"))
+    val join = Join(left, relation, LeftOuter, 
Some(And(EqualTo(left.output.head, id), unselected)),
+      JoinHint.NONE)
+
+    val prepared = PrepareExternalLookupJoin(ResolveExternalLookupJoin(join))
+    prepared match {
+      case lookup: PreparedExternalLookupJoin =>
+        assert(lookup.lookupParameters === left.output)
+        assert(lookup.residualCondition === Some(unselected))
+        assert(lookup.output === join.output)
+        checkError(
+          exception = intercept[SparkException] { lookup.copy(joinType = 
FullOuter).output },
+          condition = "INTERNAL_ERROR",
+          parameters = Map("message" -> "Unexpected external lookup join type 
FullOuter"))
+      case other => fail(s"Expected a prepared external lookup join: $other")
+    }
+    assert(PrepareExternalLookupJoin(prepared) === prepared)
+  }
+
+  test("a join without a condition over a lookup-only table fails on the final 
plan") {
+    val left = LocalRelation(Seq(AttributeReference("id", IntegerType)()), 
isStreaming = true)
+    val relation = DataSourceV2Relation.create(
+      PlanningExternalLookupTable, None, None, CaseInsensitiveStringMap.empty)
+    // Analysis keeps such a join, because the Dataset API analyzes a USING 
join first as one.
+    checkError(
+      exception = intercept[AnalysisException] {
+        PrepareExternalLookupJoin(ResolveExternalLookupJoin(
+          Join(left, relation, Inner, None, JoinHint.NONE)))
+      },
+      condition = "EXTERNAL_LOOKUP_JOIN_UNSUPPORTED.MISSING_JOIN_CONDITION",
+      parameters = Map.empty)
+  }
+}
+
+/** A lookup-only table that the planning rules can prepare. It is never read. 
*/
+object PlanningExternalLookupTable extends Table with SupportsRead {

Review Comment:
   > [!NOTE]
   > **DISCLAIMER:** This comment was written by an LLM, Claude Opus 5.5 
(`claude-opus-5-5`, 1M context), posting on behalf of @HeartSaVioR.
   
   Partly addressed in 
[73b62b7c559](https://github.com/apache/spark/commit/73b62b7c559b90caf5ac9088417679591118c5d5).
 The table stub now takes a selection function and a read schema. The suite 
uses it to cover several candidates selected in a different order than offered, 
an unselected candidate kept in the residual in written order, a reordered read 
schema with weaker top-level and nested nullability, columns that differ only 
by case, a missing column, a type mismatch, a condition with no offerable 
candidate, an empty selection, and a null selection or a null element. Binding 
parameters in offered order, removing the null-selection check, or reordering 
the residual now fails a test. The other contract errors are already raised end 
to end with their parameters in SPARK-60690's `ExternalLookupJoinSuite` 
(`UNOFFERED_SELECTED_PREDICATE`, `DUPLICATE_SELECTED_PREDICATE`, 
`NON_POSITIVE_MAX_PARAMETER_ROWS`, `UNKNOWN_SCAN_FIELD`, 
`DUPLICATE_SCAN_FIELD`, `TABLE`, `SCAN_BUILDER`, `NO_LOOKUP_P
 REDICATE`), so they aren't repeated here. The scan description is redacted by 
the physical node, which is added and tested in SPARK-60690.
   



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/analysis/ResolveExternalLookupJoinSuite.scala:
##########
@@ -0,0 +1,120 @@
+/*
+ * 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.spark.sql.catalyst.analysis
+
+import scala.jdk.CollectionConverters._
+
+import org.apache.spark.SparkException
+import org.apache.spark.sql.AnalysisException
+import org.apache.spark.sql.catalyst.dsl.expressions._
+import org.apache.spark.sql.catalyst.plans.{Cross, FullOuter, Inner, 
LeftOuter, RightOuter}
+import org.apache.spark.sql.catalyst.plans.logical.{BROADCAST, 
ExternalLookupJoin, HintInfo, Join,
+  JoinHint, JoinStrategyHint, LocalRelation, LogicalPlan, SHUFFLE_HASH, 
SHUFFLE_MERGE,
+  SHUFFLE_REPLICATE_NL}
+import org.apache.spark.sql.connector.catalog.{Table, TableCapability}
+import org.apache.spark.sql.connector.catalog.TableCapability.{BATCH_READ, 
EXTERNAL_LOOKUP}
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation
+import org.apache.spark.sql.types.StructType
+import org.apache.spark.sql.util.CaseInsensitiveStringMap
+
+class ResolveExternalLookupJoinSuite extends AnalysisTest {
+  // The stream side of a stream-static join.
+  private val left = LocalRelation(Seq($"tenant".int, $"id".int), isStreaming 
= true)
+  private val lookupOnly = relation(EXTERNAL_LOOKUP)
+
+  private def relation(capabilities: TableCapability*): DataSourceV2Relation =
+    DataSourceV2Relation.create(

Review Comment:
   > [!NOTE]
   > **DISCLAIMER:** This comment was written by an LLM, Claude Opus 5.5 
(`claude-opus-5-5`, 1M context), posting on behalf of @HeartSaVioR.
   
   Partly addressed in 
[73b62b7c559](https://github.com/apache/spark/commit/73b62b7c559b90caf5ac9088417679591118c5d5).
 Both suites now use `ExternalLookupRelation` as the lookup side. In 
`PrepareExternalLookupJoinSuite`, that covers its classification, its 
unwrapping in `extractLookupPlan`, and the rejection of a stray 
`ExternalLookupRelation` outside a lookup join. A new test runs `ColumnPruning` 
over a projection of stream columns above the lookup join. It checks that the 
stream side is pruned, the lookup relation stays directly under the join, and 
the join still prepares. Without `requiredLookupOutput` in the references, that 
test fails. The hint tests now run the analyzer, so a strategy hint is a 
`ResolvedHint` node above the stream side, as it is when the conversion runs. 
Both the analysis-time check and the conversion raise 
`CONFLICTING_JOIN_STRATEGY` for it. A new test puts a `broadcast` hint inside a 
nested join on the stream side and checks that the lookup join still forms, 
 because the hint belongs to the nested join.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to