cloud-fan commented on code in PR #59267:
URL: https://github.com/apache/spark/pull/59267#discussion_r4284981637


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/resolver/AsOfJoinResolver.scala:
##########
@@ -124,11 +132,62 @@ class AsOfJoinResolver(
         )
         cteRegistry.currentScope.tryPutWithCTE(
           unresolvedOperator = unresolvedAsOfJoin,
-          resolvedOperator = resolvedJoin
+          resolvedOperator = resolvedJoinWithOriginalOutput
         )
     }
   }
 
+  /**
+   * If the join uses a hidden column, wraps it in a [[Project]] that also 
keeps the hidden columns.
+   */
+  private def retainOriginalOutputIfHiddenColumnIsReferenced(
+      unresolvedAsOfJoin: AsOfJoin,
+      resolvedJoin: AsOfJoin,
+      leftNameScope: NameScope,
+      rightNameScope: NameScope): LogicalPlan = {
+    val originalOutput = AsOfJoin.computeOutput(
+      joinType = resolvedJoin.joinType,
+      leftOutput = leftNameScope.output,
+      rightOutput = rightNameScope.output
+    )
+    val originalOutputSet = AttributeSet(originalOutput)
+
+    if (resolvedJoin.references.subsetOf(originalOutputSet)) {
+      resolvedJoin
+    } else {
+      val qualifiedAccessOnlyColumns = AsOfJoin.computeOutput(
+        joinType = resolvedJoin.joinType,
+        leftOutput = leftNameScope.hiddenOutput.filter(_.qualifiedAccessOnly),
+        rightOutput = rightNameScope.hiddenOutput.filter(_.qualifiedAccessOnly)
+      )
+
+      operatorResolutionContextStack.current.baseOperator = Some(resolvedJoin)
+      Project(
+        withQualifiedAccessOnlyColumns(

Review Comment:
   **Nit (P3):** In the single-pass analyzer, this appends the 
qualified-access-only hidden columns whenever the AsOfJoin itself is not tagged 
`TOP_LEVEL_OPERATOR`, and only the plan root gets that tag 
(`Resolver.scala:230`). The helper's scaladoc relies on `PruneMetadataColumns` 
to drop the unused ones. That pass starts from an empty needed set, and generic 
operators forward only their own references (`PruneMetadataColumns.scala:49, 
127`). A Project reached with an empty set keeps its whole list (lines 97-111). 
So when every operator between the root and the wrapper references nothing, 
`d.k` survives:
   
   ```sql
   FROM using_left l ASOF JOIN (using_right r LEFT JOIN using_keys d USING (k))
     MATCH_CONDITION (l.lts >= d.k)
   LIMIT 1
   ```
   
   The root here is `GlobalLimit(LocalLimit(...))`. Single-pass outputs `(k, 
lts, k, rts, k)`, while fixed-point outputs `(k, lts, k, rts)`, the 
FVT-ASOF-2-040 shape. `FROM (<same join>) AS s` and bare-`FROM` `UNION ALL` 
operands behave the same way. Under dual-run, 
`HybridAnalyzer.validateLogicalPlans` fails on the schema mismatch. With 
single-pass alone, the extra column reaches the result. The fixed-point path 
returns the right columns.
   
   The JoinResolver and ASOF USING Projects already have this append-then-prune 
gap at the merge base, for example `FROM a JOIN b USING (k) LIMIT 1`. So the 
root cause is `PruneMetadataColumns`' root handling rather than this call site, 
and this PR makes it reachable from `MATCH_CONDITION`. This answers the earlier 
aliased-bare-`FROM` question; I confirmed it by tracing the source, not by 
running it.
   
   This extends an [existing 
discussion](https://github.com/apache/spark/pull/59267#pullrequestreview-5518239445).
   
   **Recommended change:** Make PruneMetadataColumns seed its traversal at each 
plan root with the ExprIds of the root's output attributes that are not 
qualified-access-only, instead of an empty set. A Project reached through 
reference-free pass-through ancestors then prunes the appended 
qualified-access-only columns that no ancestor needs, the same as one reached 
through a Project or Aggregate parent. Add SQL golden coverage to the ASOF JOIN 
and USING join test families for bare-FROM shapes under LIMIT/OFFSET and a root 
alias.
   
   **Why this works:** Under TOP_LEVEL_OPERATOR, producers never append 
qualified-access-only columns to the root itself. Per the PruneMetadataColumns 
doc, any qualified-access-only entry in a Project list is an artificial append, 
and the root output's user-visible columns are never qualified-access-only. 
Seeding the needed set with the root's visible output ExprIds therefore keeps 
every user-visible column and lets the non-empty-set branch drop each 
artificial append that no operator above references. Generic pass-through 
operators keep forwarding the parent set plus their own references, so the 
wrapper below GlobalLimit, SubqueryAlias, or Union sees a non-empty set without 
d.k and prunes it, matching fixed-point's Project(node.output).
   
   **Scope:** Single-pass metadata-column pruning learns to recognize the real 
root. ASOF JOIN and USING join golden tests gain bare-FROM shapes with 
reference-free ancestors.
   
   **Compatibility:** User-visible root columns, the non-empty-needed-set 
pruning behavior for Project and Aggregate parents, and every fixed-point plan.
   
   **Risks:** Single-pass plans change for existing NATURAL/USING joins under 
reference-free roots. These currently disagree with fixed-point, so the change 
restores parity, but any golden that happened to encode the leaked column would 
need regeneration and review. A root output that is legitimately 
qualified-access-only would be pruned. Per the PruneMetadataColumns doc this 
should not happen, because resolution strips the flag from selected columns, 
but the seed must not drop non-qualified-access-only root columns.
   
   **Constraints:** Fixed-point behavior and the results of the default path 
must not change. Existing single-pass plans that already match fixed-point, 
including every FVT-ASOF-2-027..041 golden, must stay identical. Pruning must 
stay a single top-down pass over each root with no extra plan traversals.
   
   **Success:** `FROM using_left l ASOF JOIN (using_right r LEFT JOIN 
using_keys d USING (k)) MATCH_CONDITION (l.lts >= d.k) LIMIT 1` and `FROM 
(<same ASOF JOIN>) AS s` produce the schema (k, lts, k, rts) in single-pass, 
and dual-run reports no schema or plan mismatch. A bare-FROM USING join under 
LIMIT produces the same single-pass schema as fixed-point. Queries whose 
ancestors reference columns keep their current single-pass plans.
   
   **Verification:**
   - **Regression:** Under dual-run, an ASOF JOIN with a hidden MATCH_CONDITION 
operand in a bare FROM with LIMIT, and the same join under a root table alias, 
resolve with identical single-pass and fixed-point schemas and plans, and the 
hidden key does not appear in the output.
   - **Regression:** Under dual-run, a bare-FROM NATURAL or USING join under 
LIMIT or OFFSET produces a schema that matches fixed-point.
   - **Compatibility:** Regenerated ASOF JOIN and NATURAL/USING join goldens 
for queries with referencing ancestors are byte-identical to the current ones.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/resolver/AsOfJoinResolver.scala:
##########
@@ -124,11 +132,62 @@ class AsOfJoinResolver(
         )
         cteRegistry.currentScope.tryPutWithCTE(
           unresolvedOperator = unresolvedAsOfJoin,
-          resolvedOperator = resolvedJoin
+          resolvedOperator = resolvedJoinWithOriginalOutput
         )
     }
   }
 
+  /**
+   * If the join uses a hidden column, wraps it in a [[Project]] that also 
keeps the hidden columns.
+   */
+  private def retainOriginalOutputIfHiddenColumnIsReferenced(
+      unresolvedAsOfJoin: AsOfJoin,
+      resolvedJoin: AsOfJoin,
+      leftNameScope: NameScope,
+      rightNameScope: NameScope): LogicalPlan = {
+    val originalOutput = AsOfJoin.computeOutput(
+      joinType = resolvedJoin.joinType,
+      leftOutput = leftNameScope.output,
+      rightOutput = rightNameScope.output
+    )
+    val originalOutputSet = AttributeSet(originalOutput)
+
+    if (resolvedJoin.references.subsetOf(originalOutputSet)) {
+      resolvedJoin
+    } else {
+      val qualifiedAccessOnlyColumns = AsOfJoin.computeOutput(

Review Comment:
   **Nit (P3):** Under `LEFT ASOF JOIN`, this `computeOutput` call is the only 
thing that makes the appended hidden right-side columns nullable, and no 
current test exercises it. FVT-ASOF-2-041's `d.k` comes from a nested `LEFT 
JOIN`, so it is nullable already. No parent reads it either, so 
`PruneMetadataColumns` removes it from the wrapper (the golden shows `Project 
[k, lts, k, rts]`). Every case that reads a hidden key above the join is an 
inner ASOF JOIN: FVT-ASOF-2-031..034, FVT-039, and the new `AsOfJoinSQLSuite` 
tests. FVT-041 does cover the widening of the visible right-side columns. But 
if this hidden-column widening were dropped, for example to plain `leftHidden 
++ rightHidden`, no golden, `checkAnswer`, or dual-run comparison would change. 
The single-pass plan would then declare `d.k` NOT NULL over the NULLs produced 
for unmatched left rows.
   
   A case that would catch it needs a hidden key that is NOT NULL below the 
join and read above it:
   
   ```sql
   SELECT l.lts, d.k
   FROM using_left l LEFT ASOF JOIN (using_right r JOIN using_keys d USING (k))
     MATCH_CONDITION (l.k <= d.k)
   ```
   
   `l.k = 8` has no match, so `d.k` comes back NULL. The parent's `d.k` 
reference is already widened through the scope's hidden output, so dual-run 
would flag an unwidened wrapper attribute.
   
   This extends an [existing 
discussion](https://github.com/apache/spark/pull/59267#discussion_r4271050826).
   
   **Verification:**
   - **Behavior:** A LEFT ASOF JOIN whose MATCH_CONDITION uses a NOT NULL 
hidden key, read by the parent, returns the expected rows, including NULL for 
unmatched left rows, and its schema reports the column as nullable.
   - **Regression:** Removing the join-type widening from the wrapper's 
appended hidden columns makes this case fail under dual-run.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/resolver/RetainsOriginalJoinOutput.scala:
##########
@@ -127,10 +127,30 @@ trait RetainsOriginalJoinOutput {
             referencedAttributes = childReferencedAttributes
           ) =>
         Project(scopes.current.output, join)
+      case asOfJoin: AsOfJoin

Review Comment:
   Thanks, confirmed at 1c23257. AsOfJoinResolver now adds the restoring 
Project itself, and buildUsingProject puts the USING Project on top of it. 
FVT-ASOF-2-030 shows Project(using) <- Project [k, lts, k, rts] <- AsOfJoin, 
which matches the fixed-point plan.
   
   <!-- SPARK_DEV_REVIEW_REPLY 
{"feedback_id":"inline:4257662381","thread_id":"inline:4257662381","verdict_sha256":"345736d3e98c123cab0949b6b6406ec4758f4c7d7f1ebcc67c52b399d935e133"}
 -->



##########
sql/core/src/test/scala/org/apache/spark/sql/AsOfJoinSQLSuite.scala:
##########
@@ -157,6 +157,69 @@ class AsOfJoinSQLSuite extends QueryTest with 
SharedSparkSession {
           stop = 137)))
   }
 
+  // Tests run both analyzers and compare their plans (dualRunWithLegacy), so 
checkAnswer also
+  // covers the single-pass analyzer. LEFT JOIN leaves d.k NULL on some rows, 
so a match on the
+  // visible k column gives a different answer.
+  test("MATCH_CONDITION accepts a hidden USING column of the right input") {

Review Comment:
   Thanks, confirmed. FVT-ASOF-2-031 and 033 to 038 now cover a parent that 
selects the hidden key, plus GROUP BY, WHERE, count(*), alias, and join-input 
parents. Since the wrap moved into AsOfJoinResolver, the parent-specific path 
this comment was about is also gone.
   
   <!-- SPARK_DEV_REVIEW_REPLY 
{"feedback_id":"inline:4257662392","thread_id":"inline:4257662392","verdict_sha256":"345736d3e98c123cab0949b6b6406ec4758f4c7d7f1ebcc67c52b399d935e133"}
 -->



-- 
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