Skip to content

[SPARK-58089][SQL][FOLLOWUP] Preserve join extraction semantics - #57956

Open
cloud-fan wants to merge 2 commits into
apache:masterfrom
cloud-fan:SPARK-58089-followup
Open

[SPARK-58089][SQL][FOLLOWUP] Preserve join extraction semantics#57956
cloud-fan wants to merge 2 commits into
apache:masterfrom
cloud-fan:SPARK-58089-followup

Conversation

@cloud-fan

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

Followup to #57190.

This patch preserves the semantics of variant extractions moved through joins:

  • A strict variant_get crosses a join only when cast-error deferral is enabled.
  • Project attributes above a copied outer join are remapped to the join's actual output so nullable-side attributes retain their widened nullability.

Why are the changes needed?

Moving a strict extraction below a join can evaluate it on rows that the join later eliminates, exposing an error that the original plan would not raise. In addition, copying an outer join widens attributes on its nullable side, while the parent Project could retain references with stale pre-join nullability.

Does this PR introduce any user-facing change?

No. This fixes followup issues in an unreleased change on master.

How was this patch tested?

Added regression tests and ran:

build/sbt 'sql/testOnly org.apache.spark.sql.execution.datasources.PushVariantIntoScanSuite -- -z "left outer join: projected right-side attributes use widened nullability"'
build/sbt 'sql/testOnly org.apache.spark.sql.execution.datasources.PushVariantIntoScanSuite -- -z "strict variant_get crosses a join only with cast-error deferral"'

The outer-join nullability test was also verified to fail before the production fix and pass after it.

Was this patch authored or co-authored using generative AI tooling?

Generated-by: OpenAI Codex (GPT-5).

Prevent strict variant_get expressions from crossing joins unless cast-error deferral is enabled. Also remap projected attributes to copied outer-join outputs so nullable-side attributes retain the widened nullability established by the join.
@cloud-fan

Copy link
Copy Markdown
Contributor Author

cc @qlong @peter-toth

@uros-b

uros-b commented Aug 12, 2026

Copy link
Copy Markdown
Member

LGTM

case _ => false
}

private def isJoinHoistable(e: Expression): Boolean = {

@qlong qlong Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason to add this new gate? isJoinHoistable gate will not hoist a strict variant_get across a Join unless PUSH_VARIANT_INTO_SCAN_DEFER_CAST_ERROR is set. The ungated behavior is documented between line 108-122, and is more consistent with existing bahaviors. It seems this gate singles out variant for cross a join while allowing the other two cases to raise:

  1. variant_get below a Filter → raises
  2. cast(v as int) across a Join → raises (test @844, pre-existing base-PR behavior)
  3. variant_get across a Join → suppressed by the gate (test @881, new here)

I think it's good that all three have consistent behavior. We also have existing switches:

  • pushVariantIntoScan.pullOutExtractions = false to turn off the pull-out rule completely
  • pushVariantIntoScan.deferCastError = true to defer the error past eliminated rows uniformly for every strict extraction

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I replaced the VariantGet-specific gate with the generic Expression.throwable check. VariantGet and Cast are not currently classified as throwable, so this preserves the existing behavior for both while making the rule ready to honor that metadata if we classify them in the future. I also removed the new test that singled out strict variant_get.

@qlong qlong Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 on using throwable, it is cleaner. Minor suggestion, please feel free to ignore. I am a bit worried there is no clear signal for hoist behavior change when someone classifies Cast and VariantGet as throwable in the future. Should we add a unit test to assert those two are throwable and add some comment there?

condition = newCondition)
project.copy(projectList = newProjectList, child = newJoin)
// Outer joins widen attributes on their nullable side. Remap references in the parent
// Project to the copied Join's actual output so their nullability matches the child.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 on nullablity fix

@qlong qlong left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

case _ => false
}

private def isJoinHoistable(e: Expression): Boolean = {

@qlong qlong Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 on using throwable, it is cleaner. Minor suggestion, please feel free to ignore. I am a bit worried there is no clear signal for hoist behavior change when someone classifies Cast and VariantGet as throwable in the future. Should we add a unit test to assert those two are throwable and add some comment there?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants