szehon-ho commented on code in PR #58145:
URL: https://github.com/apache/spark/pull/58145#discussion_r3867318645


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2Relation.scala:
##########
@@ -187,6 +193,7 @@ case class DataSourceV2ScanRelation(
     keyGroupedPartitioning: Option[Seq[Expression]] = None,
     ordering: Option[Seq[SortOrder]] = None,
     pushedFilters: Seq[Expression] = Seq.empty,
+    advisoryFilters: Seq[Expression] = Seq.empty,

Review Comment:
   Done in 319bffae37f. PlanMerger now preserves advisory provenance across a 
scan merge: it requires both scans to carry equal advisory sets, offers them to 
the fresh builder, and re-validates that the rebuilt scan fully pushes the 
strict and advisory filters (otherwise the merge is declined). The inherited 
strict/advisory metadata is restored so DataSourceV2Strategy still removes the 
logical Filter and a later merge round accepts an equivalent scan. Added 
scan-rebuild regression tests in MergeSubplansSuite (SPARK-58892).



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanRelationPushDown.scala:
##########
@@ -126,6 +127,10 @@ object V2ScanRelationPushDown extends Rule[LogicalPlan] 
with PredicateHelper {
         sHolder.pushedPredicates.mkString(", ")
       }
 
+      sHolder.advisoryFilterExpressions = getAdvisoryFilters(sHolder)

Review Comment:
   Done in 319bffae37f. Advisory filters are now collected only when the 
Catalyst pushFilters callback actually dispatched (added 
PushDownUtils.dispatchesToCatalystFilters, which encodes the 
SupportsPushDownFilters / SupportsPushDownV2Filters precedence) and an eligible 
non-subquery filter was pushed (normalizedFiltersWithoutSubquery.nonEmpty). 
Covered both cases in DataSourceV2Suite: a builder mixing 
SupportsPushDownV2Filters + SupportsPushDownCatalystFilters, and a 
subquery-only filter.



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2TableSampleSuite.scala:
##########
@@ -326,4 +333,49 @@ class DataSourceV2TableSampleSuite extends 
DatasourceV2SQLBase
       sql(s"DROP TABLE IF EXISTS $tableName")
     }
   }
+
+  test("advisory filters do not block TABLESAMPLE pushdown") {
+    registerCatalog("testsampleadvisory", 
classOf[InMemoryTableWithTableSampleAndAdvisoryCatalog])
+    val table = "testsampleadvisory.ns.sample_tbl"
+    val advisory = "id = 1L"

Review Comment:
   Done, changed the fixture advisory to `id > 0L`, which is implied by `WHERE 
id >= 1`.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2Strategy.scala:
##########
@@ -126,8 +126,17 @@ class DataSourceV2Strategy(session: SparkSession) extends 
Strategy with Predicat
     tableSpec.withNewLocation(newLoc)
   }
 
+  private def removeNotEvaluatedFilters(
+      filters: Seq[Expression],
+      relation: DataSourceV2ScanRelation,
+      otherNotEvaluatedFilters: Seq[Expression] = Nil): Seq[Expression] = {
+    val notEvaluatedFilterSet =
+      ExpressionSet(otherNotEvaluatedFilters ++ relation.advisoryFilters)
+    filters.filterNot(notEvaluatedFilterSet.contains)

Review Comment:
   Done. removeNotEvaluatedFilters now returns the filters unchanged when both 
exclusion sequences are empty, avoiding ExpressionSet canonicalization on the 
ordinary V2 path.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanRelationPushDown.scala:
##########
@@ -126,6 +127,10 @@ object V2ScanRelationPushDown extends Rule[LogicalPlan] 
with PredicateHelper {
         sHolder.pushedPredicates.mkString(", ")
       }
 
+      sHolder.advisoryFilterExpressions = getAdvisoryFilters(sHolder)
+      // Keep advisory filters off the plan until the other source pushdowns 
have run. Their
+      // matchers require that no Spark-side filters remain, but advisory 
filters do not need to be

Review Comment:
   Done, reworded to "interactions with Spark-side filters vary, but advisory 
filters do not need to be evaluated ...".



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2Suite.scala:
##########
@@ -1880,26 +2037,168 @@ class CatalystFilterDataSourceV2 extends 
TestingV2Source {
 
   override def getTable(options: CaseInsensitiveStringMap): Table = new 
SimpleBatchTable {
     override def newScanBuilder(options: CaseInsensitiveStringMap): 
ScanBuilder = {
-      new CatalystFilterScanBuilder()
+      new CatalystFilterScanBuilder(options)
+    }
+  }
+}
+
+class CatalystFilterJoinDataSourceV2 extends TestingV2Source {
+
+  override def getTable(options: CaseInsensitiveStringMap): Table = new 
SimpleBatchTable {
+    override def newScanBuilder(options: CaseInsensitiveStringMap): 
ScanBuilder = {
+      new CatalystFilterJoinScanBuilder(options)
+    }
+  }
+}
+
+class CatalystFilterJoinScanBuilder(options: CaseInsensitiveStringMap)
+  extends CatalystFilterScanBuilder(options) with SupportsPushDownJoin {
+
+  private var joinedSchema: Option[StructType] = None
+
+  override def isOtherSideCompatibleForJoin(other: SupportsPushDownJoin): 
Boolean =
+    other.isInstanceOf[CatalystFilterJoinScanBuilder]
+
+  override def pushDownJoin(
+      other: SupportsPushDownJoin,
+      joinType: V2JoinType,
+      leftColumns: Array[ColumnWithAlias],
+      rightColumns: Array[ColumnWithAlias],
+      condition: Predicate): Boolean = {
+    def fields(columns: Array[ColumnWithAlias]): Array[StructField] = 
columns.map { col =>
+      val name = if (col.alias() != null) col.alias() else col.colName()
+      TestingV2Source.schema(col.colName()).copy(name = name)
+    }
+    joinedSchema = Some(StructType(fields(leftColumns) ++ 
fields(rightColumns)))
+    true
+  }
+
+  override def readSchema(): StructType = 
joinedSchema.getOrElse(super.readSchema())
+}
+
+class CatalystFilterLimitDataSourceV2 extends TestingV2Source {
+
+  override def getTable(options: CaseInsensitiveStringMap): Table = new 
SimpleBatchTable {
+    override def newScanBuilder(options: CaseInsensitiveStringMap): 
ScanBuilder = {
+      new CatalystFilterLimitScanBuilder(options)
+    }
+  }
+}
+
+class CatalystFilterLimitScanBuilder(options: CaseInsensitiveStringMap)
+  extends CatalystFilterScanBuilder(options) with SupportsPushDownLimit {
+
+  private var pushedLimit: Option[Int] = None
+
+  override def pushLimit(limit: Int): Boolean = {
+    pushedLimit = Some(limit)
+    true
+  }
+
+  override def isPartiallyPushed: Boolean = false
+
+  override def planInputPartitions(): Array[InputPartition] = {
+    val partitions = super.planInputPartitions()
+    pushedLimit match {
+      case Some(n) =>
+        val values = partitions.flatMap {
+          case ValuesInputPartition(vs) => vs
+          case RangeInputPartition(start, end) => start until end
+          case other =>
+            throw new IllegalArgumentException(s"Unexpected partition: $other")
+        }.take(n)
+        Array(ValuesInputPartition(values.toSeq))
+      case None =>
+        partitions
     }
   }
+
+  override def createReaderFactory(): PartitionReaderFactory = {
+    if (pushedLimit.isDefined) ValuesReaderFactory else 
super.createReaderFactory()
+  }
 }
 
-class CatalystFilterScanBuilder extends SimpleScanBuilder
+class CatalystFilterScanBuilder(options: CaseInsensitiveStringMap) extends 
SimpleScanBuilder
   with SupportsPushDownCatalystFilters {
 
+  private var pushedCatalystFilters = Seq.empty[CatalystExpression]
+  private val deriveAdvisory = CatalystFilterScanBuilder.derivation(options)
+
   override def pushFilters(filters: Seq[CatalystExpression]): 
Seq[CatalystExpression] = {
     if (filters.exists(!_.deterministic)) {
       throw new IllegalArgumentException(
         s"Non-deterministic filters should not be pushed: 
${filters.mkString(", ")}")
     }
+    pushedCatalystFilters = filters
     Nil
   }
 
+  override def advisoryFilters: Seq[CatalystExpression] = 
deriveAdvisory(pushedCatalystFilters)
+
   override def pushedFilters: Array[Predicate] = Array.empty
 
   override def planInputPartitions(): Array[InputPartition] = {
-    throw new IllegalArgumentException("planInputPartitions must not be 
called")
+    // Spark never evaluates an advisory filter, so the source has to apply it 
itself.
+    val enforcedFilters = pushedCatalystFilters ++ advisoryFilters
+    enforcedFilters.reduceLeftOption(CatalystAnd) match {
+      case Some(filter) =>
+        val attrs = DataTypeUtils.toAttributes(readSchema())
+        val bound = filter.transformUp {
+          case attr: AttributeReference =>
+            attrs.find(_.name == attr.name).getOrElse(
+              throw new IllegalArgumentException(s"Unknown column: 
${attr.name}"))
+        }
+        val predicate = CatalystPredicate.createInterpreted(
+          BindReferences.bindReference(bound, attrs))
+        Array(ValuesInputPartition((0 until 10).filter { i =>
+          predicate.eval(InternalRow(i, -i))
+        }))
+      case None =>
+        Array(RangeInputPartition(0, 5), RangeInputPartition(5, 10))
+    }
+  }
+
+  override def createReaderFactory(): PartitionReaderFactory = {
+    if (pushedCatalystFilters.nonEmpty) ValuesReaderFactory else 
SimpleReaderFactory
+  }
+}
+
+object CatalystFilterScanBuilder {
+  val ADVISORY_DERIVATION: String = "advisoryDerivation"
+  val NEGATE_I_TO_J: String = "negate-i-to-j"
+
+  // Common derivation used by advisory-filter tests. Rows are (i, j) with j = 
-i, so a
+  // pushed predicate on i implies the negated predicate on j.

Review Comment:
   Done, renamed to "pushed predicate on i implies the corresponding predicate 
on j after negating both sides."



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to