-
Notifications
You must be signed in to change notification settings - Fork 29.4k
[SPARK-59068][SQL][FOLLOWUP] Correct runtime filter validation and test fixtures #58503
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
Changes from all commits
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 |
|---|---|---|
|
|
@@ -29,7 +29,7 @@ import scala.collection.mutable.{ArrayBuffer, ListBuffer} | |
| import scala.jdk.CollectionConverters._ | ||
|
|
||
| import org.apache.spark.sql.catalyst.InternalRow | ||
| import org.apache.spark.sql.catalyst.expressions.{AttributeReference, BindReferences, Cast, EvalMode, Expression => CatalystExpression, GenericInternalRow, GetStructField, JoinedRow, Literal, MetadataStructFieldWithLogicalName, Predicate => CatalystPredicate} | ||
| import org.apache.spark.sql.catalyst.expressions.{AttributeReference, BoundReference, Cast, EvalMode, Expression => CatalystExpression, GenericInternalRow, GetStructField, JoinedRow, Literal, MetadataStructFieldWithLogicalName, Predicate => CatalystPredicate} | ||
| import org.apache.spark.sql.catalyst.util.{ArrayBasedMapData, ArrayData, CaseInsensitiveMap, CharVarcharUtils, DateTimeUtils, GenericArrayData, MapData, ResolveDefaultColumns} | ||
| import org.apache.spark.sql.connector.catalog.constraints.Constraint | ||
| import org.apache.spark.sql.connector.distributions.{Distribution, Distributions} | ||
|
|
@@ -231,6 +231,10 @@ abstract class InMemoryBaseTable( | |
| } | ||
| } | ||
|
|
||
| protected def identityPartitionReferences: Array[NamedReference] = { | ||
| partitioning.collect { case IdentityTransform(ref) => ref } | ||
| } | ||
|
|
||
| private val UTC = ZoneId.of("UTC") | ||
| private val EPOCH_LOCAL_DATE = Instant.EPOCH.atZone(UTC).toLocalDate | ||
|
|
||
|
|
@@ -513,9 +517,10 @@ abstract class InMemoryBaseTable( | |
| } | ||
|
|
||
| private def canEvaluate(filter: Filter): Boolean = { | ||
| if (partitioning.length == 1 && partitioning.head.references.length == 1) { | ||
| val identityRefs = identityPartitionReferences | ||
| if (partitioning.length == 1 && identityRefs.length == 1) { | ||
| filter match { | ||
| case In(attrName, _) if attrName == partitioning.head.references.head.toString => true | ||
| case In(attrName, _) if attrName == identityRefs.head.toString => true | ||
| case _ => false | ||
| } | ||
| } else { | ||
|
|
@@ -633,6 +638,12 @@ abstract class InMemoryBaseTable( | |
|
|
||
| override def toBatch: Batch = this | ||
|
|
||
| protected def identityPartitionAttributes: Array[NamedReference] = { | ||
| identityPartitionReferences.distinct | ||
| .filter(ref => readSchema.findNestedField( | ||
| ref.fieldNames.toImmutableArraySeq, resolver = SQLConf.get.resolver).isDefined) | ||
| } | ||
|
|
||
| override def estimateStatistics(): Statistics = { | ||
| if (data.isEmpty) { | ||
| return InMemoryStats(OptionalLong.of(0L), OptionalLong.of(0L), new util.HashMap()) | ||
|
|
@@ -717,11 +728,10 @@ abstract class InMemoryBaseTable( | |
|
|
||
| /** | ||
| * Reference implementation of [[SupportsRuntimeCatalystFiltering.filter]] for the in-memory | ||
| * fixtures: records what was pushed, and for expressions referencing only partition columns | ||
| * binds them against the partition key and drops partitions that do not match. Binding and | ||
| * interpreting rather than pattern matching a fixed set of operators is what lets the fixture | ||
| * honor an arbitrary pushed expression, the same way `PartitionPredicateImpl` does. Mixing | ||
| * classes supply their own `filterAttributes()`. | ||
| * fixtures: records what was pushed, and binds expressions referencing only identity partition | ||
| * columns against the partition key to drop partitions that do not match. Interpreting the | ||
| * bound expression lets the fixture honor arbitrary pushed expressions. Mixing classes supply | ||
| * their own `filterAttributes()`. | ||
| */ | ||
| trait CatalystRuntimeFilteringScan extends SupportsRuntimeCatalystFiltering { | ||
| self: BatchScanBaseClass => | ||
|
|
@@ -737,18 +747,15 @@ abstract class InMemoryBaseTable( | |
| filterCalls += 1 | ||
| val partAttrs = partitionAttributes | ||
| if (partAttrs.isEmpty) return | ||
| val partAttrRefs = partAttrs.map(_._2) | ||
|
|
||
| expressions.foreach { expr => | ||
| // Top down, so `s.part` is rewritten before its `s` child is considered. | ||
| val remapped = expr.transformDown { | ||
| case e => partitionAttrFor(e, partAttrs).getOrElse(e) | ||
| } | ||
| // Only evaluate expressions whose refs are all partition columns, so we can bind | ||
| // against the partition key InternalRow (same approach as PartitionPredicateImpl). | ||
| if (remapped.references.forall(r => partAttrRefs.exists(_.exprId == r.exprId))) { | ||
| val bound = BindReferences.bindReference(remapped, partAttrRefs) | ||
| val pred = CatalystPredicate.createInterpreted(bound) | ||
| // Evaluate expressions only when every reference maps to an identity partition-key slot. | ||
| if (remapped.references.isEmpty) { | ||
| val pred = CatalystPredicate.createInterpreted(remapped) | ||
| self.data = self.data.filter { p => | ||
| try { | ||
| pred.eval(p.asInstanceOf[BufferedRows].partitionKey()) | ||
|
|
@@ -772,37 +779,33 @@ abstract class InMemoryBaseTable( | |
| def filterCallCount: Int = filterCalls | ||
|
|
||
| /** | ||
| * The `AttributeReference`s standing for the partition key InternalRow fields, in its field | ||
| * order, each paired with the name-part sequence of its partition column. The parts are kept | ||
| * unflattened so a quoted top-level column `a.b` (parts `Seq("a.b")`) stays distinct from a | ||
| * nested column `a`.`b` (parts `Seq("a", "b")`). Example: | ||
| * - `PARTITIONED BY (part, s.nested)` -> `(Seq("part"), AttributeReference(part))`, then | ||
| * `(Seq("s", "nested"), AttributeReference(s.nested))` | ||
| * Identity partition columns paired with their bound partition-key slots. | ||
| * | ||
| * Only identity transforms expose a source path because their partition-key slot retains the | ||
| * source value. Name parts stay separate so a quoted top-level column `a.b` remains distinct | ||
| * from a nested column `a`.`b`. | ||
| */ | ||
| private def partitionAttributes: Seq[(Seq[String], AttributeReference)] = { | ||
| partitioning.flatMap(_.references()).flatMap { ref => | ||
| val path = ref.fieldNames.toImmutableArraySeq | ||
| val resolver = SQLConf.get.resolver | ||
| readSchema.findNestedField(path, resolver = resolver) | ||
| .orElse(tableSchema.findNestedField(path, resolver = resolver)).map { | ||
| case (_, f) => | ||
| path -> AttributeReference(ref.fieldNames.mkString("."), f.dataType, f.nullable)() | ||
| } | ||
| private def partitionAttributes: Seq[(Seq[String], BoundReference)] = { | ||
| partitioning.zipWithIndex.flatMap { | ||
| case (IdentityTransform(ref), ordinal) => | ||
| val path = ref.fieldNames.toImmutableArraySeq | ||
| val resolver = SQLConf.get.resolver | ||
| readSchema.findNestedField(path, resolver = resolver) | ||
| .orElse(tableSchema.findNestedField(path, resolver = resolver)).map { | ||
| case (_, f) => path -> BoundReference(ordinal, f.dataType, f.nullable) | ||
| } | ||
| case _ => None | ||
| }.toSeq | ||
| } | ||
|
|
||
| /** | ||
| * The partition key `AttributeReference` that `e` reads, or None if `e` reads no partition | ||
| * column. The path `e` reads is compared to each partition column's name parts component-wise | ||
| * with the resolver, so a quoted top-level column `a.b` cannot collide with a nested column | ||
| * `a`.`b`. Examples, under `PARTITIONED BY (part, s.nested)` where `nested` is field 0 of `s`: | ||
| * - `AttributeReference(part)` -> `AttributeReference(part)` | ||
| * - `GetStructField(AttributeReference(s), 0)` -> `AttributeReference(s.nested)` | ||
| * - `AttributeReference(s)` -> None if `s` itself is not a partition column, only `s.nested` | ||
| * The partition-key slot that `e` reads, or None if `e` reads no identity partition column. | ||
| * The path `e` reads is compared to each partition column's name parts component-wise with the | ||
| * resolver, so a quoted top-level column `a.b` cannot collide with a nested column `a`.`b`. | ||
| */ | ||
| private def partitionAttrFor( | ||
| e: CatalystExpression, | ||
| partAttrs: Seq[(Seq[String], AttributeReference)]): Option[AttributeReference] = { | ||
| partAttrs: Seq[(Seq[String], BoundReference)]): Option[BoundReference] = { | ||
| val resolver = SQLConf.get.resolver | ||
| partitionKeyPath(e).flatMap { path => | ||
| partAttrs.collectFirst { | ||
|
|
@@ -841,14 +844,12 @@ abstract class InMemoryBaseTable( | |
| var pushedFilters: Array[Filter] = Array.empty | ||
|
|
||
| override def filterAttributes(): Array[NamedReference] = { | ||
| partitioning.flatMap(_.references) | ||
| .filter(ref => readSchema.findNestedField( | ||
| ref.fieldNames.toImmutableArraySeq, resolver = SQLConf.get.resolver).isDefined) | ||
| identityPartitionAttributes | ||
| } | ||
|
|
||
| override def filter(filters: Array[Filter]): Unit = { | ||
| if (partitioning.length == 1 && partitioning.head.references().length == 1) { | ||
| val ref = partitioning.head.references().head | ||
| if (partitioning.length == 1 && identityPartitionReferences.length == 1) { | ||
|
Member
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.
Example: Could we make
Member
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. Fixed in 5cd4730. |
||
| val ref = identityPartitionReferences.head | ||
| filters.foreach { | ||
| case In(attrName, values) if attrName == ref.toString => | ||
| val matchingKeys = values.map { value => | ||
|
|
||
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.
This name-only membership check runs before
fullyPushedFilterAttributes()is resolved against the output. A fully pushed reference that does not exist at all is therefore reported asNOT_IN_FILTER_ATTRIBUTES("must also be returned byfilterAttributes()") rather thanCANNOT_RESOLVE. That is exactly theMissingFullyPushedFilterAttributeScancase, whose expectation was flipped in this PR and whosegetCauseassertion was dropped.A connector author following that message would add
missingtofilterAttributes()and only then getCANNOT_RESOLVEon the next run. Checking resolvability of the fully pushed refs before the membership check (or resolving both lists first) would surface the root cause in one round and let the original test expectation stand.Uh oh!
There was an error while loading. Please reload this page.
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.
Fixed in 5cd4730. Fully-pushed references are now checked for the top-level constraint, resolved against the output, and only then checked for exact membership after ordinary filter attributes are also resolved. The missing-reference test again expects
CANNOT_RESOLVEand checks its underlying resolution cause.