Skip to content
Open
23 changes: 23 additions & 0 deletions common/utils/src/main/resources/error/error-conditions.json
Original file line number Diff line number Diff line change
Expand Up @@ -2212,6 +2212,29 @@
],
"sqlState" : "KD010"
},
"DATA_SOURCE_INVALID_RUNTIME_FILTER_ATTRIBUTE" : {
"message" : [
"The runtime filter attribute <attribute> reported by `<method>` in data source scan <scanClass> is invalid for the scan relation output <relationOutput>."
],
"subClass" : {
"CANNOT_RESOLVE" : {
"message" : [
"The attribute cannot be resolved."
]
},
"NOT_IN_FILTER_ATTRIBUTES" : {
"message" : [
"The attribute must also be returned by `filterAttributes()`."
]
},
"NOT_TOP_LEVEL" : {
"message" : [
"The attribute must be top-level, but it is a nested reference."
]
}
},
"sqlState" : "KD000"
},
"DATA_SOURCE_METADATA_SCHEMA_NOT_IMPLEMENTED" : {
"message" : [
"<class> does not implement metadataSchema."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,8 @@ public interface SupportsRuntimeFiltering extends SupportsRuntimeV2Filtering {
* Spark will call {@link #filter(Filter[])} if it can derive a runtime
* predicate for any of the filter attributes.
* <p>
* Each reference must be a top-level attribute present in {@link Scan#readSchema()}.
* Nested references and attributes pruned out of the read schema fail to resolve when
* Spark builds the scan relation.
* Each reference must resolve against the scan relation output when Spark builds it. Attributes
* pruned out of {@link Scan#readSchema()} fail to resolve.
*/
NamedReference[] filterAttributes();

Expand All @@ -51,6 +50,14 @@ public interface SupportsRuntimeFiltering extends SupportsRuntimeV2Filtering {
* The provided expressions must be interpreted as a set of filters that are ANDed together.
* Implementations may use the filters to prune initially planned {@link InputPartition}s.
* <p>
* Spark currently tracks runtime-filter eligibility by root attribute. If
* {@link #filterAttributes()}
* returns a nested reference, this method may receive a filter on another nested field under
* the same root. Implementations must inspect each filter and use only filters they can apply.
* Nested paths are encoded in a V1 {@link Filter} as dot-separated names, with each path part
* quoted as needed, such as {@code parent.`child.with.dot`}. A top-level column whose name
* contains a dot remains quoted, such as {@code `parent.child`}.
* <p>
* If the scan also implements {@link SupportsReportPartitioning}, it must preserve
* the originally reported partitioning during runtime filtering. While applying runtime filters,
* the scan may detect that some {@link InputPartition}s have no matching data, in which case
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,8 @@ public interface SupportsRuntimeV2Filtering extends Scan {
* Spark will call {@link #filter(Predicate[])} if it can derive a runtime
* predicate for any of the filter attributes.
* <p>
* Each reference must be a top-level attribute present in {@link Scan#readSchema()}.
* Nested references and attributes pruned out of the read schema fail to resolve when
* Spark builds the scan relation.
* Each reference must resolve against the scan relation output when Spark builds it. Attributes
* pruned out of {@link Scan#readSchema()} fail to resolve.
*/
NamedReference[] filterAttributes();

Expand All @@ -64,6 +63,12 @@ public interface SupportsRuntimeV2Filtering extends Scan {
* The provided expressions must be interpreted as a set of predicates that are ANDed together.
* Implementations may use the predicates to prune initially planned {@link InputPartition}s.
* <p>
* Spark currently tracks runtime-filter eligibility by root attribute. If
* {@link #filterAttributes()}
* returns a nested reference, this method may receive a predicate on another nested field under
* the same root. Implementations must inspect each predicate and use only predicates they can
* apply.
* <p>
* If the scan also implements {@link SupportsReportPartitioning}, it must preserve
* the originally reported partitioning during runtime filtering. While applying runtime
* predicates, the scan may detect that some {@link InputPartition}s have no matching data, in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import org.apache.spark.sql.catalyst.analysis.{NoSuchFunctionException, Unresolv
import org.apache.spark.sql.catalyst.encoders.EncoderUtils
import org.apache.spark.sql.catalyst.expressions.objects.{Invoke, StaticInvoke}
import org.apache.spark.sql.catalyst.plans.logical.{LocalRelation, LogicalPlan, SampleMethod}
import org.apache.spark.sql.catalyst.types.DataTypeUtils.fromAttributes
import org.apache.spark.sql.connector.catalog.{FunctionCatalog, Identifier}
import org.apache.spark.sql.connector.catalog.functions._
import org.apache.spark.sql.connector.catalog.functions.ScalarFunction.MAGIC_METHOD_NAME
Expand Down Expand Up @@ -67,7 +68,35 @@ object V2ExpressionUtils extends SQLConfHelper with Logging {
refs: Array[NamedReference],
output: Seq[Attribute]): AttributeSet = {
val plan = LocalRelation(output)
AttributeSet(resolveRefs[Attribute](refs.toImmutableArraySeq, plan))
AttributeSet(resolveRefs[NamedExpression](refs.toImmutableArraySeq, plan))
}

/**
* Resolves data source runtime-filter attributes and wraps resolution failures with connector
* context.
*/
private[sql] def resolveDataSourceRuntimeFilterRefs(
refs: Array[NamedReference],
output: Seq[Attribute],
method: String,
scanClass: String): AttributeSet = {
if (refs.isEmpty) return AttributeSet.empty

val plan = LocalRelation(output)
val resolvedAttrs = refs.map { ref =>
try {
resolveRef[NamedExpression](ref, plan)
} catch {
case e: AnalysisException =>
throw QueryCompilationErrors.cannotResolveDataSourceRuntimeFilterAttributeError(
attribute = ref.fieldNames,
method = method,
scanClass = scanClass,
relationOutput = fromAttributes(output),
cause = e)
}
}
AttributeSet(resolvedAttrs)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4640,6 +4640,59 @@ private[sql] object QueryCompilationErrors extends QueryErrorsBase with Compilat
)
}

def cannotResolveDataSourceRuntimeFilterAttributeError(
attribute: Array[String],
method: String,
scanClass: String,
relationOutput: StructType,
cause: AnalysisException): AnalysisException = {
invalidDataSourceRuntimeFilterAttributeError(
attribute, method, scanClass, relationOutput, "CANNOT_RESOLVE", Some(cause))
}

def nestedDataSourceFullyPushedRuntimeFilterAttributeError(
attribute: Array[String],
scanClass: String,
relationOutput: StructType): AnalysisException = {
invalidDataSourceRuntimeFilterAttributeError(
attribute,
"fullyPushedFilterAttributes()",
scanClass,
relationOutput,
"NOT_TOP_LEVEL",
None)
}

def fullyPushedDataSourceRuntimeFilterAttributeNotFilterableError(
attribute: Array[String],
scanClass: String,
relationOutput: StructType): AnalysisException = {
invalidDataSourceRuntimeFilterAttributeError(
attribute,
"fullyPushedFilterAttributes()",
scanClass,
relationOutput,
"NOT_IN_FILTER_ATTRIBUTES",
None)
}

private def invalidDataSourceRuntimeFilterAttributeError(
attribute: Array[String],
method: String,
scanClass: String,
relationOutput: StructType,
errorSubClass: String,
cause: Option[AnalysisException]): AnalysisException = {
new AnalysisException(
errorClass = s"DATA_SOURCE_INVALID_RUNTIME_FILTER_ATTRIBUTE.$errorSubClass",
messageParameters = Map(
"attribute" -> toSQLId(attribute.toImmutableArraySeq),
"method" -> method,
"scanClass" -> scanClass,
"relationOutput" -> toSQLType(relationOutput)),
cause = cause)
}

def foundMultipleXMLDataSourceError(provider: String,
sourceNames: Seq[String],
externalSource: String): Throwable = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,18 @@ import org.apache.spark.sql.catalyst.plans.QueryPlan
import org.apache.spark.sql.catalyst.plans.logical.{ColumnStat, ExposesMetadataColumns, Histogram, HistogramBin, LeafNode, LogicalPlan, Statistics}
import org.apache.spark.sql.catalyst.plans.logical.statsEstimation.EstimationUtils
import org.apache.spark.sql.catalyst.streaming.{StreamingSourceIdentifyingName, Unassigned}
import org.apache.spark.sql.catalyst.types.DataTypeUtils.toAttributes
import org.apache.spark.sql.catalyst.types.DataTypeUtils.{fromAttributes, toAttributes}
import org.apache.spark.sql.catalyst.util.{removeInternalMetadata, truncatedString, CharVarcharUtils}
import org.apache.spark.sql.connector.catalog.{CatalogPlugin, FunctionCatalog, Identifier, SupportsMetadataColumns, Table, TableCapability, TableCatalog, V2TableUtil}
import org.apache.spark.sql.connector.catalog.CatalogV2Implicits.CatalogHelper
import org.apache.spark.sql.connector.expressions.{FieldReference, NamedReference}
import org.apache.spark.sql.connector.read.{Scan, Statistics => V2Statistics, SupportsReportStatistics, SupportsRuntimeV2Filtering}
import org.apache.spark.sql.connector.read.colstats.{ColumnStatistics, Histogram => V2Histogram, HistogramBin => V2HistogramBin}
import org.apache.spark.sql.connector.read.streaming.{Offset, SparkDataStream}
import org.apache.spark.sql.errors.QueryCompilationErrors
import org.apache.spark.sql.internal.connector.{SupportsRuntimeCatalystFiltering, V2StatisticsUtils}
import org.apache.spark.sql.types.{DataType, StructType}
import org.apache.spark.sql.util.CaseInsensitiveStringMap
import org.apache.spark.util.ArrayImplicits._
import org.apache.spark.util.Utils

/**
Expand Down Expand Up @@ -198,17 +198,37 @@ case class DataSourceV2ScanRelation(
* Resolved attributes that the scan declares for runtime filtering via
* [[SupportsRuntimeV2Filtering.filterAttributes]] or
* [[SupportsRuntimeCatalystFiltering.filterAttributes]]. Empty when the scan
* implements neither interface or exposes no attributes.
* implements neither interface or exposes no attributes. Accessing this value also validates
* attributes returned by [[SupportsRuntimeCatalystFiltering.fullyPushedFilterAttributes]].
*/
lazy val runtimeFilterAttrs: AttributeSet = {
checkRuntimeFilteringInterfaces()
val filterAttrs = scan match {
case s: SupportsRuntimeV2Filtering => s.filterAttributes
case s: SupportsRuntimeCatalystFiltering => s.filterAttributes()
case _ => Array.empty[NamedReference]
}
AttributeSet(V2ExpressionUtils.resolveRefs[Attribute](
filterAttrs.toImmutableArraySeq, this))
resolvedFullyPushedRuntimeFilterAttrs
resolvedRuntimeFilterAttrs
}

private[sql] lazy val declaredRuntimeFilterAttrs: Array[NamedReference] = scan match {
case s: SupportsRuntimeV2Filtering => s.filterAttributes
case s: SupportsRuntimeCatalystFiltering => s.filterAttributes()
case _ => Array.empty
}

private lazy val declaredFullyPushedRuntimeFilterAttrs: Array[NamedReference] = scan match {
case s: SupportsRuntimeCatalystFiltering => s.fullyPushedFilterAttributes()
case _ => Array.empty
}

private lazy val resolvedRuntimeFilterAttrs: AttributeSet = {
resolveFilterAttrs(declaredRuntimeFilterAttrs, "filterAttributes()")
}

private lazy val resolvedFullyPushedRuntimeFilterAttrs: AttributeSet = {
checkFullyPushedFilterAttrsAreTopLevel()
val resolvedAttrs = resolveFilterAttrs(
declaredFullyPushedRuntimeFilterAttrs, "fullyPushedFilterAttributes()")
resolvedRuntimeFilterAttrs
checkFullyPushedFilterAttrsAreFilterable()
resolvedAttrs
}

/**
Expand All @@ -217,12 +237,20 @@ case class DataSourceV2ScanRelation(
*/
lazy val fullyPushedRuntimeFilterAttrs: AttributeSet = {
checkRuntimeFilteringInterfaces()
val filterAttrs = scan match {
case s: SupportsRuntimeCatalystFiltering => s.fullyPushedFilterAttributes()
case _ => Array.empty[NamedReference]
}
AttributeSet(V2ExpressionUtils.resolveRefs[Attribute](
filterAttrs.toImmutableArraySeq, this))
resolvedFullyPushedRuntimeFilterAttrs
}

/**
* Resolves runtime-filter references against this relation's output.
*
* [[AttributeSet]] reduces nested references to their root attributes. This is sufficient for
* ordinary runtime-filter eligibility because Spark retains the post-scan predicate.
*/
private def resolveFilterAttrs(
filterAttrs: Array[NamedReference],
method: String): AttributeSet = {
V2ExpressionUtils.resolveDataSourceRuntimeFilterRefs(
filterAttrs, output, method, scan.getClass.getName)
}

override def name: String = relation.name
Expand Down Expand Up @@ -271,12 +299,37 @@ case class DataSourceV2ScanRelation(
Statistics(sizeInBytes = conf.defaultSizeInBytes)
}

private def checkRuntimeFilteringInterfaces(): Unit = scan match {
case _: SupportsRuntimeV2Filtering with SupportsRuntimeCatalystFiltering =>
throw SparkException.internalError(
"A scan must not implement both SupportsRuntimeV2Filtering and " +
s"SupportsRuntimeCatalystFiltering, but ${scan.getClass.getName} implements both.")
case _ =>
private def checkRuntimeFilteringInterfaces(): Unit = {
scan match {
case _: SupportsRuntimeV2Filtering with SupportsRuntimeCatalystFiltering =>
throw SparkException.internalError(
"A scan must not implement both SupportsRuntimeV2Filtering and " +
s"SupportsRuntimeCatalystFiltering, but ${scan.getClass.getName} implements both.")
case _ =>
}
}

private def checkFullyPushedFilterAttrsAreTopLevel(): Unit = {
declaredFullyPushedRuntimeFilterAttrs.find(_.fieldNames.length > 1).foreach { ref =>
throw QueryCompilationErrors.nestedDataSourceFullyPushedRuntimeFilterAttributeError(
attribute = ref.fieldNames,
scanClass = scan.getClass.getName,
relationOutput = fromAttributes(output))
}
}

private def checkFullyPushedFilterAttrsAreFilterable(): Unit = {
declaredFullyPushedRuntimeFilterAttrs.find { fullyPushedRef =>
!declaredRuntimeFilterAttrs.exists { filterRef =>
fullyPushedRef.fieldNames.length == filterRef.fieldNames.length &&
fullyPushedRef.fieldNames.lazyZip(filterRef.fieldNames).forall(conf.resolver)
}
}.foreach { ref =>
throw QueryCompilationErrors.fullyPushedDataSourceRuntimeFilterAttributeNotFilterableError(
attribute = ref.fieldNames,
scanClass = scan.getClass.getName,
relationOutput = fromAttributes(output))
}
}

override def doCanonicalize(): DataSourceV2ScanRelation = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,8 @@ trait SupportsRuntimeCatalystFiltering extends Scan {
* Returns attributes this scan can be filtered by at runtime.
*
* Spark will call [[filter]] if it can derive a runtime filter for any of these attributes.
* Each reference must be a top-level attribute present in [[Scan.readSchema]]. Nested
* references and attributes pruned out of the read schema fail to resolve when Spark builds
* the scan relation.
* Each reference must resolve against the scan relation output when Spark builds it. Attributes
* pruned out of [[Scan.readSchema]] fail to resolve.
*/
def filterAttributes(): Array[NamedReference]

Expand All @@ -64,8 +63,11 @@ trait SupportsRuntimeCatalystFiltering extends Scan {
* predicate over it.
*
* Each reference must be a top-level attribute present in [[Scan.readSchema]]. Nested
* references and attributes pruned out of the read schema fail to resolve when Spark builds
* the scan relation.
* references are rejected, and attributes pruned out of the read schema fail to resolve, when
* Spark builds the scan relation. Spark cannot currently represent an individual fully pushed
* nested path. A scan must not return the root struct as a substitute unless it can fully
* evaluate predicates over every nested field, since Spark would remove their post-scan
* evaluation as well.
*/
def fullyPushedFilterAttributes(): Array[NamedReference] = Array.empty

Expand All @@ -76,9 +78,10 @@ trait SupportsRuntimeCatalystFiltering extends Scan {
* Implementations may use the expressions to prune initially planned
* [[org.apache.spark.sql.connector.read.InputPartition]]s.
*
* An expression may access nested fields of an attribute returned by [[filterAttributes]], as
* that attribute is required to be top-level. The scan is responsible for matching such
* accesses against its own partition layout.
* Spark currently tracks runtime-filter eligibility by root attribute. If [[filterAttributes]]
* returns a nested reference, an expression may access another nested field under the same root.
* The scan must match each access against its own partition layout and use only expressions it
* can apply.
*
* Spark may call this method more than once for the same scan instance: a plan can hold several
* scan nodes sharing one scan (e.g. the two branches of a group-based UPDATE), and each pushes
Expand Down
Loading