Skip to content

Commit 84c89bf

Browse files
committed
[SPARK-59108][4.2][SQL] Fix Avro positional matching under column pruning
### What changes were proposed in this pull request? Backport of `c809c283c2d` (#58409) to `branch-4.2`. The Avro fix comes over as it landed; the two gate removals that PR also carried are not here, because neither gate exists on this branch. `AvroDeserializer` now takes the schema its Catalyst schema was projected from, and under `positionalFieldMatching` it resolves a Catalyst field against that field's position in the data schema rather than its position in the projection. `AvroUtils.AvroSchemaHelper` takes the resulting positions; with none it keeps using a field's own position, which is what every caller whose Catalyst schema is not a projection needs (`from_avro`, the write path, the state-store encoder). Two read call sites pass the data schema on this branch: `AvroPartitionReaderFactory` on the V2 path and `AvroFileFormat.buildReader` on V1. Master has a third, `AvroFileFormat.readArchive`, which does not exist here. A nested record keeps resolving by its own positions, since neither read path prunes nested fields: `FileScanBuilder.supportsNestedSchemaPruning` is false and `AvroScanBuilder` does not override it, and `SchemaPruning.canPruneDataSchema` covers only Parquet and ORC. ORC already does this for `orc.force.positional.evolution`: `OrcUtils.requestedColumnIds` maps the required schema through `dataSchema.fieldIndex(name)`, which makes its positional path projection-independent. Avro decodes the whole record whatever the projection asks for, so nothing extra is read. Master retired two gates that kept avro out of scan merging, and neither is on this branch: SPARK-57205 (#58340) withheld the `SCAN_MERGING` capability from `AvroTable`, and SPARK-59107 (#58411) named avro in `DataSourceUtils.isProjectionSensitiveRead`. So there is no predicate to change, no `AvroTable.supportsScanMerging` to turn on, no test to delete, and `docs/sql-performance-tuning.md` has no "Merging Subplans" section stating the old behaviour. That also means subplan merging can widen an avro projection here with nothing in front of it, which makes the position mapping worth more on this branch than on 4.3, not less. One shape stays broken, with or without this change: `recursiveFieldMaxDepth` makes `SchemaConverters` drop a field it will not recurse into, so the data schema is a gapped view of the Avro schema and positional matching misaligns from the gap onwards. The code records that where the positions are computed. ### Why are the changes needed? With `positionalFieldMatching=true` the deserializer is built from the projected read schema while the Avro side stays the full Avro schema, and `AvroUtils.AvroSchemaHelper.getAvroField` pairs Catalyst field *i* with Avro field *i*, so a column-pruned read takes the wrong Avro field and returns wrong values with no error. Measured on a file whose fields `a`, `b`, `c` hold `id`, `100 * id`, `10000 * id` for ids 0 to 4, read with the option on: ``` sql("SELECT sum(a), sum(b), sum(c) FROM t").show() // 10, 1000, 100000 -- all correct sql("SELECT sum(c) FROM t").show() // 10 -- should be 100000 sql("SELECT sum(b) FROM t").show() // 10 -- should be 1000 sql("SELECT sum(a), sum(c) FROM t").show() // 10, 1000 -- sum(c) should be 100000 ``` Only a projection that is a prefix of the file's field list comes back right, so a column's value depends on which other columns the query selects. Both read paths behave the same way. Whether the failure is silent depends on the types of the mispaired fields: matching types return wrong values, as above, and incompatible ones fail the read with a schema-incompatibility error instead. A pushed filter is evaluated inside the deserializer, so the wrong pairing can also drop rows rather than only return wrong values for them. A pruned projection is all it takes, so this does not depend on scan merging. Merging only makes it easier to reach without asking for it, and on this branch nothing keeps an avro relation out of it. ### Does this PR introduce _any_ user-facing change? Yes, a bug fix on the Avro read path, both V1 and V2, and every 4.2.x release shipped the bug: positional matching has resolved against the projection since 3.2.0 (SPARK-34365). A read that sets `positionalFieldMatching` and prunes columns now returns the values of the columns it asked for. A query whose projection is a prefix of the Avro field list is unaffected, which is why the option's existing tests need no change. A read that used to land on a type-compatible neighbouring field now pairs with its own field and fails when the two types do not match, so a query that returned values before this change can return an error instead. That is the point of the fix rather than a side effect, but it is the shape most likely to be reported as a regression. The "Cannot find field at position N" message that positional matching raises now names the position it looked for rather than the position within the projection, which are the same number for an unprojected read. Nothing changes when the option is off, which is the default, and nothing changes on the write path or in `from_avro`. ### How was this patch tested? Five new tests in `AvroSuite`, so each runs on both read paths (`AvroV1Suite` and `AvroV2Suite` extend it): the renamed-schema shape from the description, with each one-column and two-column projection whose values the fix changes, the ones it leaves alone being the prefixes of the field list, a pushed filter under both settings of `spark.sql.avro.filterPushdown.enabled`, `count(1)`, and mixed-case names under both case-sensitivity settings; a partition column sitting between two data columns in the schema; a nested record, which must keep resolving by its own positions, together with the `avroSchema` option supplying the Avro side; a projection that reaches past the end of the Avro schema, which reads null; and a mispaired type, which fails the read rather than returning a neighbouring field's values. One test in `AvroSchemaHelperSuite` for the helper itself. Master's `AvroArchiveReadBase` case is not here, since this branch has no archive reader. One more test, in `AvroV1Suite`, for a merged read. The file has three columns and the two scalar aggregates read the last two, so the merged projection is a proper subset of the data schema and the read has to resolve against that schema to answer `[100, 1000]`; the scan is one widened `FileSourceScanExec` reading both columns. Unlike master's version it pins only AQE, since the strictness flags matter to a predicate that does not exist on this branch, and it has no `AvroV2Suite` twin, since avro never declares `SCAN_MERGING` here. Mutation check, measured on this branch: with the position mapping disabled, 11 cases fail, the five `SPARK-59108` shapes on each read path and the new merge test, which answers `[10, 100]` where the file has `[100, 1000]`. Regression, measured on this branch: the whole `avro` module, 404 tests, and `avro/scalastyle`, `avro/Test/scalastyle`, `sql/scalastyle` and `catalyst/scalastyle`. `RocksDBStateEncoderSuite` and `StateStoreSuite`, which also build an `AvroDeserializer`, were run on master rather than here; nothing in this backport differs from the master commit in that path. The existing `positionalFieldMatching` tests (SPARK-34365) needed no change, because their projections cover the whole schema. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code Closes #58538 from LuciferYang/SPARK-59108-4.2. Authored-by: YangJie <yangjie01@baidu.com> Signed-off-by: yangjie01 <yangjie01@baidu.com>
1 parent 6c3ac3a commit 84c89bf

9 files changed

Lines changed: 284 additions & 17 deletions

File tree

connector/avro/src/main/scala/org/apache/spark/sql/v2/avro/AvroPartitionReaderFactory.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@ case class AvroPartitionReaderFactory(
106106
avroFilters,
107107
options.useStableIdForUnionType,
108108
options.stableIdPrefixForUnionType,
109-
options.recursiveFieldMaxDepth)
109+
options.recursiveFieldMaxDepth,
110+
dataSchema = Some(dataSchema))
110111
override val stopPosition = partitionedFile.start + partitionedFile.length
111112

112113
override def next(): Boolean = hasNextRow

connector/avro/src/test/scala/org/apache/spark/sql/avro/AvroCatalystDataConversionSuite.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,8 @@ class AvroCatalystDataConversionSuite extends SparkFunSuite
292292
filters,
293293
false,
294294
"",
295-
-1)
295+
-1,
296+
dataSchema = None)
296297
val deserialized = deserializer.deserialize(data)
297298
expected match {
298299
case None => assert(deserialized == None)

connector/avro/src/test/scala/org/apache/spark/sql/avro/AvroRowReaderSuite.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ class AvroRowReaderSuite extends SharedSparkSession {
7575
new NoopFilters,
7676
false,
7777
"",
78-
-1)
78+
-1,
79+
dataSchema = None)
7980
override val stopPosition = fileSize
8081

8182
override def hasNext: Boolean = hasNextRow

connector/avro/src/test/scala/org/apache/spark/sql/avro/AvroSchemaHelperSuite.scala

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,49 @@ class AvroSchemaHelperSuite extends SharedSparkSession {
8787
assert(nameHelper.getAvroField("nonexist", 1).isEmpty)
8888
}
8989

90+
test("SPARK-59108: positional field match resolves against the data schema positions") {
91+
val dataSchema = new StructType()
92+
.add("a", IntegerType).add("b", IntegerType).add("c", IntegerType)
93+
val avroSchema = SchemaConverters.toAvroType(dataSchema)
94+
val projection = new StructType().add("c", IntegerType).add("a", IntegerType)
95+
96+
val helper = new AvroUtils.AvroSchemaHelper(
97+
avroSchema, projection, Seq(""), Seq(""), true, Array(2, 0))
98+
assert(helper.getAvroField("c", 0) === Some(avroSchema.getFields.get(2)))
99+
assert(helper.getAvroField("a", 1) === Some(avroSchema.getFields.get(0)))
100+
assert(helper.matchedFields.map(_.avroField.name()) === Seq("c", "a"))
101+
102+
// With no positions a field's own position is used, which is what an unprojected match needs.
103+
val unprojected =
104+
new AvroUtils.AvroSchemaHelper(avroSchema, projection, Seq(""), Seq(""), true)
105+
assert(unprojected.getAvroField("c", 0) === Some(avroSchema.getFields.get(0)))
106+
107+
// The shape both read paths produce is an ascending subsequence of the data schema.
108+
val ascending = new StructType().add("a", IntegerType).add("c", IntegerType)
109+
val ascendingHelper = new AvroUtils.AvroSchemaHelper(
110+
avroSchema, ascending, Seq(""), Seq(""), true, Array(0, 2))
111+
assert(ascendingHelper.getAvroField("a", 0) === Some(avroSchema.getFields.get(0)))
112+
assert(ascendingHelper.getAvroField("c", 1) === Some(avroSchema.getFields.get(2)))
113+
assert(ascendingHelper.matchedFields.map(_.avroField.name()) === Seq("a", "c"))
114+
115+
val msg = intercept[IllegalArgumentException] {
116+
new AvroUtils.AvroSchemaHelper(avroSchema, projection, Seq(""), Seq(""), true, Array(2))
117+
}.getMessage
118+
assert(msg.contains("Got 1 data schema positions for 2 Catalyst fields"))
119+
120+
// A missing field is reported by the position that was looked for, not by the position the
121+
// field happens to have in the projection.
122+
val twoFieldAvro = SchemaConverters.toAvroType(
123+
new StructType().add("a", IntegerType).add("b", IntegerType))
124+
val pastTheEnd = new AvroUtils.AvroSchemaHelper(
125+
twoFieldAvro, new StructType().add("c", IntegerType, nullable = false),
126+
Seq(""), Seq(""), true, Array(2))
127+
val missing = intercept[IncompatibleSchemaException] {
128+
pastTheEnd.validateNoExtraCatalystFields(ignoreNullable = false)
129+
}.getMessage
130+
assert(missing.contains("Cannot find field at position 2"))
131+
}
132+
90133
test("properly match fields between Avro and Catalyst schemas") {
91134
val catalystSchema = StructType(
92135
Seq("catalyst1", "catalyst2", "shared1", "shared2").map(StructField(_, IntegerType))

connector/avro/src/test/scala/org/apache/spark/sql/avro/AvroSerdeSuite.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,8 @@ object AvroSerdeSuite {
229229
new NoopFilters,
230230
false,
231231
"",
232-
-1)
232+
-1,
233+
dataSchema = None)
233234
}
234235

235236
/**

connector/avro/src/test/scala/org/apache/spark/sql/avro/AvroSuite.scala

Lines changed: 165 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import org.apache.spark.sql.catalyst.expressions.AttributeReference
4242
import org.apache.spark.sql.catalyst.plans.logical.Filter
4343
import org.apache.spark.sql.catalyst.util.DateTimeTestUtils
4444
import org.apache.spark.sql.catalyst.util.DateTimeTestUtils.{withDefaultTimeZone, LA, UTC}
45-
import org.apache.spark.sql.execution.{FormattedMode, SparkPlan}
45+
import org.apache.spark.sql.execution.{FileSourceScanExec, FormattedMode, SparkPlan}
4646
import org.apache.spark.sql.execution.datasources.{CommonFileDataSourceSuite, DataSource, FilePartition}
4747
import org.apache.spark.sql.execution.datasources.v2.{BatchScanExec, FileDataSourceV2, FileTable}
4848
import org.apache.spark.sql.functions._
@@ -1735,6 +1735,143 @@ abstract class AvroSuite
17351735
}
17361736
}
17371737

1738+
test("SPARK-59108: positionalFieldMatching resolves fields against the full schema") {
1739+
withTempPath { dir =>
1740+
val path = dir.getCanonicalPath
1741+
spark.range(0, 5).selectExpr("id AS a", "id * 100 AS b", "id * 10000 AS c")
1742+
.write.format("avro").save(path)
1743+
// The names differ from the file's, so only the positions can pair the two schemas.
1744+
val renamedSchema = new StructType()
1745+
.add("x", LongType).add("y", LongType).add("z", LongType)
1746+
val df = spark.read.format("avro")
1747+
.option("positionalFieldMatching", true.toString)
1748+
.schema(renamedSchema)
1749+
.load(path)
1750+
1751+
val rows = (0 until 5).map(i => Row(i.toLong, i * 100L, i * 10000L))
1752+
checkAnswer(df, rows)
1753+
// A column keeps its own Avro field however few of them the query projects.
1754+
checkAnswer(df.select("z"), rows.map(r => Row(r.get(2))))
1755+
checkAnswer(df.select("y"), rows.map(r => Row(r.get(1))))
1756+
checkAnswer(df.select("x", "z"), rows.map(r => Row(r.get(0), r.get(2))))
1757+
checkAnswer(df.select("z", "x"), rows.map(r => Row(r.get(2), r.get(0))))
1758+
checkAnswer(df.select("y", "z"), rows.map(r => Row(r.get(1), r.get(2))))
1759+
checkAnswer(df.selectExpr("sum(z)"), Row(100000L))
1760+
// With pushdown on, the filter runs inside the deserializer; with it off, it runs above the
1761+
// scan.
1762+
// Either way a wrong pairing drops rows rather than only returning wrong values for them.
1763+
Seq("true", "false").foreach { pushDown =>
1764+
withSQLConf(SQLConf.AVRO_FILTER_PUSHDOWN_ENABLED.key -> pushDown) {
1765+
checkAnswer(df.where("z = 20000").select("z"), Row(20000L))
1766+
checkAnswer(df.where("z > 20000").select("x"), Seq(Row(3L), Row(4L)))
1767+
}
1768+
}
1769+
// A projection of no columns at all.
1770+
checkAnswer(df.selectExpr("count(1)"), Row(5L))
1771+
1772+
// The projected schema carries the schema's own spelling whatever casing the query used, so
1773+
// the name lookup that resolves a position finds the field either way.
1774+
val mixedCase = spark.read.format("avro")
1775+
.option("positionalFieldMatching", true.toString)
1776+
.schema(new StructType().add("Xx", LongType).add("yY", LongType).add("ZZ", LongType))
1777+
.load(path)
1778+
Seq("true", "false").foreach { caseSensitive =>
1779+
withSQLConf(SQLConf.CASE_SENSITIVE.key -> caseSensitive) {
1780+
checkAnswer(mixedCase.select("ZZ"), rows.map(r => Row(r.get(2))))
1781+
}
1782+
}
1783+
withSQLConf(SQLConf.CASE_SENSITIVE.key -> "false") {
1784+
checkAnswer(mixedCase.select("zz"), rows.map(r => Row(r.get(2))))
1785+
}
1786+
}
1787+
}
1788+
1789+
test("SPARK-59108: positionalFieldMatching with a partition column in the schema") {
1790+
withTempPath { dir =>
1791+
val path = dir.getCanonicalPath
1792+
spark.range(0, 4).selectExpr("id AS a", "id * 100 AS b", "id % 2 AS p")
1793+
.write.partitionBy("p").format("avro").save(path)
1794+
// p is a partition column, so the files hold a and b only and the data schema is x and z.
1795+
val df = spark.read.format("avro")
1796+
.option("positionalFieldMatching", true.toString)
1797+
.schema("x long, p int, z long")
1798+
.load(path)
1799+
1800+
checkAnswer(df.select("z"), (0 until 4).map(i => Row(i * 100L)))
1801+
checkAnswer(df.select("x"), (0 until 4).map(i => Row(i.toLong)))
1802+
checkAnswer(df.select("p", "z"), (0 until 4).map(i => Row(i % 2, i * 100L)))
1803+
checkAnswer(df.where("p = 1").select("z"), Seq(Row(100L), Row(300L)))
1804+
}
1805+
}
1806+
1807+
test("SPARK-59108: positionalFieldMatching with a nested record and the avroSchema option") {
1808+
withTempPath { dir =>
1809+
val path = dir.getCanonicalPath
1810+
spark.range(0, 3).selectExpr(
1811+
"id AS a",
1812+
"named_struct('f1', id * 10, 'f2', cast(id AS string)) AS r",
1813+
"id * 1000 AS c")
1814+
.write.format("avro").save(path)
1815+
1816+
// Only the top level is a projection, so the nested record keeps resolving by its own
1817+
// positions. Reading the struct alone would take Avro field 0, a long, and fail.
1818+
val df = spark.read.format("avro")
1819+
.option("positionalFieldMatching", true.toString)
1820+
.schema("x long, s struct<g1: long, g2: string>, z long")
1821+
.load(path)
1822+
checkAnswer(df.select("s"), (0 until 3).map(i => Row(Row(i * 10L, i.toString))))
1823+
checkAnswer(df.select("s.g2"), (0 until 3).map(i => Row(i.toString)))
1824+
checkAnswer(df.select("z"), (0 until 3).map(i => Row(i * 1000L)))
1825+
1826+
// The avroSchema option supplies the Avro side, and the data schema is inferred from it, so
1827+
// the positions are the option's.
1828+
val avroSubset =
1829+
"""{"type":"record","name":"topLevelRecord","fields":[
1830+
|{"name":"a","type":"long"},
1831+
|{"name":"c","type":"long"}]}""".stripMargin
1832+
val fromOption = spark.read.format("avro")
1833+
.option("positionalFieldMatching", true.toString)
1834+
.option("avroSchema", avroSubset)
1835+
.load(path)
1836+
checkAnswer(fromOption.select("c"), (0 until 3).map(i => Row(i * 1000L)))
1837+
checkAnswer(fromOption.select("a"), (0 until 3).map(i => Row(i.toLong)))
1838+
}
1839+
}
1840+
1841+
test("SPARK-59108: a position past the end of the Avro schema reads null") {
1842+
withTempPath { dir =>
1843+
val path = dir.getCanonicalPath
1844+
spark.range(0, 3).selectExpr("id AS a", "id * 100 AS b").write.format("avro").save(path)
1845+
val df = spark.read.format("avro")
1846+
.option("positionalFieldMatching", true.toString)
1847+
.schema("x long, y long, z long")
1848+
.load(path)
1849+
1850+
// z is at position 2 of the schema and the file has two fields, so it has no Avro field to
1851+
// read and comes back null however few columns the query projects.
1852+
checkAnswer(df.select("z"), Seq(Row(null), Row(null), Row(null)))
1853+
checkAnswer(df, (0 until 3).map(i => Row(i.toLong, i * 100L, null)))
1854+
}
1855+
}
1856+
1857+
test("SPARK-59108: positionalFieldMatching fails a mispaired type rather than reading it") {
1858+
withTempPath { dir =>
1859+
val path = dir.getCanonicalPath
1860+
spark.range(0, 3).selectExpr("id AS a", "cast(id AS string) AS b", "id * 10 AS c")
1861+
.write.format("avro").save(path)
1862+
val df = spark.read.format("avro")
1863+
.option("positionalFieldMatching", true.toString)
1864+
.schema("x long, y long, z long")
1865+
.load(path)
1866+
1867+
// y takes Avro field 1, which is a string, so the read fails instead of returning the values
1868+
// of a neighbouring field.
1869+
val ex = intercept[SparkException](df.select("y").collect())
1870+
assert(Utils.exceptionString(ex).contains("Cannot convert Avro"))
1871+
checkAnswer(df.select("z"), (0 until 3).map(i => Row(i * 10L)))
1872+
}
1873+
}
1874+
17381875
test("int/long double/float conversion") {
17391876
val catalystSchema =
17401877
StructType(Seq(
@@ -3374,6 +3511,33 @@ class AvroV1Suite extends AvroSuite {
33743511
.sparkConf
33753512
.set(SQLConf.USE_V1_SOURCE_LIST, "avro")
33763513

3514+
test("SPARK-59108: two positional reads of different columns share one widened scan") {
3515+
// Subplan merging widens the projection of a shared V1 file scan, and this branch has nothing
3516+
// that keeps an avro relation out of it, so the two subqueries share one scan whatever this
3517+
// fix does. What it changes is the values they get: each column resolves against the data
3518+
// schema rather than against the merged projection. AQE off because `AdaptiveSparkPlanExec`
3519+
// is a leaf node, so with it on the scan underneath is not reachable from the executed plan.
3520+
withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
3521+
withTempPath { dir =>
3522+
val path = dir.getCanonicalPath
3523+
spark.range(0, 5).selectExpr("id AS a", "id * 10 AS b", "id * 100 AS c")
3524+
.write.format("avro").save(path)
3525+
withTempView("t") {
3526+
spark.read.option("positionalFieldMatching", true.toString).format("avro").load(path)
3527+
.createOrReplaceTempView("t")
3528+
// b and c sit at data schema positions 1 and 2, so the merged read of the two has to
3529+
// resolve against the data schema rather than against its own projection.
3530+
val df = sql("SELECT (SELECT sum(b) FROM t), (SELECT sum(c) FROM t)")
3531+
checkAnswer(df, Row(100L, 1000L))
3532+
val scanColumns = df.queryExecution.executedPlan
3533+
.collectWithSubqueries { case s: FileSourceScanExec => s }
3534+
.map(_.requiredSchema.fieldNames.sorted.toSeq)
3535+
assert(scanColumns === Seq(Seq("b", "c")))
3536+
}
3537+
}
3538+
}
3539+
}
3540+
33773541
test("SPARK-36271: V1 insert should check schema field name too") {
33783542
withView("v") {
33793543
spark.range(1).createTempView("v")

sql/core/src/main/scala/org/apache/spark/sql/avro/AvroDeserializer.scala

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ import org.apache.spark.unsafe.types.UTF8String
4444

4545
/**
4646
* A deserializer to deserialize data in avro format to data in catalyst format.
47+
*
48+
* @param dataSchema The schema `rootCatalystType` was projected from, for a read that prunes
49+
* columns. A positional field match pairs a Catalyst field with the Avro field
50+
* at the same position, and that position is the one in the full schema rather
51+
* than in the projection: without this, reading only the third column would take
52+
* the first Avro field. `None` when the Catalyst type is not a projection;
53+
* unused when field matching is by name.
4754
*/
4855
private[sql] class AvroDeserializer(
4956
rootAvroType: Schema,
@@ -53,7 +60,8 @@ private[sql] class AvroDeserializer(
5360
filters: StructFilters,
5461
useStableIdForUnionType: Boolean,
5562
stableIdPrefixForUnionType: String,
56-
recursiveFieldMaxDepth: Int) {
63+
recursiveFieldMaxDepth: Int,
64+
dataSchema: Option[StructType]) {
5765

5866
def this(
5967
rootAvroType: Schema,
@@ -70,7 +78,8 @@ private[sql] class AvroDeserializer(
7078
new NoopFilters,
7179
useStableIdForUnionType,
7280
stableIdPrefixForUnionType,
73-
recursiveFieldMaxDepth)
81+
recursiveFieldMaxDepth,
82+
dataSchema = None)
7483
}
7584

7685
private lazy val decimalConversions = new DecimalConversion()
@@ -91,7 +100,8 @@ private[sql] class AvroDeserializer(
91100
val resultRow = new SpecificInternalRow(st.map(_.dataType))
92101
val fieldUpdater = new RowUpdater(resultRow)
93102
val applyFilters = filters.skipRow(resultRow, _)
94-
val writer = getRecordWriter(rootAvroType, st, Nil, Nil, applyFilters)
103+
val writer =
104+
getRecordWriter(rootAvroType, st, Nil, Nil, applyFilters, positionsInDataSchema(st))
95105
(data: Any) => {
96106
val record = data.asInstanceOf[GenericRecord]
97107
val skipRow = writer(fieldUpdater, record)
@@ -285,8 +295,8 @@ private[sql] class AvroDeserializer(
285295
case (RECORD, st: StructType) =>
286296
// Avro datasource doesn't accept filters with nested attributes. See SPARK-32328.
287297
// We can always return `false` from `applyFilters` for nested records.
288-
val writeRecord =
289-
getRecordWriter(avroType, st, avroPath, catalystPath, applyFilters = _ => false)
298+
val writeRecord = getRecordWriter(
299+
avroType, st, avroPath, catalystPath, applyFilters = _ => false, Array.empty)
290300
(updater, ordinal, value) =>
291301
val row = new SpecificInternalRow(st)
292302
writeRecord(new RowUpdater(row), value.asInstanceOf[GenericRecord])
@@ -426,15 +436,44 @@ private[sql] class AvroDeserializer(
426436
}
427437
}
428438

439+
/**
440+
* The position of each `projection` field in `dataSchema`, which is what a positional field match
441+
* resolves against. Empty when there is no data schema to resolve against, or when field matching
442+
* is by name and the positions are unused.
443+
*
444+
* This takes a data schema position for an Avro field position, which `recursiveFieldMaxDepth`
445+
* can break: `SchemaConverters` drops a field it will not recurse into, so the data schema is a
446+
* gapped view of the Avro schema and every field after the gap resolves one position early.
447+
* Positional matching is already wrong for such a schema without this method, because the fields
448+
* after the gap shift by one whatever the projection is.
449+
*/
450+
private def positionsInDataSchema(projection: StructType): Array[Int] = dataSchema match {
451+
case Some(schema) if positionalFieldMatch =>
452+
projection.map(field => schema.fieldIndex(field.name)).toArray
453+
case _ => Array.empty
454+
}
455+
456+
/**
457+
* Creates a writer that reads a record's fields into `catalystType`'s fields.
458+
*
459+
* @param dataSchemaPositions The positions a positional field match resolves `catalystType`'s
460+
* fields against, empty to use each field's own position. Only the
461+
* root record passes them: a nested record is never a projection,
462+
* because V1 nested pruning is limited to Parquet and ORC
463+
* (`SchemaPruning.canPruneDataSchema`) and V2's
464+
* `FileScanBuilder.supportsNestedSchemaPruning` is false for Avro.
465+
*/
429466
private def getRecordWriter(
430467
avroType: Schema,
431468
catalystType: StructType,
432469
avroPath: Seq[String],
433470
catalystPath: Seq[String],
434-
applyFilters: Int => Boolean): (CatalystDataUpdater, GenericRecord) => Boolean = {
471+
applyFilters: Int => Boolean,
472+
dataSchemaPositions: Array[Int])
473+
: (CatalystDataUpdater, GenericRecord) => Boolean = {
435474

436475
val avroSchemaHelper = new AvroUtils.AvroSchemaHelper(
437-
avroType, catalystType, avroPath, catalystPath, positionalFieldMatch)
476+
avroType, catalystType, avroPath, catalystPath, positionalFieldMatch, dataSchemaPositions)
438477

439478
avroSchemaHelper.validateNoExtraCatalystFields(ignoreNullable = true)
440479
// no need to validateNoExtraAvroFields since extra Avro fields are ignored

sql/core/src/main/scala/org/apache/spark/sql/avro/AvroFileFormat.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,8 @@ private[sql] class AvroFileFormat extends FileFormat
148148
avroFilters,
149149
parsedOptions.useStableIdForUnionType,
150150
parsedOptions.stableIdPrefixForUnionType,
151-
parsedOptions.recursiveFieldMaxDepth)
151+
parsedOptions.recursiveFieldMaxDepth,
152+
dataSchema = Some(dataSchema))
152153
override val stopPosition = file.start + file.length
153154

154155
override def hasNext: Boolean = hasNextRow

0 commit comments

Comments
 (0)