Repository navigation
[SPARK-59146][SQL] Retain qualified access to source columns affected by pipe SET #58760
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
fb466db
d092103
262b18b
5545d0d
fa812e6
e0ae807
b7dc511
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -48,7 +48,7 @@ import org.apache.spark.sql.catalyst.trees.AlwaysProcess | |
| import org.apache.spark.sql.catalyst.trees.CurrentOrigin.withOrigin | ||
| import org.apache.spark.sql.catalyst.trees.TreePattern._ | ||
| import org.apache.spark.sql.catalyst.types.DataTypeUtils | ||
| import org.apache.spark.sql.catalyst.util.{toPrettySQL, trimTempResolvedColumn, CharVarcharUtils, GeneratedColumn} | ||
| import org.apache.spark.sql.catalyst.util.{toPrettySQL, trimTempResolvedColumn, CharVarcharUtils, GeneratedColumn, MetadataColumnHelper} | ||
| import org.apache.spark.sql.catalyst.util.ResolveDefaultColumns._ | ||
| // `View` is aliased to `V2View` to avoid clashing with the logical-plan `View` imported via | ||
| // `org.apache.spark.sql.catalyst.plans.logical._`. | ||
|
|
@@ -1163,28 +1163,34 @@ class Analyzer( | |
| } | ||
|
|
||
| private def getMetadataAttributes(plan: LogicalPlan): Seq[Attribute] = { | ||
| val childrenMetadataOutput = childrenMetadataOutputById(plan) | ||
| plan.expressions.flatMap(_.collect { | ||
| case a: Attribute if a.isMetadataCol => a | ||
| case a: Attribute | ||
| if plan.children.exists(c => c.metadataOutput.exists(_.exprId == a.exprId)) => | ||
| plan.children.collectFirst { | ||
| case c if c.metadataOutput.exists(_.exprId == a.exprId) => | ||
| c.metadataOutput.find(_.exprId == a.exprId).get | ||
| }.get | ||
| case a: Attribute if childrenMetadataOutput.contains(a.exprId) => | ||
| childrenMetadataOutput(a.exprId) | ||
| }) | ||
| } | ||
|
|
||
| private def hasMetadataCol(plan: LogicalPlan): Boolean = { | ||
| lazy val childrenMetadataOutput = childrenMetadataOutputById(plan) | ||
| plan.expressions.exists(_.exists { | ||
| case a: Attribute => | ||
| // If an attribute is resolved before being labeled as metadata | ||
| // (i.e. from the originating Dataset), we check with expression ID | ||
| a.isMetadataCol || | ||
| plan.children.exists(c => c.metadataOutput.exists(_.exprId == a.exprId)) | ||
| a.isMetadataCol || childrenMetadataOutput.contains(a.exprId) | ||
| case _ => false | ||
| }) | ||
| } | ||
|
|
||
| // The metadata output of the children, keeping the first attribute for each expression ID. | ||
| // Looking attributes up by ID keeps this rule linear when a child's metadata output is as wide | ||
| // as its output, as the hidden source row of the SQL pipe SET operator is. | ||
| private def childrenMetadataOutputById(plan: LogicalPlan): collection.Map[ExprId, Attribute] = { | ||
| val byId = mutable.HashMap.empty[ExprId, Attribute] | ||
| plan.children.foreach(_.metadataOutput.foreach(a => byId.getOrElseUpdate(a.exprId, a))) | ||
| byId | ||
| } | ||
|
|
||
| private def addMetadataCol( | ||
| plan: LogicalPlan, | ||
| requiredAttrIds: Set[ExprId], | ||
|
|
@@ -1783,6 +1789,7 @@ class Analyzer( | |
| if (expanded.projectList.size < p.projectList.size) { | ||
| checkTrailingCommaInSelect(expanded, starRemoved = true) | ||
| } | ||
| retainExceptedColumnsAsHiddenOutput(p, expanded) | ||
| expanded | ||
| // If the filter list contains Stars, expand it. | ||
| case p: Filter if containsStar(Seq(p.condition)) => | ||
|
|
@@ -2182,6 +2189,50 @@ class Analyzer( | |
| }.map(_.asInstanceOf[NamedExpression]) | ||
| } | ||
|
|
||
| /** | ||
| * The SQL pipe SET operator is implemented as a star expansion that excludes the assigned | ||
| * column and appends a replacement of the same name. That drops the original attribute from | ||
| * the project list, which would also make it unreachable through its table alias, contradicting | ||
| * the documented behavior that table aliases keep referring to the original row values after an | ||
| * assignment. Retain the excluded attributes as hidden output instead, the same way USING joins | ||
| * hide their duplicated join keys (SPARK-59146). | ||
| * | ||
| * The hidden output holds the whole qualified source row, with every position in its original | ||
| * order, not just the excluded attributes, because a qualified star expands hidden output | ||
| * ahead of the visible output. Retaining only the excluded attributes would move them to the | ||
| * front, so that `VALUES (1, 2, 3) AS t(a, b, c) |> SET b = 20 |> SELECT t.*` would return | ||
| * `(b, a, c)`. Attributes that are also visible are emitted once by the star expansion, in | ||
| * their hidden position. The Project does not hide them, so they are also recorded in | ||
| * [[Project.visibleHiddenOutputTag]], which keeps them regular in DataFrame column resolution. | ||
| */ | ||
| private def retainExceptedColumnsAsHiddenOutput(original: Project, expanded: Project): Unit = { | ||
| val retain = original.projectList.exists { | ||
| case s: UnresolvedStarExceptOrReplace => s.retainExceptedColumnsAsHidden | ||
| case _ => false | ||
| } | ||
| if (retain) { | ||
| val child = expanded.child | ||
| if (child.output.exists(!expanded.outputSet.contains(_))) { | ||
| // The row a qualified star sees on the child, keeping every one of its positions. Only | ||
| // qualified attributes are reachable through a qualified star. | ||
| val sourceRow = UnresolvedStarBase | ||
| .mergeHiddenAndVisibleOutput( | ||
| child.metadataOutput.filter(_.qualifiedAccessOnly), child.output) | ||
| .filter(_.qualifier.nonEmpty) | ||
| val sourceRowIds = sourceRow.map(_.exprId).toSet | ||
| // The rule forwards the original tags, such as the Spark Connect plan id, only onto a | ||
| // node without tags, so copy them here before adding the hidden output tag. | ||
| expanded.copyTagsFrom(original) | ||
| expanded.setTagValue( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking (P2): This tag becomes correctness-critical across analyzer iterations: See Shared repair plan 1 in the review body.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The preference would be to retain |
||
| Project.hiddenOutputTag, | ||
| sourceRow.map(_.markAsQualifiedAccessOnly()) ++ | ||
| child.metadataOutput.filterNot(a => sourceRowIds.contains(a.exprId))) | ||
| expanded.setTagValue( | ||
| Project.visibleHiddenOutputTag, sourceRow.filter(expanded.outputSet.contains)) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Returns true if `exprs` contains a [[Star]]. | ||
| */ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Non-blocking (P2):
transformUpWithPruningnormally forwards the originalProjecttags after this rewrite, butcopyTagsFromonly does so when the destination tag map is empty. SettinghiddenOutputTaghere first makes the map nonempty, so a Spark Connect relation rooted at pipeSETlosesPLAN_ID_TAG; DataFrame columns bound to that relation can then fail withCANNOT_RESOLVE_DATAFRAME_COLUMN. Please preserve or merge the original project's tags when adding this tag.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks a lot @cloud-fan for catching both the issues. I've addressed them in d092103.
Moved
copyTagsFromto run before setting hiddenOutputTag. Previously, copying tags afterwards was overwritingPLAN_ID_TAG.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Verified on the current commit: the original Project tags are copied before hiddenOutputTag is installed, and the focused analyzer test preserves both PLAN_ID_TAG and hiddenOutputTag. This issue is resolved.