-
Notifications
You must be signed in to change notification settings - Fork 352
feat: Support native scans with unprojected Spark 4 VARIANT columns #5377
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
base: main
Are you sure you want to change the base?
Changes from all commits
ebd3711
902ed11
f690fae
a7d0539
6b17059
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 |
|---|---|---|
|
|
@@ -540,15 +540,43 @@ case class CometScanRule(session: SparkSession) | |
| } | ||
| } | ||
|
|
||
| // Comet serializes the whole table/scan schema to native, not just projected columns, so a | ||
| // type the native reader does not support (e.g. variant) breaks the scan even when that | ||
| // column is not projected. The readSchema allow-list only covers projected columns, so run | ||
| // the same allow-list over the full schema Comet may serialize. Reflection failure also | ||
| // falls back. | ||
| // The whole Iceberg table schema is serialized to native, but iceberg-rust can represent | ||
| // Variant in that schema as long as no projected field contains one. Match projected | ||
| // roots by field ID so historical snapshots still identify renamed columns, check them | ||
| // strictly, and allow Variant only under entirely unprojected roots. Other unsupported | ||
| // types still fail closed everywhere. An empty data projection is also strict because | ||
| // iceberg-rust currently interprets an empty field-id list as a request for every column. | ||
| val schemaTypesSupported = | ||
| try { | ||
| val fullSchema = IcebergReflection.toSparkSchema(metadata.tableSchema) | ||
| typeChecker.isSchemaSupported(fullSchema, fallbackReasons) | ||
| val projectedDataColumns = scanExec.output.filterNot(_.isMetadataCol) | ||
| // DataTypeSupport recursively dispatches back to this override for struct fields, | ||
| // array elements, and map entries, so Variant is allowed at any nesting depth only | ||
| // when its entire top-level Iceberg field is unprojected. | ||
| val unprojectedTypeChecker = new CometScanTypeChecker() { | ||
| override def isTypeSupported( | ||
| dt: DataType, | ||
| name: String, | ||
| reasons: ListBuffer[String]): Boolean = | ||
| isVariantType(dt) || super.isTypeSupported(dt, name, reasons) | ||
| } | ||
| val resolver = session.sessionState.conf.resolver | ||
| val tableFieldIds = IcebergReflection.buildFieldIdMapping(metadata.tableSchema) | ||
| val projectedFieldIds = projectedDataColumns.map { attr => | ||
| metadata.globalFieldIdMapping.collectFirst { | ||
| case (fieldName, fieldId) if resolver(fieldName, attr.name) => fieldId | ||
| } | ||
| } | ||
| val resolvedProjectedFieldIds = projectedFieldIds.flatten.toSet | ||
| val hasUnresolvedProjectedFieldIds = projectedFieldIds.exists(_.isEmpty) | ||
|
|
||
| fullSchema.fields.forall { field => | ||
| val isProjected = projectedDataColumns.isEmpty || | ||
| hasUnresolvedProjectedFieldIds || | ||
| tableFieldIds.get(field.name).forall(resolvedProjectedFieldIds.contains) | ||
| val checker = if (isProjected) typeChecker else unprojectedTypeChecker | ||
| checker.isTypeSupported(field.dataType, field.name, fallbackReasons) | ||
| } | ||
| } catch { | ||
| case e: Exception => | ||
| fallbackReasons += "Iceberg reflection failure: could not verify column " + | ||
|
|
@@ -715,7 +743,7 @@ case class CometScanRule(session: SparkSession) | |
| true | ||
| } | ||
|
|
||
| // Check for unsupported struct types in delete files | ||
| // Check for unsupported struct and Variant types in delete files | ||
| val deleteFileTypesSupported = { | ||
| var hasUnsupportedDeletes = false | ||
|
|
||
|
|
@@ -766,12 +794,13 @@ case class CometScanRule(session: SparkSession) | |
| } | ||
| fieldInfo match { | ||
| case Some((fieldName, fieldType)) => | ||
| if (fieldType.contains("struct")) { | ||
| if (fieldType.contains("struct") || fieldType.equalsIgnoreCase( | ||
| "variant")) { | ||
|
Comment on lines
+797
to
+798
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. Could we add a new test for this new gate to test spark fallback on this case?
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. Added |
||
| hasUnsupportedDeletes = true | ||
| fallbackReasons += | ||
| s"Equality delete on unsupported column type '$fieldName' " + | ||
| s"($fieldType) is not yet supported by iceberg-rust. " + | ||
| "Struct types in equality deletes " + | ||
| "Struct and Variant types in equality deletes " + | ||
| "require datum conversion support that is not yet implemented." | ||
| } | ||
| case None => | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,14 +22,26 @@ | |
| -- MinSparkVersion: 4.0 | ||
|
|
||
| statement | ||
| CREATE TABLE test_variant(id INT, v VARIANT) USING parquet | ||
| CREATE TABLE test_variant(id INT, v VARIANT, tail STRING) USING parquet | ||
|
Contributor
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. Should we also add a test with
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. Added explicit |
||
|
|
||
| statement | ||
| INSERT INTO test_variant VALUES | ||
| (1, parse_json('{"a": 1, "b": "hello"}')), | ||
| (2, parse_json('{"a": 2, "b": "world"}')), | ||
| (3, parse_json('null')), | ||
| (4, NULL) | ||
| (1, parse_json('{"a": 1, "b": "hello"}'), 'first'), | ||
| (2, parse_json('{"a": 2, "b": "world"}'), NULL), | ||
| (3, parse_json('null'), 'variant-null'), | ||
| (4, NULL, 'sql-null') | ||
|
|
||
| -- A plain Parquet scan can remain native when its required schema prunes the | ||
| -- Variant column completely, including both SQL NULL and Variant null values. | ||
| query | ||
| SELECT id FROM test_variant ORDER BY id | ||
|
|
||
| -- A projected column after the pruned Variant must use its rebased native index. | ||
| query | ||
| SELECT tail FROM test_variant ORDER BY id | ||
|
|
||
| query | ||
| SELECT id, tail FROM test_variant WHERE tail IS NOT NULL ORDER BY id | ||
|
|
||
| query expect_fallback(type VariantType) | ||
| SELECT id, v FROM test_variant ORDER BY id | ||
|
|
@@ -44,12 +56,85 @@ query expect_fallback(type VariantType) | |
| SELECT COUNT(*) FROM test_variant WHERE v IS NOT NULL | ||
|
|
||
| statement | ||
| CREATE TABLE test_variant_struct(id INT, s STRUCT<v: VARIANT>) USING parquet | ||
| CREATE TABLE test_variant_struct(id INT, s STRUCT<safe: INT, v: VARIANT>, tail STRING) | ||
| USING parquet | ||
|
|
||
| statement | ||
| INSERT INTO test_variant_struct VALUES | ||
| (1, named_struct('v', parse_json('{"x": 10}'))), | ||
| (2, named_struct('v', parse_json('{"x": 20}'))) | ||
| (1, named_struct('safe', 10, 'v', parse_json('{"x": 10}')), 'first'), | ||
| (2, named_struct('safe', NULL, 'v', parse_json('{"x": 20}')), NULL), | ||
| (3, NULL, 'null-parent') | ||
|
|
||
| query | ||
| SELECT id FROM test_variant_struct ORDER BY id | ||
|
|
||
| query | ||
| SELECT tail FROM test_variant_struct ORDER BY id | ||
|
|
||
| -- Projecting a supported sibling replaces the full struct with Spark's pruned nested schema. | ||
| query | ||
| SELECT s.safe FROM test_variant_struct ORDER BY id | ||
|
|
||
| query expect_fallback(type VariantType) | ||
| SELECT id, s FROM test_variant_struct ORDER BY id | ||
|
|
||
| statement | ||
| CREATE TABLE test_variant_collections( | ||
| id INT, | ||
| variants ARRAY<VARIANT>, | ||
| variants_by_key MAP<STRING, VARIANT>, | ||
| tail STRING) | ||
| USING parquet | ||
|
|
||
| statement | ||
| INSERT INTO test_variant_collections VALUES | ||
| (1, | ||
| array(parse_json('{"x": 1}'), parse_json('null')), | ||
| map('first', parse_json('{"x": 2}')), | ||
| 'first'), | ||
| (2, | ||
| array(CAST(NULL AS VARIANT)), | ||
| map('sql-null', CAST(NULL AS VARIANT)), | ||
| NULL), | ||
| (3, NULL, NULL, 'null-collections') | ||
|
|
||
| -- Variant-bearing arrays and maps can be pruned as entire top-level fields. | ||
| query | ||
| SELECT id, tail FROM test_variant_collections ORDER BY id | ||
|
|
||
| -- Exposing either collection still requires Spark to decode its nested Variant values. | ||
| query expect_fallback(type VariantType) | ||
| SELECT id, variants FROM test_variant_collections ORDER BY id | ||
|
|
||
| query expect_fallback(type VariantType) | ||
| SELECT id, variants_by_key FROM test_variant_collections ORDER BY id | ||
|
|
||
| statement | ||
| CREATE TABLE test_variant_partitioned(id INT, v VARIANT, tail STRING, p INT) | ||
| USING parquet PARTITIONED BY (p) | ||
|
|
||
| statement | ||
| INSERT INTO test_variant_partitioned VALUES | ||
| (1, parse_json('{"a": 1}'), 'first', 10), | ||
| (2, NULL, 'second', 20) | ||
|
|
||
| -- Partition columns are appended after the pruned data schema, so their offsets must be rebased. | ||
| query | ||
| SELECT tail, p FROM test_variant_partitioned ORDER BY id | ||
|
|
||
| -- File-constant metadata follows the partition columns and needs the same rebased offsets. | ||
| query | ||
| SELECT tail, p, _metadata.file_name FROM test_variant_partitioned ORDER BY id | ||
|
|
||
| statement | ||
| CREATE TABLE test_plain_variant_shape(id INT, payload STRUCT<value: BINARY, metadata: BINARY>) | ||
| USING parquet | ||
|
|
||
| statement | ||
| INSERT INTO test_plain_variant_shape VALUES | ||
| (1, named_struct('value', X'01', 'metadata', X'02')), | ||
| (2, NULL) | ||
|
|
||
| -- An ordinary binary struct with Variant-like field names is not a logical Variant. | ||
| query | ||
| SELECT payload FROM test_plain_variant_shape ORDER BY id | ||
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 is being called for all the top level variant fields. What about a nested variant field?
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.
Nested Variant fields are handled recursively.
DataTypeSupport.isTypeSupporteddispatches back to this overridden checker for struct fields, array elements, and map keys/values, so an unprojected top-level Iceberg field may contain Variants at any depth. If that top-level field is projected, the strict checker still rejects it and the scan falls back to Spark. I clarified this in the comment and extended the Iceberg regression to cover nested structs,ARRAY<VARIANT>, andMAP<STRING, VARIANT>.