This is an automated email from the ASF dual-hosted git repository.
cloud-fan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new bd65ed7ce121 [SPARK-57499][SQL] Fix column pruning and invalid plans
in variant extraction pushdown on DSv2 scans
bd65ed7ce121 is described below
commit bd65ed7ce121ef3fa38b7b6dd7e3581c1555d4f1
Author: Qiegang Long <[email protected]>
AuthorDate: Mon Jun 22 09:58:22 2026 -0700
[SPARK-57499][SQL] Fix column pruning and invalid plans in variant
extraction pushdown on DSv2 scans
### What changes were proposed in this pull request?
Three fixes in `pushVariantExtractions` (called
by`V2ScaqqnRelationPushDown.pushDownVariants`):
1. **Guard against double-visit**: Add a `pushedVariants.isEmpty`
sentinel check so the inner `ScanBuilderHolder` leaf visit (caused by
`transformDown` recursing into the child after returning the plan
unchanged) returns immediately. This ensures
`builder.pushVariantExtractions` is called exactly once per holder.
2. **Eager column pruning**: While `projectList` and `filters` are in
scope, call `builder.pruneColumns(requiredSchema)` for builders
implementing `SupportsPushDownRequiredColumns` and trim
`sHolder.output` to the required columns. By the time
`buildScanWithPushedVariants` calls `build()`, the builder already
has the correct pruned schema. This is similiar to how
buildScanWithPushedAggregate works.
3. **Keep whole-variant reads raw**: pushdown is for extractions, not
whole-variant reads. A bare variant reference -- `SELECT v`, or a
column lifted to feed a `variant_get` above a Join/Sort/Aggregate
barrier the local rewrite cannot see -- is recorded as `fullVariant`
(path `$`), meaning "the entire value." Shredding that to a lone
full-variant slot saves no I/O and is mishandled: the Parquet reader
collapses it to a boolean placeholder, and above a barrier the
re-exposed `GetStructField AS v#orig` alias is dropped by
`RemoveRedundantAliases`, giving wrong results or an invalid plan. So
when fullVariant is a variant's only requested field, leave the
column raw; when it coexists with real extractions (e.g. `SELECT v,
variant_get(v, '$.a')`) the >=2-slot struct is not collapsed and
keeps its pushdown. This subsumes the join-key case and shreds
automatically once barrier-aware pushdown makes the `variant_get`
visible as a typed path.
Jira: https://issues.apache.org/jira/browse/SPARK-57499
### Why are the changes needed?
Three bugs on the accepted variant pushdown path:
**Issue 1 (Performance) — column pruning is skipped.**
`buildScanWithPushedVariants` calls
`builder.build()` and replaces the `ScanBuilderHolder` with a
`DataSourceV2ScanRelation`.
The subsequent `pruneColumns` rule matches only `ScanBuilderHolder` nodes,
so it is a
no-op and `builder.pruneColumns()` is never called. The scan reads the full
table schema
including unreferenced columns. For unreferenced `VARIANT` columns this is
especially
costly — each is fully reconstructed from its shredded Parquet tree on
every row.
**Issue 2 (Correctness) — invalid plan / crash on tables with >=2 VARIANT
columns**
pushDownVariants uses transformDown, which recurses into the child
ScanBuilderHolder after returning the plan unchanged. The bare
ScanBuilderHolder matches PhysicalOperation a second time, collecting an
unreferenced sibling VARIANT column as a full-variant request and pushing it to
the builder again. ParquetScanBuilder overwrites its state on every call, so
the second push clobbers the correct extraction from the first. The rewritten
scan then emits a fresh ExprId for the variant whil [...]
This affects any extraction shape — projection, ORDER BY, aggregate, join —
on a table with two or more VARIANT columns. Single-VARIANT tables are
unaffected.
Reproduce on stock spark-4.1.x (path-based views force DSv2):
```
SET spark.sql.sources.useV1SourceList = "";
CREATE TABLE t (a INT, v1 VARIANT, v2 VARIANT) USING PARQUET LOCATION
'/tmp/vt';
INSERT INTO t VALUES
(1, parse_json('{"x":1,"price":3,"name":"x"}'), parse_json('{"y":2}')),
(2, parse_json('{"x":9,"price":1,"name":"z"}'), parse_json('{"y":8}'));
CREATE OR REPLACE TEMPORARY VIEW tv USING parquet OPTIONS (path
'/tmp/vt');
CREATE OR REPLACE TEMPORARY VIEW codes USING parquet OPTIONS (path
'/tmp/vt');
-- All four crash with: [INTERNAL_ERROR_ATTRIBUTE_NOT_FOUND] Could not find
v1#NN in [...]
SELECT variant_get(v1, '$.x', 'int') FROM tv;
SELECT variant_get(v1,'$.name','string') AS nm
FROM tv ORDER BY variant_get(v1,'$.price','int');
SELECT max(variant_get(v1, '$.price', 'int')) FROM tv;
SELECT l.a FROM tv l JOIN codes r
ON variant_get(l.v1,'$.x','int') = variant_get(r.v1,'$.x','int');
[INTERNAL_ERROR_ATTRIBUTE_NOT_FOUND] Could not find v1#21 in
[a#33,v1#34,v2#35]. SQLSTATE: XX000
at
org.apache.spark.sql.catalyst.expressions.BindReferences$.attributeNotFoundException(BoundAttribute.scala:109)
...
```
**Issue 3 (Correctness) -- wrong results / crash when a whole-variant read
is shredded**. A bare
variant reference (a plain `SELECT v`, or a column lifted to feed a
variant_get
above a Join/Sort/Aggregate barrier the local rewrite cannot see) is
recorded as a
full-variant request (path "$"). Shredding it to a lone full-variant slot
is both
useless (the whole value is read regardless) and mishandled:
- The Parquet reader collapses a lone VariantType slot to a boolean
placeholder,
so ORDER BY variant_get(v, '$.price') sorts on the placeholder and
silently
returns the wrong order, and max(variant_get(v, '$.price')) fails to
codegen.
- A join key is re-exposed above the join as GetStructField(v_new, i) AS
v#orig;
RemoveRedundantAliases collapses the alias and the condition references
a
dropped ExprId, failing plan validation.
This issue is masked on stock 4.1 by the Issue 2 binding crash, which fails
first; it surfaces only after that issue is fixed, which is why it has no repro
for stock 4.1. See new unit tests for join, order by, aggregrate.
### Does this PR introduce _any_ user-facing change?
No
### How was this patch tested?
- Added new unit tests
- manual testing with spark-sql
### Was this patch authored or co-authored using generative AI tooling?
Co-authored with Claude code (Sonnet 4.6)
Closes #56556 from qlong/SPARK-57499-variant-pushdown-column-pruning.
Authored-by: Qiegang Long <[email protected]>
Signed-off-by: Wenchen Fan <[email protected]>
---
.../datasources/v2/V2ScanRelationPushDown.scala | 123 ++-
.../datasources/PushVariantIntoScanSuite.scala | 898 +++++++++++++++++++++
2 files changed, 1006 insertions(+), 15 deletions(-)
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanRelationPushDown.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanRelationPushDown.scala
index 2b291bf3a4db..42afb513f406 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanRelationPushDown.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanRelationPushDown.scala
@@ -34,12 +34,12 @@ import
org.apache.spark.sql.catalyst.types.DataTypeUtils.toAttributes
import org.apache.spark.sql.connector.expressions.{SortOrder => V2SortOrder}
import org.apache.spark.sql.connector.expressions.aggregate.{Aggregation, Avg,
Count, CountStar, Max, Min, Sum}
import org.apache.spark.sql.connector.expressions.filter.Predicate
-import org.apache.spark.sql.connector.read.{Scan, ScanBuilder,
SupportsPushDownAggregates, SupportsPushDownFilters, SupportsPushDownJoin,
SupportsPushDownVariantExtractions, V1Scan, VariantExtraction}
+import org.apache.spark.sql.connector.read.{Scan, ScanBuilder,
SupportsPushDownAggregates, SupportsPushDownFilters, SupportsPushDownJoin,
SupportsPushDownRequiredColumns, SupportsPushDownVariantExtractions, V1Scan,
VariantExtraction}
import org.apache.spark.sql.execution.datasources.{DataSourceStrategy,
VariantInRelation, VariantMetadata}
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.internal.connector.VariantExtractionImpl
import org.apache.spark.sql.sources
-import org.apache.spark.sql.types.{DataType, DecimalType, IntegerType,
StringType, StructField, StructType}
+import org.apache.spark.sql.types.{DataType, DecimalType, IntegerType,
StringType, StructField, StructType, VariantType}
import org.apache.spark.sql.util.SchemaUtils._
import org.apache.spark.util.ArrayImplicits._
import org.apache.spark.util.Utils
@@ -358,11 +358,30 @@ object V2ScanRelationPushDown extends Rule[LogicalPlan]
with PredicateHelper {
case agg: Aggregate => rewriteAggregate(agg)
}
- def pushDownVariants(plan: LogicalPlan): LogicalPlan = plan.transformDown {
- case p@PhysicalOperation(projectList, filters, sHolder @
ScanBuilderHolder(_, _,
- builder: SupportsPushDownVariantExtractions))
- if
conf.getConf(org.apache.spark.sql.internal.SQLConf.PUSH_VARIANT_INTO_SCAN) =>
- pushVariantExtractions(p, projectList, filters, sHolder, builder)
+ // Two-visit protocol explanation:
+ // `transformDown` is pre-order and always recurses into children, because
+ // `pushVariantExtractions` returns `originalPlan` unchanged (same object
reference).
+ // This means a tree like Project(pl, ScanBuilderHolder) gets visited twice:
+ // (1) Outer visit: PhysicalOperation collapses Project->ScanBuilderHolder
and
+ // yields the real (projectList, filters, sHolder). This is the
authoritative
+ // visit where column pruning decisions are made.
+ // (2) Inner leaf visit: PhysicalOperation on a bare ScanBuilderHolder
LeafNode
+ // yields (sHolder.output, Nil, sHolder) -- projectList = full schema
output.
+ // Without a guard, this would add fullVariant for *every* variant
column.
+ //
+ // The `pushedVariants.isEmpty` guard prevents the inner visit from
re-running once
+ // the outer visit has completed (successfully or not). An empty-mapping
sentinel
+ // `Some(new VariantInRelation())` is written by the outer visit when there
is
+ // nothing to push; `buildScanWithPushedVariants` requires
`mapping.nonEmpty` to
+ // fire, so the sentinel correctly bypasses it while still suppressing the
inner visit.
+ def pushDownVariants(plan: LogicalPlan): LogicalPlan = {
+ plan.transformDown {
+ case p@PhysicalOperation(projectList, filters, sHolder @
ScanBuilderHolder(_, _,
+ builder: SupportsPushDownVariantExtractions))
+ if
conf.getConf(org.apache.spark.sql.internal.SQLConf.PUSH_VARIANT_INTO_SCAN) &&
+ sHolder.pushedVariants.isEmpty =>
+ pushVariantExtractions(p, projectList, filters, sHolder, builder)
+ }
}
/**
@@ -418,14 +437,48 @@ object V2ScanRelationPushDown extends Rule[LogicalPlan]
with PredicateHelper {
for ((a, defaultValue) <- schemaAttributes.zip(defaultValues)) {
variants.addVariantFields(a.exprId, a.dataType, defaultValue, Nil)
}
- if (variants.mapping.isEmpty) return originalPlan
+ if (variants.mapping.isEmpty) {
+ // No variant columns in the schema. Mark as attempted so the guard in
+ // pushDownVariants prevents a spurious second visit on the leaf node.
+ sHolder.pushedVariants = Some(new VariantInRelation())
+ return originalPlan
+ }
// Collect requested fields from project list and filters
projectList.foreach(variants.collectRequestedFields)
filters.foreach(variants.collectRequestedFields)
- // If no variant columns remain after collection, return original plan
- if (variants.mapping.forall(_._2.isEmpty)) return originalPlan
+ // Drop a variant whose sole requested field targets VariantType (a
whole-variant read: bare
+ // `SELECT v`, 2-arg `variant_get(v, '$.a')`, or a column lifted to feed a
variant_get above a
+ // barrier). Shredding it to a lone variant-typed slot saves no I/O and is
mishandled: the
+ // reader collapses such a slot to a boolean placeholder while catalyst
keeps it VariantType, so
+ // holder.output and readSchema disagree. Leaving it raw avoids that. The
predicate matches the
+ // reader's collapse condition by target type, not the exact `fullVariant`
value -- the latter
+ // would miss 2-arg variant_get and non-UTC sessions (different path/tz,
same broken slot).
+ // When the field coexists with real extractions the struct has >= 2 slots
and is not collapsed,
+ // so this is scoped to the sole-field case. Runs before the
IsNull/IsNotNull injection so
+ // presence-only references, which legitimately shred to a placeholder,
are unaffected.
+ variants.mapping.values.foreach { pathToFields =>
+ pathToFields.filterInPlace { case (_, fields) =>
+ !(fields.size == 1 &&
fields.head._1.targetType.isInstanceOf[VariantType])
+ }
+ }
+ variants.mapping.filterInPlace { case (_, pathToFields) =>
pathToFields.nonEmpty }
+
+ // If a variant column is referenced only via IsNull/IsNotNull (e.g. WHERE
isnotnull(v)),
+ // collectRequestedFields adds nothing to its field map (the
IsNull/IsNotNull branch is a
+ // no-op). Inject a fullVariant entry for each such column so rewriteType
generates the
+ // placeholder struct and the extraction is pushed on this (outer) visit.
Without this,
+ // the inner leaf visit would fire with sHolder.output as its projectList,
causing all
+ // variant columns -- including unreferenced siblings -- to receive
fullVariant treatment.
+ val referencedAttrs = AttributeSet((projectList ++
filters).flatMap(_.references))
+ for (a <- sHolder.relation.output) {
+ if (variants.mapping.contains(a.exprId) &&
+ variants.mapping(a.exprId).values.forall(_.isEmpty) &&
+ referencedAttrs.contains(a)) {
+ variants.collectRequestedFields(a)
+ }
+ }
// Build individual VariantExtraction for each field access
// Track which extraction corresponds to which (attr, field, ordinal)
@@ -475,20 +528,31 @@ object V2ScanRelationPushDown extends Rule[LogicalPlan]
with PredicateHelper {
}
}
- // Call the API to push down variant extractions
- if (extractionInfo.isEmpty) return originalPlan
+ // No extraction was requested for any variant column. Set the sentinel to
suppress
+ // the inner leaf visit and avoid a spurious empty push.
+ if (extractionInfo.isEmpty) {
+ sHolder.pushedVariants = Some(new VariantInRelation())
+ return originalPlan
+ }
// Companion extractions can only be honored by readers that support
cast-error deferral. If
// none were generated, the pushdown carries only non-strict accesses
(`try_variant_get`, plain
// variant reads, casts to variant/string) that are safe regardless of
deferral support.
- if (hasCompanionExtraction && !builder.supportsDeferCastError()) return
originalPlan
+ if (hasCompanionExtraction && !builder.supportsDeferCastError()) {
+ // Set the sentinel like the other early returns, else the leaf is
re-visited (double-visit).
+ sHolder.pushedVariants = Some(new VariantInRelation())
+ return originalPlan
+ }
val extractions: Array[VariantExtraction] =
extractionInfo.map(_._1).toArray
val pushedResults = builder.pushVariantExtractions(extractions)
// Filter to only the accepted extractions
val acceptedExtractions =
extractionInfo.zip(pushedResults).filter(_._2).map(_._1)
- if (acceptedExtractions.isEmpty) return originalPlan
+ if (acceptedExtractions.isEmpty) {
+ sHolder.pushedVariants = Some(new VariantInRelation())
+ return originalPlan
+ }
// Group accepted extractions by attribute to rebuild the struct schemas
val extractionsByAttr = acceptedExtractions.groupBy(_._2)
@@ -513,6 +577,31 @@ object V2ScanRelationPushDown extends Rule[LogicalPlan]
with PredicateHelper {
sHolder.pushedVariantAttributeMap = attributeMap
sHolder.output = newOutput
+ // Commit the required top-level columns to the builder now, while
projectList and
+ // filters are in scope. This mirrors how rewriteAggregate sets
holder.output to only
+ // the pushed-down aggregate columns before build() -- both patterns
ensure that
+ // buildScanWithPushed* can call build() and zip holder.output with
readSchema() directly.
+ //
+ // projectList/filters reference original ExprIds (this function returns
originalPlan
+ // unchanged). holder.relation.output carries those original ExprIds, so
AttributeSet
+ // matching works directly against it.
+ //
+ // attributeMap maps old ExprId -> new AttributeReference (with a fresh
ExprId for rewritten
+ // variant columns). Invert it to map new ExprId -> old ExprId so we can
filter holder.output
+ // (which carries new ExprIds) against requiredColumns (which carries old
ExprIds).
+ sHolder.builder match {
+ case r: SupportsPushDownRequiredColumns =>
+ val requiredColumns = AttributeSet((projectList ++
filters).flatMap(_.references))
+ val neededRelOutput =
sHolder.relation.output.filter(requiredColumns.contains)
+ val newToOldExprId = attributeMap.map { case (oldId, newAttr) =>
newAttr.exprId -> oldId }
+ val oldExprIdToRelAttr = sHolder.relation.output.map(a => a.exprId ->
a).toMap
+ sHolder.output = sHolder.output.filter { a =>
+
oldExprIdToRelAttr.get(newToOldExprId(a.exprId)).exists(requiredColumns.contains)
+ }
+ r.pruneColumns(neededRelOutput.toStructType)
+ case _ => // builder does not support column pruning; holder.output
stays full-schema
+ }
+
// Return the original plan unchanged - transformation happens in
buildScanWithPushedVariants
originalPlan
}
@@ -794,13 +883,17 @@ object V2ScanRelationPushDown extends Rule[LogicalPlan]
with PredicateHelper {
def buildScanWithPushedVariants(plan: LogicalPlan): LogicalPlan =
plan.transform {
case p@PhysicalOperation(projectList, filters, holder: ScanBuilderHolder)
- if holder.pushedVariants.isDefined =>
+ if holder.pushedVariants.exists(_.mapping.nonEmpty) =>
val variants = holder.pushedVariants.get
val attributeMap = holder.pushedVariantAttributeMap
// Build the scan
val scan = holder.builder.build()
val realOutput = toAttributes(scan.readSchema())
+ assert(realOutput.length == holder.output.length,
+ s"The data source returns ${realOutput.length} columns but the plan
expected " +
+ s"${holder.output.length}:
scan=[${realOutput.map(_.name).mkString(",")}], " +
+ s"plan=[${holder.output.map(_.name).mkString(",")}]")
val wrappedScan = getWrappedScan(scan, holder)
// Note: holder.pushedFilterExpressions is not propagated here because
the output schema
// changes with variant extraction. When validConstraints is wired up,
this needs revisiting.
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/PushVariantIntoScanSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/PushVariantIntoScanSuite.scala
index d6a9cfc94e8c..67bee55c2275 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/PushVariantIntoScanSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/PushVariantIntoScanSuite.scala
@@ -472,6 +472,17 @@ abstract class PushVariantIntoScanV2SuiteBase extends
QueryTest with PushVariant
super.sparkConf.set(SQLConf.PARQUET_VECTORIZED_READER_ENABLED.key,
vectorizedReaderEnabled.toString)
+ // Locate the single DataSourceV2ScanRelation in an optimized plan,
regardless of how many
+ // Project/Filter nodes wrap it. This keeps scan-content assertions robust
against optimizer
+ // rules (e.g. CollapseProject) that may or may not collapse the identity
outputProjection
+ // that buildScanWithPushedVariants inserts.
+ protected def findScanRelation(plan: LogicalPlan): DataSourceV2ScanRelation
= {
+ val scans = plan.collect { case s: DataSourceV2ScanRelation => s }
+ assert(scans.length == 1,
+ s"Expected exactly one DataSourceV2ScanRelation but found
${scans.length}:\n$plan")
+ scans.head
+ }
+
test(s"V2 test - basic variant field extraction ($readerName)") {
withTempPath { dir =>
val path = dir.getCanonicalPath
@@ -794,6 +805,893 @@ abstract class PushVariantIntoScanV2SuiteBase extends
QueryTest with PushVariant
}
}
+ test(s"V2 test - column pruning: only referenced variant column in scan
($readerName)") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ withTable("temp_v1") {
+ sql(s"create table temp_v1 (v1 variant, v2 variant, v3 variant) using
PARQUET " +
+ s"location '$path'")
+ sql("insert into temp_v1 values " +
+ "(parse_json('{\"a\": 1}'), parse_json('{\"b\": 2}'),
parse_json('{\"c\": 3}'))")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ val df = spark.read.parquet(path)
+ df.createOrReplaceTempView("T_V2")
+ val query = "select variant_get(v1, '$.a', 'int') from T_V2"
+ val expectedRows = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect()
+ }
+ checkAnswer(sql(query), expectedRows)
+ sql(query).queryExecution.optimizedPlan match {
+ case Project(_, scanRelation: DataSourceV2ScanRelation) =>
+ // Only v1 should be in the scan; v2 and v3 must be pruned.
+ assert(scanRelation.output.map(_.name) == Seq("v1"),
+ s"Expected scan output [v1] but got
${scanRelation.output.map(_.name)}")
+ assert(scanRelation.output(0).dataType.isInstanceOf[StructType],
+ "Expected v1 to be rewritten to struct type after extraction
pushdown")
+ case other =>
+ fail(s"Unexpected plan shape: ${other.getClass.getName}\n$other")
+ }
+ }
+ }
+ }
+
+ test(s"V2 test - column pruning: mixed variant/scalar, only variant
referenced ($readerName)") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ withTable("temp_v1") {
+ sql(s"create table temp_v1 (v variant, s string, i int) using PARQUET
location '$path'")
+ sql("insert into temp_v1 values (parse_json('{\"a\": 42}'), 'hello',
7)")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ val df = spark.read.parquet(path)
+ df.createOrReplaceTempView("T_V2")
+ val query = "select variant_get(v, '$.a', 'int') from T_V2"
+ val expectedRows = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect()
+ }
+ checkAnswer(sql(query), expectedRows)
+ sql(query).queryExecution.optimizedPlan match {
+ case Project(_, scanRelation: DataSourceV2ScanRelation) =>
+ // s and i are unreferenced; only v should appear in the scan.
+ assert(scanRelation.output.map(_.name) == Seq("v"),
+ s"Expected scan output [v] but got
${scanRelation.output.map(_.name)}")
+ // v is referenced via variant_get, so it is rewritten to an
extraction struct
+ // (slot per path) rather than read as a whole variant.
+ assert(scanRelation.output(0).dataType.isInstanceOf[StructType],
+ s"Expected v rewritten to struct, but got
${scanRelation.output(0).dataType}")
+ case other =>
+ fail(s"Unexpected plan shape: ${other.getClass.getName}\n$other")
+ }
+ }
+ }
+ }
+
+ test(s"V2 test - column pruning: referenced scalar survives alongside
variant ($readerName)") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ withTable("temp_v1") {
+ sql(s"create table temp_v1 (v variant, s string) using PARQUET
location '$path'")
+ sql("insert into temp_v1 values (parse_json('{\"a\": 5}'), 'keep_me')")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ val df = spark.read.parquet(path)
+ df.createOrReplaceTempView("T_V2")
+ val query = "select variant_get(v, '$.a', 'int'), s from T_V2"
+ val expectedRows = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect()
+ }
+ checkAnswer(sql(query), expectedRows)
+ sql(query).queryExecution.optimizedPlan match {
+ case Project(_, scanRelation: DataSourceV2ScanRelation) =>
+ // Both v and s are referenced; neither should be pruned.
+ val names = scanRelation.output.map(_.name).toSet
+ assert(names == Set("v", "s"),
+ s"Expected scan output {v, s} but got $names")
+ // v is rewritten to an extraction struct; the scalar s keeps its
original type.
+ val byName = scanRelation.output.map(a => a.name ->
a.dataType).toMap
+ assert(byName("v").isInstanceOf[StructType],
+ s"Expected v rewritten to struct, but got ${byName("v")}")
+ assert(byName("s") == StringType,
+ s"Expected s to remain StringType, but got ${byName("s")}")
+ case other =>
+ fail(s"Unexpected plan shape: ${other.getClass.getName}\n$other")
+ }
+ }
+ }
+ }
+
+ test(s"V2 test - column pruning: filter on second variant keeps it in scan
($readerName)") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ withTable("temp_v1") {
+ sql(s"create table temp_v1 (v1 variant, v2 variant) using PARQUET
location '$path'")
+ sql("insert into temp_v1 values " +
+ "(parse_json('{\"a\": 10}'), parse_json('{\"b\": 1}'))")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ val df = spark.read.parquet(path)
+ df.createOrReplaceTempView("T_V2")
+ val query =
+ "select variant_get(v1, '$.a', 'int') from T_V2 " +
+ "where variant_get(v2, '$.b', 'int') > 0"
+ val expectedRows = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect()
+ }
+ checkAnswer(sql(query), expectedRows)
+ // v1 is in SELECT, v2 is in WHERE -- both must survive column pruning.
+ val scanRelation =
findScanRelation(sql(query).queryExecution.optimizedPlan)
+ val names = scanRelation.output.map(_.name).toSet
+ assert(names == Set("v1", "v2"),
+ s"Expected scan output {v1, v2} but got $names")
+ // Both are referenced via variant_get, so both are rewritten to
extraction structs.
+ scanRelation.output.foreach { a =>
+ assert(a.dataType.isInstanceOf[StructType],
+ s"Expected ${a.name} rewritten to struct, but got ${a.dataType}")
+ }
+ }
+ }
+ }
+
+ test(s"V2 test - isnotnull on variant with sibling variant column
($readerName)") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ withTable("temp_v1") {
+ sql(s"create table temp_v1 (v1 variant, v2 variant) using PARQUET
location '$path'")
+ sql("insert into temp_v1 values (parse_json('{\"a\": 1}'),
parse_json('{\"b\": 2}'))")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ val df = spark.read.parquet(path)
+ df.createOrReplaceTempView("T_V2")
+ val query = "select 1 from T_V2 where isnotnull(v1)"
+ val expectedRows = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect()
+ }
+ checkAnswer(sql(query), expectedRows)
+ // Only v1 should be in the scan; v2 is unreferenced and must be
pruned.
+ val scanRelation =
findScanRelation(sql(query).queryExecution.optimizedPlan)
+ assert(scanRelation.output.map(_.name) == Seq("v1"),
+ s"Expected scan output [v1] but got
${scanRelation.output.map(_.name)}")
+ assert(scanRelation.output(0).dataType.isInstanceOf[StructType],
+ "Expected v1 to be rewritten to a placeholder struct after pushdown")
+ }
+ }
+ }
+
+ // Aggregate barrier: an aggregate over one variant column (v1) with a
filter on the same
+ // column. The Aggregate node terminates the PhysicalOperation match, so
variant extraction
+ // pushdown only fires on the Filter -> Project -> ScanBuilderHolder subtree
below it. The
+ // $.price path inside max() lives above the barrier and is NOT pushed (v1
stays a full
+ // variant), but the unreferenced sibling v2 is never in the subtree's
projectList/filters,
+ // so top-level column pruning still drops it from the scan.
+ test(s"V2 test - column pruning: aggregate barrier prunes unreferenced
variant ($readerName)") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ withTable("temp_v1") {
+ sql(s"create table temp_v1 (v1 variant, v2 variant) using PARQUET
location '$path'")
+ sql("insert into temp_v1 values " +
+ "(parse_json('{\"price\": 10, \"country\": \"china\"}'),
parse_json('{\"b\": 2}')), " +
+ "(parse_json('{\"price\": 20, \"country\": \"japan\"}'),
parse_json('{\"b\": 3}'))")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ val df = spark.read.parquet(path)
+ df.createOrReplaceTempView("T_V2")
+ val query =
+ "select max(variant_get(v1, '$.price', 'long')) from T_V2 " +
+ "where variant_get(v1, '$.country', 'string') = 'china'"
+ val expectedRows = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect()
+ }
+ checkAnswer(sql(query), expectedRows)
+ // v2 is unreferenced anywhere in the query -- it must be pruned out
of the scan even
+ // though the aggregate barrier prevents the $.price extraction from
being pushed.
+ val scanRelation =
findScanRelation(sql(query).queryExecution.optimizedPlan)
+ assert(scanRelation.output.map(_.name) == Seq("v1"),
+ s"Expected scan output [v1] but got
${scanRelation.output.map(_.name)}")
+ }
+ }
+ }
+
+ test(s"V2 test - column pruning: group by + variant_get aggregate, sibling
pruned " +
+ s"($readerName)") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ withTable("temp_v1") {
+ sql(s"create table temp_v1 (v variant, v2 variant, name string) " +
+ s"using PARQUET location '$path'")
+ sql("insert into temp_v1 values " +
+ "(parse_json('{\"price\": 10, \"country\": \"china\"}'), " +
+ " parse_json('{\"junk\": 1}'), 'widget'), " +
+ "(parse_json('{\"price\": 20, \"country\": \"china\"}'), " +
+ " parse_json('{\"junk\": 2}'), 'widget'), " +
+ "(parse_json('{\"price\": 30, \"country\": \"japan\"}'), " +
+ " parse_json('{\"junk\": 3}'), 'gadget')")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ spark.read.parquet(path).createOrReplaceTempView("T_V2")
+ val query =
+ "select name, max(variant_get(v, '$.price', 'long')) from T_V2 " +
+ "where variant_get(v, '$.country', 'string') = 'china' group by name"
+ val expectedRows = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect()
+ }
+ checkAnswer(sql(query), expectedRows)
+ // v2 is unreferenced and must be pruned. v is referenced via
variant_get in the WHERE
+ // (local, $.country) and via max(variant_get(v, '$.price')) above the
aggregate barrier
+ // (lifted in as a bare reference -> fullVariant). Because the local
$.country extraction is
+ // also present, fullVariant is not the column's only field, so v is
still shredded (a
+ // multi-slot struct does not collapse to a placeholder); it is not
kept raw.
+ val scanRelation =
findScanRelation(sql(query).queryExecution.optimizedPlan)
+ assert(scanRelation.output.map(_.name).toSet == Set("v", "name"),
+ s"Expected scan output {v, name} but got
${scanRelation.output.map(_.name)}")
+ val vAttr = scanRelation.output.find(_.name == "v").get
+ assert(vAttr.dataType.isInstanceOf[StructType],
+ s"Expected v rewritten to struct, but got ${vAttr.dataType}")
+ }
+ }
+ }
+
+ test(s"V2 test - order by variant_get: correct ordering, variant read raw,
sibling pruned " +
+ s"($readerName)") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ withTable("temp_v1") {
+ sql(s"create table temp_v1 (v variant, v2 variant, name string) " +
+ s"using PARQUET location '$path'")
+ sql("insert into temp_v1 values " +
+ "(parse_json('{\"price\": 3}'), parse_json('{\"junk\": 1}'), 'x'), "
+
+ "(parse_json('{\"price\": 1}'), parse_json('{\"junk\": 2}'), 'z'), "
+
+ "(parse_json('{\"price\": 2}'), parse_json('{\"junk\": 3}'), 'y')")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ spark.read.parquet(path).createOrReplaceTempView("T_V2")
+ // The sort key variant_get(v, '$.price') lives in Sort.order, above
the scan window, so v
+ // is lifted in only as a bare reference -> a whole-variant request ->
v stays raw and the
+ // sort evaluates on the real variant. Compare ORDER-SENSITIVELY: a
shredded full-variant
+ // slot would collapse to a boolean placeholder and silently
mis-order, which the
+ // order-insensitive checkAnswer would not catch.
+ val query = "select name from T_V2 order by variant_get(v, '$.price',
'int')"
+ val expectedOrder = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect().map(_.getString(0)).toList
+ }
+ assert(expectedOrder == List("z", "y", "x"),
+ s"baseline sanity: expected z,y,x but got $expectedOrder")
+ assert(sql(query).collect().map(_.getString(0)).toList ==
expectedOrder,
+ "ORDER BY variant_get produced the wrong row order")
+ val scanRelation =
findScanRelation(sql(query).queryExecution.optimizedPlan)
+ assert(scanRelation.output.map(_.name).toSet == Set("v", "name"),
+ s"Expected scan output {v, name} but got
${scanRelation.output.map(_.name)}")
+ val vAttr = scanRelation.output.find(_.name == "v").get
+ assert(vAttr.dataType.isInstanceOf[VariantType],
+ s"Expected v left as raw VariantType, but got ${vAttr.dataType}")
+ }
+ }
+ }
+
+ test(s"V2 test - aggregate max(variant_get) with no local filter: no codegen
crash, variant " +
+ s"read raw ($readerName)") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ withTable("temp_v1") {
+ sql(s"create table temp_v1 (v variant, v2 variant, name string) " +
+ s"using PARQUET location '$path'")
+ sql("insert into temp_v1 values " +
+ "(parse_json('{\"price\": 10}'), parse_json('{\"junk\": 1}'), 'a'),
" +
+ "(parse_json('{\"price\": 30}'), parse_json('{\"junk\": 2}'), 'a'),
" +
+ "(parse_json('{\"price\": 20}'), parse_json('{\"junk\": 3}'), 'b')")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ spark.read.parquet(path).createOrReplaceTempView("T_V2")
+ // variant_get(v, '$.price') is inside an aggregate function, above
the aggregate barrier,
+ // with no local filter/projection on v, so v is lifted in only as a
bare reference -> a
+ // whole-variant request. A whole-variant read is kept raw: shredding
it to a lone
+ // full-variant slot would collapse to a boolean placeholder, and
max(variant_get(<boolean>,
+ // ...)) would fail to codegen. Keeping v raw yields correct
aggregates with a valid plan.
+ val query =
+ "select name, max(variant_get(v, '$.price', 'int')) as mx from T_V2
group by name"
+ val expectedRows = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect()
+ }
+ checkAnswer(sql(query), expectedRows) // a -> 30, b -> 20
+ val scanRelation =
findScanRelation(sql(query).queryExecution.optimizedPlan)
+ val vAttr = scanRelation.output.find(_.name == "v").get
+ assert(vAttr.dataType.isInstanceOf[VariantType],
+ s"Expected v left as raw VariantType, but got ${vAttr.dataType}")
+ assert(!scanRelation.output.exists(_.name == "v2"),
+ s"Expected v2 pruned but got ${scanRelation.output.map(_.name)}")
+ }
+ }
+ }
+
+ test(s"V2 test - join on variant_get key: no crash, variant read raw,
sibling pruned " +
+ s"($readerName)") {
+ withTempPath { dir =>
+ val itemsPath = dir.getCanonicalPath + "/items"
+ val countriesPath = dir.getCanonicalPath + "/countries"
+ withTable("temp_items", "temp_countries") {
+ sql(s"create table temp_items (v variant, v2 variant, name string) " +
+ s"using PARQUET location '$itemsPath'")
+ sql("insert into temp_items values " +
+ "(parse_json('{\"country_code\": \"CN\"}'), parse_json('{\"junk\":
1}'), 'widget'), " +
+ "(parse_json('{\"country_code\": \"JP\"}'), parse_json('{\"junk\":
2}'), 'gadget')")
+ sql(s"create table temp_countries (code string, country_name string) "
+
+ s"using PARQUET location '$countriesPath'")
+ sql("insert into temp_countries values ('CN', 'China'), ('JP',
'Japan')")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ spark.read.parquet(itemsPath).createOrReplaceTempView("ITEMS_V2")
+
spark.read.parquet(countriesPath).createOrReplaceTempView("COUNTRIES_V2")
+ val query =
+ "select i.name, c.country_name from ITEMS_V2 i join COUNTRIES_V2 c "
+
+ "on variant_get(i.v, '$.country_code', 'string') = c.code"
+ val expectedRows = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect()
+ }
+ // A variant_get join key produces a valid plan (no attribute-binding
failure) and
+ // correct results; the key is read raw and the extraction is
evaluated above the scan.
+ checkAnswer(sql(query), expectedRows)
+ val scans = sql(query).queryExecution.optimizedPlan.collect {
+ case s: DataSourceV2ScanRelation => s
+ }
+ val itemsScan = scans.find(_.output.exists(_.name == "v")).getOrElse(
+ fail(s"Could not find the items scan
in:\n${sql(query).queryExecution.optimizedPlan}"))
+ // v is the join key (referenced only via variant_get in the join
condition): it must be
+ // left as a raw variant, not shredded. v2 is unreferenced and must be
pruned.
+ assert(itemsScan.output.map(_.name).toSet == Set("v", "name"),
+ s"Expected items scan output {v, name} but got
${itemsScan.output.map(_.name)}")
+ val vAttr = itemsScan.output.find(_.name == "v").get
+ assert(vAttr.dataType.isInstanceOf[VariantType],
+ s"Expected v left as raw VariantType, but got ${vAttr.dataType}")
+ }
+ }
+ }
+
+ test(s"V2 test - join on variant_get key with local variant filter: no crash
($readerName)") {
+ withTempPath { dir =>
+ val itemsPath = dir.getCanonicalPath + "/items"
+ val countriesPath = dir.getCanonicalPath + "/countries"
+ withTable("temp_items", "temp_countries") {
+ sql(s"create table temp_items (v variant, v2 variant) " +
+ s"using PARQUET location '$itemsPath'")
+ sql("insert into temp_items values " +
+ "(parse_json('{\"country_code\": \"CN\", \"qty\": 5}'),
parse_json('{\"junk\": 1}')), " +
+ "(parse_json('{\"country_code\": \"JP\", \"qty\": 0}'),
parse_json('{\"junk\": 2}'))")
+ sql(s"create table temp_countries (code string, country_name string) "
+
+ s"using PARQUET location '$countriesPath'")
+ sql("insert into temp_countries values ('CN', 'China'), ('JP',
'Japan')")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ spark.read.parquet(itemsPath).createOrReplaceTempView("ITEMS_V2")
+
spark.read.parquet(countriesPath).createOrReplaceTempView("COUNTRIES_V2")
+ // v appears in both a join condition and a local WHERE extraction.
The join reference
+ // forces v to stay raw; the query must still optimize without
crashing and be correct.
+ val query =
+ "select c.country_name from ITEMS_V2 i join COUNTRIES_V2 c " +
+ "on variant_get(i.v, '$.country_code', 'string') = c.code " +
+ "where variant_get(i.v, '$.qty', 'int') > 0"
+ val expectedRows = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect()
+ }
+ checkAnswer(sql(query), expectedRows)
+ }
+ }
+ }
+
+ test(s"V2 test - join on variant_get key through an aliasing projection: no
crash, no " +
+ s"mis-shred ($readerName)") {
+ withTempPath { dir =>
+ val itemsPath = dir.getCanonicalPath + "/items"
+ val countriesPath = dir.getCanonicalPath + "/countries"
+ withTable("temp_items", "temp_countries") {
+ sql(s"create table temp_items (v variant, v2 variant, name string) " +
+ s"using PARQUET location '$itemsPath'")
+ sql("insert into temp_items values " +
+ "(parse_json('{\"country_code\": \"CN\"}'), parse_json('{\"junk\":
1}'), 'widget'), " +
+ "(parse_json('{\"country_code\": \"JP\"}'), parse_json('{\"junk\":
2}'), 'gadget')")
+ sql(s"create table temp_countries (code string, country_name string) "
+
+ s"using PARQUET location '$countriesPath'")
+ sql("insert into temp_countries values ('CN', 'China'), ('JP',
'Japan')")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ spark.read.parquet(itemsPath).createOrReplaceTempView("ITEMS_V2")
+
spark.read.parquet(countriesPath).createOrReplaceTempView("COUNTRIES_V2")
+ // The variant column is aliased (v AS vw) in a subquery before the
join condition reads
+ // it via variant_get. Whether the optimizer inlines the alias or
keeps it, the query must
+ // optimize without crashing, return correct results, and not
over-shred (v2 pruned, the
+ // variant join key not turned into a struct that yields wrong data).
+ val query =
+ "select c.country_name from " +
+ "(select v as vw, name as nm from ITEMS_V2) i join COUNTRIES_V2 c " +
+ "on variant_get(i.vw, '$.country_code', 'string') = c.code"
+ val expectedRows = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect()
+ }
+ // Must not crash during optimization and must be correct.
+ checkAnswer(sql(query), expectedRows)
+ val scans = sql(query).queryExecution.optimizedPlan.collect {
+ case s: DataSourceV2ScanRelation => s
+ }
+ val itemsScan = scans.find(_.output.exists(a => a.name == "v" ||
a.name == "vw"))
+ .getOrElse(fail(
+ s"Could not find the items scan
in:\n${sql(query).queryExecution.optimizedPlan}"))
+ // v2 is unreferenced and must be pruned.
+ assert(!itemsScan.output.exists(_.name == "v2"),
+ s"Expected v2 pruned but items scan was
${itemsScan.output.map(_.name)}")
+ // The variant join key must not be shredded: it is read as a raw
variant.
+ val vAttr = itemsScan.output.find(a => a.name == "v" || a.name ==
"vw").get
+ assert(vAttr.dataType.isInstanceOf[VariantType],
+ s"Expected the variant join key left raw, but got ${vAttr.dataType}")
+ }
+ }
+ }
+
+ // Non-shredded variant: parse_json writes the variant blob without shredded
typed_value
+ // columns, so the Parquet file contains only metadata+value columns. The
logical plan
+ // rewrite (variant_get -> GetStructField) and column pruning both still
apply; the Parquet
+ // reader handles the non-shredded data at physical read time.
+ // All of the tests above already use parse_json (non-shredded) data, so
this comment
+ // documents that the non-shredded case is fully covered by the existing
test suite.
+
+ test(s"V2 test - sole full variant: select v directly stays raw
($readerName)") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ withTable("temp_v1") {
+ sql(s"create table temp_v1 (v variant, v2 variant) using PARQUET
location '$path'")
+ sql("insert into temp_v1 values " +
+ "(parse_json('{\"a\": 1}'), parse_json('{\"b\": 2}')), " +
+ "(parse_json('{\"a\": 9}'), parse_json('{\"b\": 8}'))")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ spark.read.parquet(path).createOrReplaceTempView("T_V2")
+ // Selecting the whole variant is the canonical sole-fullVariant case:
its only requested
+ // field is the entire value, which is not an extraction. v must stay
raw (shredding it to a
+ // lone full-variant slot would collapse to a boolean placeholder and
corrupt the output).
+ // v2 is unreferenced and must be pruned.
+ val query = "select v from T_V2"
+ val expectedRows = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect()
+ }
+ checkAnswer(sql(query), expectedRows)
+ val scanRelation =
findScanRelation(sql(query).queryExecution.optimizedPlan)
+ assert(scanRelation.output.map(_.name) == Seq("v"),
+ s"Expected scan output [v] but got
${scanRelation.output.map(_.name)}")
+ assert(scanRelation.output(0).dataType.isInstanceOf[VariantType],
+ s"Expected v left raw, but got ${scanRelation.output(0).dataType}")
+ }
+ }
+ }
+
+ test(s"V2 test - sole full variant on one column, sibling extracted
($readerName)") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ withTable("temp_v1") {
+ sql(s"create table temp_v1 (v variant, v2 variant) using PARQUET
location '$path'")
+ sql("insert into temp_v1 values " +
+ "(parse_json('{\"a\": 1}'), parse_json('{\"b\": 2}')), " +
+ "(parse_json('{\"a\": 9}'), parse_json('{\"b\": 8}'))")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ spark.read.parquet(path).createOrReplaceTempView("T_V2")
+ // The guard is per-column: v is read whole (sole fullVariant -> raw)
while v2 has a real
+ // extraction (-> shredded). Both must coexist in the same scan.
+ val query = "select v, variant_get(v2, '$.b', 'int') as b from T_V2"
+ val expectedRows = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect()
+ }
+ checkAnswer(sql(query), expectedRows)
+ val scanRelation =
findScanRelation(sql(query).queryExecution.optimizedPlan)
+ val vAttr = scanRelation.output.find(_.name == "v").get
+ val v2Attr = scanRelation.output.find(_.name == "v2").get
+ assert(vAttr.dataType.isInstanceOf[VariantType],
+ s"Expected v left raw, but got ${vAttr.dataType}")
+ assert(v2Attr.dataType.isInstanceOf[StructType],
+ s"Expected v2 shredded to struct, but got ${v2Attr.dataType}")
+ }
+ }
+ }
+
+ test(s"V2 test - sole 2-arg variant_get (VariantType target) stays raw
($readerName)") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ withTable("temp_v1") {
+ sql(s"create table temp_v1 (v variant, v2 variant) using PARQUET
location '$path'")
+ sql("insert into temp_v1 values " +
+ "(parse_json('{\"a\": {\"x\": 1}}'), parse_json('{\"b\": 2}')), " +
+ "(parse_json('{\"a\": {\"x\": 9}}'), parse_json('{\"b\": 8}'))")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ spark.read.parquet(path).createOrReplaceTempView("T_V2")
+ // 2-arg variant_get has no type argument, so its target is
VariantType. Its requested
+ // field is NOT the exact `fullVariant` sentinel (the path is "$.a",
not "$"), but it still
+ // shreds to a lone variant-typed slot which the reader collapses to a
boolean placeholder.
+ // The strip must catch it by target type, not by exact-value
equality, and keep v raw.
+ val query = "select variant_get(v, '$.a') as a from T_V2"
+ val expectedRows = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect()
+ }
+ checkAnswer(sql(query), expectedRows)
+ val scanRelation =
findScanRelation(sql(query).queryExecution.optimizedPlan)
+ // v is read whole (kept raw); the unreferenced sibling v2 is pruned.
+ assert(scanRelation.output.map(_.name) == Seq("v"),
+ s"Expected scan output [v] but got
${scanRelation.output.map(_.name)}")
+ assert(scanRelation.output(0).dataType.isInstanceOf[VariantType],
+ s"Expected v left raw, but got ${scanRelation.output(0).dataType}")
+ }
+ }
+ }
+
+ test(s"V2 test - sole variant_get('$$') under non-UTC session stays raw
($readerName)") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ withTable("temp_v1") {
+ sql(s"create table temp_v1 (v variant, v2 variant) using PARQUET
location '$path'")
+ sql("insert into temp_v1 values " +
+ "(parse_json('{\"a\": 1}'), parse_json('{\"b\": 2}')), " +
+ "(parse_json('{\"a\": 9}'), parse_json('{\"b\": 8}'))")
+ }
+ // A non-UTC session makes variant_get carry a non-UTC timezone, so even
the "$" path is not
+ // equal to the UTC `fullVariant` sentinel. The strip must still keep v
raw -- matching by
+ // target type rather than by the exact sentinel value covers this case.
+ withSQLConf(
+ SQLConf.USE_V1_SOURCE_LIST.key -> "",
+ SQLConf.SESSION_LOCAL_TIMEZONE.key -> "America/Los_Angeles") {
+ spark.read.parquet(path).createOrReplaceTempView("T_V2")
+ val query = "select variant_get(v, '$') as whole from T_V2"
+ val expectedRows = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect()
+ }
+ checkAnswer(sql(query), expectedRows)
+ val scanRelation =
findScanRelation(sql(query).queryExecution.optimizedPlan)
+ assert(scanRelation.output.map(_.name) == Seq("v"),
+ s"Expected scan output [v] but got
${scanRelation.output.map(_.name)}")
+ assert(scanRelation.output(0).dataType.isInstanceOf[VariantType],
+ s"Expected v left raw, but got ${scanRelation.output(0).dataType}")
+ }
+ }
+ }
+
+ test(s"V2 test - mixed extractions with order by a pushed path: shredded,
correct order " +
+ s"($readerName)") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ withTable("temp_v1") {
+ sql(s"create table temp_v1 (v variant, v2 variant) using PARQUET
location '$path'")
+ sql("insert into temp_v1 values " +
+ "(parse_json('{\"name\": \"x\", \"price\": 3}'), parse_json('{\"j\":
1}')), " +
+ "(parse_json('{\"name\": \"z\", \"price\": 1}'), parse_json('{\"j\":
2}')), " +
+ "(parse_json('{\"name\": \"y\", \"price\": 2}'), parse_json('{\"j\":
3}'))")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ spark.read.parquet(path).createOrReplaceTempView("T_V2")
+ // Two real extractions ($.name, $.price) are in the local Project,
and the ORDER BY lifts a
+ // bare v (-> fullVariant) for the sort. fullVariant is not the only
field, so v stays
+ // shredded (a multi-slot struct with a real full-variant slot, no
placeholder collapse) and
+ // the sort reads that real slot. Compare ORDER-SENSITIVELY.
+ val query = "select variant_get(v, '$.name', 'string') as nm, " +
+ "variant_get(v, '$.price', 'long') as pr " +
+ "from T_V2 order by variant_get(v, '$.price', 'long')"
+ val expectedOrder = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect().map(r => (r.getString(0), r.getLong(1))).toList
+ }
+ assert(expectedOrder == List(("z", 1L), ("y", 2L), ("x", 3L)),
+ s"baseline sanity: $expectedOrder")
+ val actualOrder = sql(query).collect().map(r => (r.getString(0),
r.getLong(1))).toList
+ assert(actualOrder == expectedOrder,
+ "ORDER BY a pushed path produced the wrong row order")
+ val scanRelation =
findScanRelation(sql(query).queryExecution.optimizedPlan)
+ val vAttr = scanRelation.output.find(_.name == "v").get
+ assert(vAttr.dataType.isInstanceOf[StructType],
+ s"Expected v shredded, but got ${vAttr.dataType}")
+ }
+ }
+ }
+
+ test(s"V2 test - order by a pushed path not in the select list: correct
order ($readerName)") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ withTable("temp_v1") {
+ sql(s"create table temp_v1 (v variant, v2 variant) using PARQUET
location '$path'")
+ sql("insert into temp_v1 values " +
+ "(parse_json('{\"name\": \"x\", \"price\": 3}'), parse_json('{\"j\":
1}')), " +
+ "(parse_json('{\"name\": \"z\", \"price\": 1}'), parse_json('{\"j\":
2}')), " +
+ "(parse_json('{\"name\": \"y\", \"price\": 2}'), parse_json('{\"j\":
3}'))")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ spark.read.parquet(path).createOrReplaceTempView("T_V2")
+ // Only $.name is selected (local); the sort key $.price is lifted as
a bare v (full
+ // variant). v shreds to {$.name, full-variant}; the sort reads the
real full-variant slot.
+ // The ORDER-SENSITIVE check guards against a placeholder mis-order.
+ val query = "select variant_get(v, '$.name', 'string') as nm " +
+ "from T_V2 order by variant_get(v, '$.price', 'long')"
+ val expectedOrder = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect().map(_.getString(0)).toList
+ }
+ assert(expectedOrder == List("z", "y", "x"), s"baseline sanity:
$expectedOrder")
+ assert(sql(query).collect().map(_.getString(0)).toList ==
expectedOrder,
+ "ORDER BY a non-selected pushed path produced the wrong row order")
+ }
+ }
+ }
+
+ test(s"V2 test - group by variant_get key: optimal shred, no full-variant
slot ($readerName)") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ withTable("temp_v1") {
+ sql(s"create table temp_v1 (v variant, v2 variant) using PARQUET
location '$path'")
+ sql("insert into temp_v1 values " +
+ "(parse_json('{\"k\": \"a\"}'), parse_json('{\"j\": 1}')), " +
+ "(parse_json('{\"k\": \"a\"}'), parse_json('{\"j\": 2}')), " +
+ "(parse_json('{\"k\": \"b\"}'), parse_json('{\"j\": 3}'))")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ spark.read.parquet(path).createOrReplaceTempView("T_V2")
+ // The grouping expression variant_get(v, '$.k') is pulled into a
Project below the
+ // Aggregate (PullOutGroupingExpressions), so it is visible to the
rewrite and shreds to
+ // the $.k slot -- a real extraction, no full-variant slot and no
bare-v lift. v2 is pruned.
+ val query =
+ "select variant_get(v, '$.k', 'string') as k, count(1) as n " +
+ "from T_V2 group by variant_get(v, '$.k', 'string')"
+ val expectedRows = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect()
+ }
+ checkAnswer(sql(query), expectedRows)
+ val scanRelation =
findScanRelation(sql(query).queryExecution.optimizedPlan)
+ assert(scanRelation.output.map(_.name) == Seq("v"),
+ s"Expected scan output [v] but got
${scanRelation.output.map(_.name)}")
+ val vStruct = scanRelation.output(0).dataType match {
+ case s: StructType => s
+ case other => fail(s"Expected v shredded to struct, but got $other")
+ }
+ assert(!vStruct.fields.exists(_.dataType.isInstanceOf[VariantType]),
+ s"Expected optimal extraction with no full-variant slot, but got
$vStruct")
+ }
+ }
+ }
+
+ test(s"V2 test - distinct variant_get: optimal shred ($readerName)") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ withTable("temp_v1") {
+ sql(s"create table temp_v1 (v variant, v2 variant) using PARQUET
location '$path'")
+ sql("insert into temp_v1 values " +
+ "(parse_json('{\"k\": \"a\"}'), parse_json('{\"j\": 1}')), " +
+ "(parse_json('{\"k\": \"a\"}'), parse_json('{\"j\": 2}')), " +
+ "(parse_json('{\"k\": \"b\"}'), parse_json('{\"j\": 3}'))")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ spark.read.parquet(path).createOrReplaceTempView("T_V2")
+ // DISTINCT becomes an Aggregate whose grouping expression is the
distinct column; like
+ // GROUP BY, the variant_get is pulled below the Aggregate and shreds
to the $.k slot.
+ val query = "select distinct variant_get(v, '$.k', 'string') as k from
T_V2"
+ val expectedRows = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect()
+ }
+ checkAnswer(sql(query), expectedRows)
+ val scanRelation =
findScanRelation(sql(query).queryExecution.optimizedPlan)
+ assert(scanRelation.output(0).dataType.isInstanceOf[StructType],
+ s"Expected v shredded, but got ${scanRelation.output(0).dataType}")
+ }
+ }
+ }
+
+ test(s"V2 test - window order by variant_get: no crash, shredded
($readerName)") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ withTable("temp_v1") {
+ sql(s"create table temp_v1 (v variant, v2 variant) using PARQUET
location '$path'")
+ sql("insert into temp_v1 values " +
+ "(parse_json('{\"k\": \"a\", \"price\": 3}'), parse_json('{\"j\":
1}')), " +
+ "(parse_json('{\"k\": \"a\", \"price\": 1}'), parse_json('{\"j\":
2}')), " +
+ "(parse_json('{\"k\": \"b\", \"price\": 2}'), parse_json('{\"j\":
3}'))")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ spark.read.parquet(path).createOrReplaceTempView("T_V2")
+ // Window is a fourth barrier type. The OVER (ORDER BY variant_get(v,
'$.price')) order key
+ // and the selected variant_get(v, '$.k') are both pushed; the query
must optimize without
+ // crashing and return correct results.
+ val query = "select variant_get(v, '$.k', 'string') as k, " +
+ "row_number() over (order by variant_get(v, '$.price', 'long')) as
rn from T_V2"
+ val expectedRows = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect()
+ }
+ checkAnswer(sql(query), expectedRows)
+ val scanRelation =
findScanRelation(sql(query).queryExecution.optimizedPlan)
+ assert(scanRelation.output.find(_.name ==
"v").get.dataType.isInstanceOf[StructType],
+ "Expected v shredded for the pushed window extractions")
+ }
+ }
+ }
+
+ test(s"V2 test - two variant columns each extracted: both shredded
($readerName)") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ withTable("temp_v1") {
+ sql(s"create table temp_v1 (v variant, v2 variant) using PARQUET
location '$path'")
+ sql("insert into temp_v1 values " +
+ "(parse_json('{\"a\": 1}'), parse_json('{\"b\": 2}')), " +
+ "(parse_json('{\"a\": 9}'), parse_json('{\"b\": 8}'))")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ spark.read.parquet(path).createOrReplaceTempView("T_V2")
+ // Both variant columns carry real extractions on different paths;
each shreds independently
+ // and both remain in the scan.
+ val query =
+ "select variant_get(v, '$.a', 'int') as a, variant_get(v2, '$.b',
'int') as b from T_V2"
+ val expectedRows = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect()
+ }
+ checkAnswer(sql(query), expectedRows)
+ val scanRelation =
findScanRelation(sql(query).queryExecution.optimizedPlan)
+ assert(scanRelation.output.map(_.name).toSet == Set("v", "v2"),
+ s"Expected scan output {v, v2} but got
${scanRelation.output.map(_.name)}")
+ assert(scanRelation.output.forall(_.dataType.isInstanceOf[StructType]),
+ s"Expected both v and v2 shredded, but got " +
+ s"${scanRelation.output.map(a =>
s"${a.name}:${a.dataType.simpleString}")}")
+ }
+ }
+ }
+
+ test(s"V2 test - column pruning: no variant_get, normal pruning applies
($readerName)") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ withTable("temp_v1") {
+ sql(s"create table temp_v1 (v1 variant, v2 variant, s string) using
PARQUET " +
+ s"location '$path'")
+ sql("insert into temp_v1 values " +
+ "(parse_json('{\"a\": 1}'), parse_json('{\"b\": 2}'), 'hello')")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ val df = spark.read.parquet(path)
+ df.createOrReplaceTempView("T_V2")
+ // No variant_get: pushdown does not fire; normal pruneColumns applies.
+ val query = "select s from T_V2"
+ val expectedRows = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect()
+ }
+ checkAnswer(sql(query), expectedRows)
+ sql(query).queryExecution.optimizedPlan match {
+ case scanRelation: DataSourceV2ScanRelation =>
+ assert(scanRelation.output.map(_.name) == Seq("s"),
+ s"Expected scan output [s] but got
${scanRelation.output.map(_.name)}")
+ case Project(_, scanRelation: DataSourceV2ScanRelation) =>
+ assert(scanRelation.output.map(_.name) == Seq("s"),
+ s"Expected scan output [s] but got
${scanRelation.output.map(_.name)}")
+ case other =>
+ fail(s"Unexpected plan shape: ${other.getClass.getName}\n$other")
+ }
+ }
+ }
+ }
+
+ test(s"V2 test - column pruning: 2 variant cols, only second referenced
($readerName)") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ withTable("temp_v1") {
+ sql(s"create table temp_v1 (v1 variant, v2 variant) using PARQUET
location '$path'")
+ sql("insert into temp_v1 values " +
+ "(parse_json('{\"a\": 1}'), parse_json('{\"b\": 99}'))")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ val df = spark.read.parquet(path)
+ df.createOrReplaceTempView("T_V2")
+ val query = "select variant_get(v2, '$.b', 'int') from T_V2"
+ val expectedRows = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect()
+ }
+ checkAnswer(sql(query), expectedRows)
+ sql(query).queryExecution.optimizedPlan match {
+ case Project(_, scanRelation: DataSourceV2ScanRelation) =>
+ assert(scanRelation.output.map(_.name) == Seq("v2"),
+ s"Expected scan output [v2] but got
${scanRelation.output.map(_.name)}")
+ assert(scanRelation.output(0).dataType.isInstanceOf[StructType],
+ s"Expected v2 rewritten to struct, but got
${scanRelation.output(0).dataType}")
+ case other =>
+ fail(s"Unexpected plan shape: ${other.getClass.getName}\n$other")
+ }
+ }
+ }
+ }
+
+ test(s"V2 test - column pruning: 3 variant cols, 2 referenced
($readerName)") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ withTable("temp_v1") {
+ sql(s"create table temp_v1 (v1 variant, v2 variant, v3 variant) using
PARQUET " +
+ s"location '$path'")
+ sql("insert into temp_v1 values " +
+ "(parse_json('{\"a\": 1}'), parse_json('{\"b\": 2}'),
parse_json('{\"c\": 3}'))")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ val df = spark.read.parquet(path)
+ df.createOrReplaceTempView("T_V2")
+ val query = "select variant_get(v1, '$.a', 'int'), variant_get(v3,
'$.c', 'int') from T_V2"
+ val expectedRows = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect()
+ }
+ checkAnswer(sql(query), expectedRows)
+ sql(query).queryExecution.optimizedPlan match {
+ case Project(_, scanRelation: DataSourceV2ScanRelation) =>
+ val names = scanRelation.output.map(_.name).toSet
+ assert(names == Set("v1", "v3"),
+ s"Expected scan output {v1, v3} but got $names")
+ scanRelation.output.foreach { a =>
+ assert(a.dataType.isInstanceOf[StructType],
+ s"Expected ${a.name} rewritten to struct, but got
${a.dataType}")
+ }
+ case other =>
+ fail(s"Unexpected plan shape: ${other.getClass.getName}\n$other")
+ }
+ }
+ }
+ }
+
+ test(s"V2 test - column pruning: 3 variant cols, all 3 referenced
($readerName)") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ withTable("temp_v1") {
+ sql(s"create table temp_v1 (v1 variant, v2 variant, v3 variant) using
PARQUET " +
+ s"location '$path'")
+ sql("insert into temp_v1 values " +
+ "(parse_json('{\"a\": 1}'), parse_json('{\"b\": 2}'),
parse_json('{\"c\": 3}'))")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ val df = spark.read.parquet(path)
+ df.createOrReplaceTempView("T_V2")
+ val query = "select variant_get(v1, '$.a', 'int'), variant_get(v2,
'$.b', 'int'), " +
+ "variant_get(v3, '$.c', 'int') from T_V2"
+ val expectedRows = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect()
+ }
+ checkAnswer(sql(query), expectedRows)
+ sql(query).queryExecution.optimizedPlan match {
+ case Project(_, scanRelation: DataSourceV2ScanRelation) =>
+ val names = scanRelation.output.map(_.name).toSet
+ assert(names == Set("v1", "v2", "v3"),
+ s"Expected all 3 columns in scan but got $names")
+ scanRelation.output.foreach { a =>
+ assert(a.dataType.isInstanceOf[StructType],
+ s"Expected ${a.name} rewritten to struct, but got
${a.dataType}")
+ }
+ case other =>
+ fail(s"Unexpected plan shape: ${other.getClass.getName}\n$other")
+ }
+ }
+ }
+ }
+
+ test(s"V2 test - column pruning: 3 variant cols, none referenced via
variant_get ($readerName)") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ withTable("temp_v1") {
+ sql(s"create table temp_v1 (v1 variant, v2 variant, v3 variant, s
string) " +
+ s"using PARQUET location '$path'")
+ sql("insert into temp_v1 values " +
+ "(parse_json('{\"a\": 1}'), parse_json('{\"b\": 2}'),
parse_json('{\"c\": 3}'), 'hi')")
+ }
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ val df = spark.read.parquet(path)
+ df.createOrReplaceTempView("T_V2")
+ // Only s is selected; no variant_get, so extraction pushdown does not
fire.
+ // Normal pruneColumns removes v1/v2/v3.
+ val query = "select s from T_V2"
+ val expectedRows = withSQLConf(SQLConf.PUSH_VARIANT_INTO_SCAN.key ->
"false") {
+ sql(query).collect()
+ }
+ checkAnswer(sql(query), expectedRows)
+ sql(query).queryExecution.optimizedPlan match {
+ case scanRelation: DataSourceV2ScanRelation =>
+ assert(scanRelation.output.map(_.name) == Seq("s"),
+ s"Expected scan output [s] but got
${scanRelation.output.map(_.name)}")
+ case Project(_, scanRelation: DataSourceV2ScanRelation) =>
+ assert(scanRelation.output.map(_.name) == Seq("s"),
+ s"Expected scan output [s] but got
${scanRelation.output.map(_.name)}")
+ case other =>
+ fail(s"Unexpected plan shape: ${other.getClass.getName}\n$other")
+ }
+ }
+ }
+ }
+
test(s"V2 No push down for JSON ($readerName)") {
withTempPath { dir =>
val path = dir.getCanonicalPath
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]