Skip to content

Commit d4bfb94

Browse files
committed
[SPARK-59410][SQL] Derive PartitionPredicate from identity fields of a mixed partitioning
PushDownUtils.getPartitionPredicateSchema returned a schema only when every transform in Table.partitioning() is an identity transform, so a table partitioned by e.g. dt (identity) and bucket(16, user_id) never received a PartitionPredicate, and a Catalyst-only filter on dt such as the cast(dt AS DATE) = DATE'...' produced by type coercion could not prune partitions in the static pass, via DPP, or in a metadata-only DELETE. The schema now has one field per transform, in partitioning order. Identity fields carry an attribute a filter can reference; other fields have none but keep their ordinal, so a predicate still binds against the full partition key and the connector contract is unchanged. A filter on the source column of a non-identity transform stays a data filter. A partitioning with no identity transform still yields no schema. The in-memory V2 filter test table now accepts only column-vs-literal predicates and returns anything else, e.g. a predicate over a cast, as a real connector would. Assisted-by: Claude Fable 5.1
1 parent 5f8de75 commit d4bfb94

8 files changed

Lines changed: 285 additions & 30 deletions

File tree

sql/catalyst/src/main/scala/org/apache/spark/sql/internal/connector/PartitionPredicateField.scala

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,19 @@ package org.apache.spark.sql.internal.connector
2020
import org.apache.spark.sql.catalyst.expressions.AttributeReference
2121

2222
/**
23-
* Metadata for one partition field.
23+
* Metadata for one field of `Table.partitioning()`. A partition predicate is built over the
24+
* fields in partitioning order, so their ordinals match the partition key a connector passes to
25+
* `PartitionPredicate.eval`.
2426
*
2527
* @param fieldNames the multi-part field name from the table's partitioning
26-
* (e.g. `Seq("s", "tz")`).
27-
* @param attrRef the [[AttributeReference]] for the partition field.
28-
* Created from the resolved partition field so it carries the
29-
* flattened dotted name (e.g. `"s.tz"`) for nested fields.
28+
* (e.g. `Seq("s", "tz")`) for an identity transform, or the transform's
29+
* description (e.g. `Seq("bucket(4, id)")`) otherwise.
30+
* @param attrRef the [[AttributeReference]] a filter can reference, for an identity transform.
31+
* Created from the resolved partition field so it carries the flattened dotted
32+
* name (e.g. `"s.tz"`) for nested fields. None for any other transform: Spark
33+
* cannot evaluate a filter against its partition value, so no filter references
34+
* it, but the field keeps its ordinal.
3035
*/
3136
case class PartitionPredicateField(
3237
fieldNames: Seq[String],
33-
attrRef: AttributeReference)
38+
attrRef: Option[AttributeReference])

sql/catalyst/src/main/scala/org/apache/spark/sql/internal/connector/PartitionPredicateImpl.scala

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ package org.apache.spark.sql.internal.connector
1919

2020
import org.apache.spark.internal.{Logging, LogKeys}
2121
import org.apache.spark.sql.catalyst.InternalRow
22-
import org.apache.spark.sql.catalyst.expressions.{BindReferences, Expression => CatalystExpression, ExprId, Predicate => CatalystPredicate}
22+
import org.apache.spark.sql.catalyst.expressions.{AttributeReference, BindReferences, Expression => CatalystExpression, ExprId, Predicate => CatalystPredicate}
2323
import org.apache.spark.sql.connector.expressions.NamedReference
2424
import org.apache.spark.sql.connector.expressions.filter.PartitionPredicate
25+
import org.apache.spark.sql.types.NullType
2526

2627
/**
2728
* An implementation for [[PartitionPredicate]] that wraps a Catalyst Expression representing a
@@ -32,15 +33,24 @@ class PartitionPredicateImpl private (
3233
private val partitionFields: Seq[PartitionPredicateField])
3334
extends PartitionPredicate with Logging {
3435

36+
/** Ordinal of each identity partition field, keyed by the attribute a filter references. */
3537
@transient private lazy val exprIdToIndex: Map[ExprId, Int] =
36-
partitionFields.zipWithIndex.map { case (f, i) => f.attrRef.exprId -> i }.toMap
38+
partitionFields.zipWithIndex.collect {
39+
case (PartitionPredicateField(_, Some(attr)), i) => attr.exprId -> i
40+
}.toMap
3741

3842
/** The wrapped partition filter Catalyst Expression. */
3943
def expression: CatalystExpression = catalystExpr
4044

4145
/** Bound predicate, computed once and reused for all partition rows. */
4246
@transient private lazy val boundPredicate: InternalRow => Boolean = {
43-
val boundExpr = BindReferences.bindReference(catalystExpr, partitionFields.map(_.attrRef))
47+
// One attribute per partition field, so that ordinals match the full partition key. A field
48+
// of a non-identity transform has no attribute a filter can reference; a placeholder keeps
49+
// its slot.
50+
val input = partitionFields.map { f =>
51+
f.attrRef.getOrElse(AttributeReference(f.fieldNames.mkString("."), NullType)())
52+
}
53+
val boundExpr = BindReferences.bindReference(catalystExpr, input)
4454
val predicate = CatalystPredicate.createInterpreted(boundExpr)
4555
predicate.eval
4656
}
@@ -102,7 +112,7 @@ object PartitionPredicateImpl extends Logging {
102112
return None
103113
}
104114

105-
val partitionExprIds = partitionFields.map(_.attrRef.exprId).toSet
115+
val partitionExprIds = partitionFields.flatMap(_.attrRef).map(_.exprId).toSet
106116
val unmatchedRefs = catalystExpr.references.filterNot(r => partitionExprIds.contains(r.exprId))
107117
if (unmatchedRefs.nonEmpty) {
108118
logWarning(

sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTableWithV2Filter.scala

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -203,14 +203,23 @@ object InMemoryTableWithV2Filter {
203203
}
204204
}
205205

206+
/**
207+
* Whether every predicate has a shape [[evalPredicate]] can evaluate: a plain column, or a
208+
* column and a literal. A predicate over an expression, e.g. a cast, is not supported and
209+
* returned to Spark, as a real connector without expression support would do.
210+
*/
206211
def supportsPredicates(predicates: Array[Predicate]): Boolean = {
207-
predicates.flatMap(splitAnd).forall {
208-
case p: Predicate if p.name().equals("=") => true
209-
case p: Predicate if p.name().equals("<=>") => true
210-
case p: Predicate if p.name().equals("IS_NULL") => true
211-
case p: Predicate if p.name().equals("IS_NOT_NULL") => true
212-
case p: Predicate if p.name().equals("ALWAYS_TRUE") => true
213-
case _ => false
212+
predicates.flatMap(splitAnd).forall { p =>
213+
def column = p.children().length == 1 && p.children()(0).isInstanceOf[NamedReference]
214+
def columnAndLiteral = p.children().length == 2 &&
215+
p.children()(0).isInstanceOf[NamedReference] &&
216+
p.children()(1).isInstanceOf[LiteralValue[_]]
217+
p.name() match {
218+
case "=" | "<=>" => columnAndLiteral
219+
case "IS_NULL" | "IS_NOT_NULL" => column
220+
case "ALWAYS_TRUE" => true
221+
case _ => false
222+
}
214223
}
215224
}
216225

sql/catalyst/src/test/scala/org/apache/spark/sql/internal/connector/PartitionPredicateImplSuite.scala

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,38 @@ class PartitionPredicateImplSuite extends SparkFunSuite {
5252
checkNestedPartitionPathReferencesAfterSerialization(serializer)
5353
}
5454

55+
test("non-identity partition field: predicate binds by ordinal and never references it") {
56+
val ref = DataTypeUtils.toAttribute(StructField("p", StringType, nullable = true))
57+
val fields = Seq(
58+
PartitionPredicateField(Seq("bucket(4, id)"), None),
59+
PartitionPredicateField(Seq("p"), Some(ref)))
60+
val predicate = PartitionPredicateImpl(GreaterThan(ref, Literal("m")), fields).get
61+
62+
// The partition key carries one value per field; the bucket value at ordinal 0 is skipped.
63+
assert(predicate.eval(InternalRow(3, UTF8String.fromString("z"))) === true)
64+
assert(predicate.eval(InternalRow(3, UTF8String.fromString("a"))) === false)
65+
assert(refsWithOrdinals(predicate.references.toSeq) === Seq(("p", 1)))
66+
67+
// A filter on the source column of the bucket transform has no field to bind to.
68+
val id = DataTypeUtils.toAttribute(StructField("id", IntegerType, nullable = true))
69+
assert(PartitionPredicateImpl(GreaterThan(id, Literal(1)), fields).isEmpty)
70+
71+
Seq(new JavaSerializer(new SparkConf()), new KryoSerializer(new SparkConf())).foreach { s =>
72+
val serializer = s.newInstance()
73+
val deserialized = serializer.deserialize[PartitionPredicateImpl](
74+
serializer.serialize(predicate))
75+
assert(deserialized.eval(InternalRow(3, UTF8String.fromString("z"))) === true)
76+
assert(deserialized.eval(InternalRow(3, UTF8String.fromString("a"))) === false)
77+
assert(refsWithOrdinals(deserialized.references.toSeq) === Seq(("p", 1)))
78+
assert(deserialized.equals(predicate))
79+
}
80+
}
81+
5582
private def checkPartitionPredicateImplAfterSerialization(
5683
serializer: SerializerInstance): Unit = {
5784
val ref = DataTypeUtils.toAttribute(StructField("p", IntegerType, nullable = true))
5885
val expr = GreaterThan(ref, Literal(5))
59-
val fields = Seq(PartitionPredicateField(Seq("p"), ref))
86+
val fields = Seq(PartitionPredicateField(Seq("p"), Some(ref)))
6087
val predicate = PartitionPredicateImpl(expr, fields).get
6188

6289
val deserialized = serializer.deserialize[PartitionPredicateImpl](
@@ -77,7 +104,7 @@ class PartitionPredicateImplSuite extends SparkFunSuite {
77104
serializer: SerializerInstance): Unit = {
78105
val ref = DataTypeUtils.toAttribute(StructField("ts.timezone", StringType, nullable = false))
79106
val expr = GreaterThan(ref, Literal("x"))
80-
val fields = Seq(PartitionPredicateField(Seq("ts", "timezone"), ref))
107+
val fields = Seq(PartitionPredicateField(Seq("ts", "timezone"), Some(ref)))
81108
val predicate = PartitionPredicateImpl(expr, fields).get
82109

83110
val deserialized = serializer.deserialize[PartitionPredicateImpl](

sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/PushDownUtils.scala

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -367,26 +367,33 @@ object PushDownUtils extends Logging {
367367
}
368368

369369
/**
370-
* Returns a Seq of [[PartitionPredicateField]] representing partition transform expression types,
371-
* if schema is supported for [[PartitionPredicate]] push down. None if not supported.
370+
* Returns one [[PartitionPredicateField]] per transform of `relation.table.partitioning`, if
371+
* the partitioning supports [[PartitionPredicate]] push down. None if not supported.
372372
*/
373373
def getPartitionPredicateSchema(relation: DataSourceV2Relation)
374374
: Option[Seq[PartitionPredicateField]] = {
375375
getPartitionPredicateSchema(relation.table, relation.output)
376376
}
377377

378378
/**
379-
* Returns a Seq of [[PartitionPredicateField]] representing partition transform expression types,
380-
* if schema is supported for [[PartitionPredicate]] push down. None if not supported.
379+
* Returns one [[PartitionPredicateField]] per transform of `table.partitioning`, if the
380+
* partitioning supports [[PartitionPredicate]] push down. None if not supported.
381381
*/
382382
def getPartitionPredicateSchema(table: Table, output: Seq[AttributeReference])
383383
: Option[Seq[PartitionPredicateField]] = {
384384
getPartitionPredicateSchema(table.partitioning, output)
385385
}
386386

387387
/**
388-
* Returns a Seq of [[PartitionPredicateField]] representing partition transform expression types,
389-
* if schema is supported for [[PartitionPredicate]] push down. None if not supported.
388+
* Returns one [[PartitionPredicateField]] per transform, in partitioning order, if the
389+
* partitioning supports [[PartitionPredicate]] push down. None if not supported.
390+
*
391+
* Only an identity transform yields a field with an attribute, so only filters over identity
392+
* partition columns become partition predicates. Any other transform is kept as a field without
393+
* an attribute: Spark cannot evaluate a filter against its partition value, but the field must
394+
* keep its ordinal since a predicate is evaluated against the full partition key. The
395+
* partitioning is not supported when it is empty, has no identity transform, or has an identity
396+
* transform that does not resolve against `output`.
390397
*
391398
* Use this overload when the caller has access to the partition transforms but not the
392399
* full [[Table]].
@@ -402,11 +409,11 @@ object PushDownUtils extends Logging {
402409
val fields = transforms.flatMap {
403410
case t: IdentityTransform =>
404411
resolveIdentityPartitionField(t, rootStruct).map { sf =>
405-
PartitionPredicateField(t.ref.fieldNames().toSeq, DataTypeUtils.toAttribute(sf))
412+
PartitionPredicateField(t.ref.fieldNames().toSeq, Some(DataTypeUtils.toAttribute(sf)))
406413
}
407-
case _ => None
414+
case t => Some(PartitionPredicateField(Seq(t.describe()), None))
408415
}
409-
if (fields.length == transforms.length) {
416+
if (fields.length == transforms.length && fields.exists(_.attrRef.isDefined)) {
410417
Some(fields.toSeq)
411418
} else {
412419
None
@@ -451,7 +458,7 @@ object PushDownUtils extends Logging {
451458
flattenedFilters: Seq[Expression],
452459
partitionFields: Seq[PartitionPredicateField])
453460
: (Seq[PartitionPredicateImpl], Seq[Expression]) = {
454-
val partitionAttributes = partitionFields.map(_.attrRef)
461+
val partitionAttributes = partitionFields.flatMap(_.attrRef)
455462
val (partFilters, nonPartitionFilters) =
456463
DataSourceUtils.getPartitionFiltersAndDataFilters(partitionAttributes, flattenedFilters)
457464
val (pushable, nonPushable) = partFilters.partition(isPushablePartitionFilter(_))
@@ -539,7 +546,9 @@ object PushDownUtils extends Logging {
539546
filters: Seq[Expression],
540547
partitionFields: Seq[PartitionPredicateField])
541548
: Map[Expression, Expression] = {
542-
val pathToAttr = partitionFields.map(f => f.fieldNames -> f.attrRef).toMap
549+
val pathToAttr = partitionFields.collect {
550+
case PartitionPredicateField(names, Some(attr)) => names -> attr
551+
}.toMap
543552
filters.map(f => doNormalizePartitionFilters(f, pathToAttr) -> f).toMap
544553
}
545554

sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2EnhancedDeleteFilterSuite.scala

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,50 @@ class DataSourceV2EnhancedDeleteFilterSuite extends SharedSparkSession {
184184
}
185185
}
186186

187+
// Mixed partitioning: the bucket field keeps its ordinal but is never referenced, so the
188+
// IN on the identity column still becomes a PartitionPredicate over the full partition key.
189+
test("second pass accepted: identity column next to a bucket transform") {
190+
withTable(deleteTableName) {
191+
sql(s"CREATE TABLE $deleteTableName (pk INT, dep STRING, salary INT) " +
192+
s"USING $v2Source PARTITIONED BY (dep, bucket(4, pk))")
193+
sql(s"INSERT INTO $deleteTableName VALUES " +
194+
"(1, 'hr', 100), (2, 'software', 200), (3, 'marketing', 300)")
195+
196+
assertDeleteWithFilters(
197+
s"DELETE FROM $deleteTableName WHERE dep IN ('hr', 'software')",
198+
expectedNumConditions = 1,
199+
expectedNumPartitionPredicates = 1,
200+
expectedOrdinalsPerPredicate = Seq(Array(0)),
201+
expectedPartitionFieldNames = Array("dep", "bucket(4, pk)"))
202+
203+
checkAnswer(
204+
sql(s"SELECT * FROM $deleteTableName"),
205+
Row(3, "marketing", 300) :: Nil)
206+
}
207+
}
208+
209+
// `dt = DATE'...'` is analyzed as `cast(dt AS DATE) = DATE'...'`, which the table cannot
210+
// evaluate in the first pass; the second pass turns it into a PartitionPredicate.
211+
test("second pass accepted: cast on a string identity column next to a bucket transform") {
212+
withTable(deleteTableName) {
213+
sql(s"CREATE TABLE $deleteTableName (pk INT, dt STRING, salary INT) " +
214+
s"USING $v2Source PARTITIONED BY (dt, bucket(4, pk))")
215+
sql(s"INSERT INTO $deleteTableName VALUES " +
216+
"(1, '2026-09-01', 100), (2, '2026-09-02', 200), (3, '2026-09-03', 300)")
217+
218+
assertDeleteWithFilters(
219+
s"DELETE FROM $deleteTableName WHERE dt = DATE'2026-09-02'",
220+
expectedNumConditions = 1,
221+
expectedNumPartitionPredicates = 1,
222+
expectedOrdinalsPerPredicate = Seq(Array(0)),
223+
expectedPartitionFieldNames = Array("dt", "bucket(4, pk)"))
224+
225+
checkAnswer(
226+
sql(s"SELECT * FROM $deleteTableName"),
227+
Seq(Row(1, "2026-09-01", 100), Row(3, "2026-09-03", 300)))
228+
}
229+
}
230+
187231
// Table property disables PartitionPredicate acceptance;
188232
// both passes rejected, falls back to row-level operation.
189233
test("first and second pass rejected: table rejects all") {

sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2EnhancedPartitionFilterSuite.scala

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,102 @@ class DataSourceV2EnhancedPartitionFilterSuite
403403
}
404404
}
405405

406+
test("mixed partitioning: second-pass PartitionPredicate on the identity field") {
407+
withTable(partFilterTableName) {
408+
sql(s"CREATE TABLE $partFilterTableName (part_col string, id int, data string) " +
409+
s"USING $v2Source PARTITIONED BY (part_col, bucket(4, id))")
410+
sql(s"INSERT INTO $partFilterTableName VALUES ('a', 1, 'x'), ('A', 2, 'y'), ('b', 3, 'z')")
411+
412+
spark.udf.register("my_upper", (s: String) =>
413+
if (s == null) null else s.toUpperCase(Locale.ROOT))
414+
415+
// Untranslatable, Partition Filter on the identity field; 2nd Pass Accepted.
416+
// The bucket field keeps ordinal 1 but is never referenced.
417+
val df = sql(s"SELECT * FROM $partFilterTableName WHERE my_upper(part_col) = 'A'")
418+
checkAnswer(df, Seq(Row("a", 1, "x"), Row("A", 2, "y")))
419+
assertPushedPartitionPredicates(df, 1)
420+
assertScanReturnsPartitionKeys(df, Set("a/1", "A/2"))
421+
assertReferencedPartitionFieldOrdinals(df, Array(0), Array("part_col", "bucket(4, id)"))
422+
}
423+
}
424+
425+
test("mixed partitioning: filter on the source column of a bucket transform stays post-scan") {
426+
withTable(partFilterTableName) {
427+
sql(s"CREATE TABLE $partFilterTableName (part_col string, id int, data string) " +
428+
s"USING $v2Source PARTITIONED BY (part_col, bucket(4, id))")
429+
sql(s"INSERT INTO $partFilterTableName VALUES ('a', 1, 'x'), ('A', 2, 'y'), ('b', 3, 'z')")
430+
431+
spark.udf.register("my_upper", (s: String) =>
432+
if (s == null) null else s.toUpperCase(Locale.ROOT))
433+
spark.udf.register("my_plus1", (i: Int) => i + 1)
434+
435+
// Only the identity conjunct becomes a PartitionPredicate. Spark cannot evaluate the
436+
// bucket conjunct against the partition key, so it is a data filter applied after the scan.
437+
val df = sql(s"SELECT * FROM $partFilterTableName " +
438+
"WHERE my_upper(part_col) = 'A' AND my_plus1(id) = 3")
439+
checkAnswer(df, Seq(Row("A", 2, "y")))
440+
assertPushedPartitionPredicates(df, 1)
441+
assertScanReturnsPartitionKeys(df, Set("a/1", "A/2"))
442+
assertReferencedPartitionFieldOrdinals(df, Array(0), Array("part_col", "bucket(4, id)"))
443+
assert(df.queryExecution.executedPlan.exists(_.isInstanceOf[FilterExec]),
444+
"Filter on the bucket source column should remain as a post-scan Filter")
445+
}
446+
}
447+
448+
test("mixed partitioning: no identity transform -> no PartitionPredicate") {
449+
withTable(partFilterTableName) {
450+
sql(s"CREATE TABLE $partFilterTableName (id int, data string) " +
451+
s"USING $v2Source PARTITIONED BY (bucket(4, id))")
452+
sql(s"INSERT INTO $partFilterTableName VALUES (1, 'x'), (2, 'y'), (3, 'z')")
453+
454+
spark.udf.register("my_plus1", (i: Int) => i + 1)
455+
456+
val df = sql(s"SELECT * FROM $partFilterTableName WHERE my_plus1(id) = 3")
457+
checkAnswer(df, Seq(Row(2, "y")))
458+
assertPushedPartitionPredicates(df, 0)
459+
assertScanReturnsPartitionKeys(df, Set("1", "2", "3"))
460+
}
461+
}
462+
463+
test("mixed partitioning: identity field after a bucket transform -> ordinal 1") {
464+
withTable(partFilterTableName) {
465+
sql(s"CREATE TABLE $partFilterTableName (part_col string, id int, data string) " +
466+
s"USING $v2Source PARTITIONED BY (bucket(4, id), part_col)")
467+
sql(s"INSERT INTO $partFilterTableName VALUES ('a', 1, 'x'), ('A', 2, 'y'), ('b', 3, 'z')")
468+
469+
spark.udf.register("my_upper_second", (s: String) =>
470+
if (s == null) null else s.toUpperCase(Locale.ROOT))
471+
472+
// The bucket field at ordinal 0 keeps its slot, so `part_col` binds to the second
473+
// partition-key value and the reference reports ordinal 1.
474+
val df = sql(s"SELECT * FROM $partFilterTableName WHERE my_upper_second(part_col) = 'A'")
475+
checkAnswer(df, Seq(Row("a", 1, "x"), Row("A", 2, "y")))
476+
assertPushedPartitionPredicates(df, 1)
477+
assertScanReturnsPartitionKeys(df, Set("1/a", "2/A"))
478+
assertReferencedPartitionFieldOrdinals(
479+
df, Array(1), Array("bucket(4, id)", "part_col"))
480+
}
481+
}
482+
483+
test("mixed partitioning: cast from type coercion on the identity field is pruned by " +
484+
"the second pass") {
485+
withTable(partFilterTableName) {
486+
sql(s"CREATE TABLE $partFilterTableName (dt string, id int, data string) " +
487+
s"USING $v2Source PARTITIONED BY (dt, bucket(4, id))")
488+
sql(s"INSERT INTO $partFilterTableName VALUES " +
489+
"('2026-09-01', 1, 'x'), ('2026-09-02', 2, 'y'), ('2026-09-03', 3, 'z')")
490+
491+
// `dt = DATE'...'` is analyzed as `cast(dt AS DATE) = DATE'...'`. The source cannot
492+
// evaluate a predicate over a cast and returns it in the first pass; the second pass
493+
// evaluates it against the `dt` value of the full partition key.
494+
val df = sql(s"SELECT * FROM $partFilterTableName WHERE dt = DATE'2026-09-02'")
495+
checkAnswer(df, Seq(Row("2026-09-02", 2, "y")))
496+
assertPushedPartitionPredicates(df, 1)
497+
assertScanReturnsPartitionKeys(df, Set("2026-09-02/2"))
498+
assertReferencedPartitionFieldOrdinals(df, Array(0), Array("dt", "bucket(4, id)"))
499+
}
500+
}
501+
406502
test("non-deterministic partition filter not pushed as PartitionPredicate") {
407503
// Same checks as FileSourceStrategy/PruneFileSourcePartitions: non-deterministic
408504
// partition filters must not be pushed as PartitionPredicate; they are applied after scan.

0 commit comments

Comments
 (0)