This is an automated email from the ASF dual-hosted git repository.
MaxGekk pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new 8b0d544e59f9 [SPARK-55444][SQL] Types Framework - Phase 3a - Storage
Formats (Parquet)
8b0d544e59f9 is described below
commit 8b0d544e59f91dd2f4752a3afb0b358a8635cd1b
Author: David Milicevic <[email protected]>
AuthorDate: Thu Jun 18 14:07:19 2026 +0200
[SPARK-55444][SQL] Types Framework - Phase 3a - Storage Formats (Parquet)
### What changes were proposed in this pull request?
This PR implements **Phase 3a (Storage Formats - Parquet)** of the Spark
Types Framework
([SPARK-55444](https://issues.apache.org/jira/browse/SPARK-55444), parent:
[SPARK-53504](https://issues.apache.org/jira/browse/SPARK-53504)). It adds a
new optional `ParquetTypeOps` trait that enables framework-managed types to
participate in Parquet read/write paths with zero per-type changes to Parquet
infrastructure files.
**Scope of this PR:** the schema-conversion, write-path, and row-based
read-path integration sites — 6 Scala files modified plus 2 added.
Vectorized-read (`ParquetVectorUpdaterFactory`, `VectorizedColumnReader`) and
filter-pushdown (`ParquetFilters`) integration are deferred — see "Follow-ups"
below.
**New trait:** `ParquetTypeOps` in `sql/core` (package
`o.a.s.sql.execution.datasources.parquet.types.ops`) following the Phase 1c
pattern (ConnectArrowTypeOps — separate module, separate factory). The trait
surface **implemented in this PR**:
- **Schema conversion** (Spark DataType → Parquet schema type, write path)
- **Value write** (writing values to the Parquet `RecordConsumer`)
- **Row-based read** (creating Parquet converters for reading into
`InternalRow`)
- **Type gates** (`supportDataType`, plus the `isBatchReadSupported`
capability flag)
- **Schema clipping** (`parquetStructSchema` for column pruning of
struct-backed types)
**Not yet on the trait (deferred to Follow-ups):** vectorized-read batch
updaters and filter-pushdown predicates. Today the trait only exposes the
`isBatchReadSupported` gate for the vectorized path and has no filter surface.
**Reference implementation:** `TimeTypeParquetOps` validates the schema,
write, row-read, and type-gate paths for a primitive `INT64`-backed type.
**Dispatch pattern:** Framework FIRST at all integration sites, with the
entire original code extracted to `*Default` methods as fallback — the same
`Ops(dt).map(_.method).getOrElse(methodDefault(dt))` pattern established in
Phase 1a (PR #54223) and Phase 1c (PR #54905). The framework dispatches
**unconditionally** (the `spark.sql.types.framework.enabled` flag was removed
in [SPARK-57372](https://issues.apache.org/jira/browse/SPARK-57372)), so
`TimeType` is always routed through the f [...]
**Integration sites (in this PR):**
| File | Dispatch |
|------|----------|
| `ParquetSchemaConverter` | Write-path schema: framework-first
`convertField` → `convertToParquetType`, original code extracted to
`convertFieldDefault`. (The Parquet→Spark read/inference direction is unchanged
apart from a cosmetic `case _ => illegalType()` line merge; `TimeType`
inference still uses the hardcoded `TimeLogicalTypeAnnotation` case.) |
| `ParquetWriteSupport` | Framework-first split of `makeWriter` into
`makeWriter` + `makeWriterDefault`. (No companion-utility extraction —
`consumeGroup`/`writeFields` are untouched.) |
| `ParquetRowConverter` | Framework-first `newConverter` with method
overloading (simple for primitive, extended for struct-backed) |
| `ParquetFileFormat` | Framework-first `supportDataType` |
| `ParquetUtils` | Framework-first `isBatchReadSupported` |
| `ParquetReadSupport` | Framework-first `clipParquetType` for
struct-backed types via `parquetStructSchema` |
**Integration sites (follow-up, not in this PR):**
| File | Dispatch |
|------|----------|
| `ParquetVectorUpdaterFactory` (Java) | Framework-first `getUpdater` via
Java-friendly `getVectorUpdaterOrNull` |
| `VectorizedColumnReader` (Java) | Framework-first
`isLazyDecodingSupported` via Java-friendly `isLazyDecodingSupportedFor` |
| `ParquetFilters` | `FrameworkFilterOps` custom extractor + `orElse` on 7
PartialFunctions + framework-first `valueCanMakeFilterOn` |
**Design decisions:**
- `ParquetTypeOps` is a separate trait in `sql/core` (not on `TypeOps` in
`sql/catalyst`) because Parquet types (`RecordConsumer`,
`ParquetVectorUpdater`, etc.) live in `sql/core` and catalyst cannot reference
them.
- `rowRepresentationType` (Phase 1b) is NOT used for Parquet — it is scoped
to row infrastructure only. Using it would erase type identity in Parquet value
paths, create dispatch asymmetry between struct-backed and primitive types, and
extend it beyond its designed scope.
- `parquetStructSchema` is independent of `PhysicalDataType` — Parquet
storage representation may differ from internal row representation.
- `recordConsumer` is passed as `() => RecordConsumer` (lazy supplier)
because `makeWriter` is called during `init()` when `recordConsumer` is still
null (set later in `prepareForWrite()`).
- Filter dispatch (deferred to the follow-up) will use a
`FrameworkFilterOps` custom extractor inside `ParquetFilters` because
`ParquetSchemaType` is a private inner class that cannot be referenced from
outside.
**Follow-ups (not in this PR):**
1. **Vectorized-read integration** — `ParquetVectorUpdaterFactory` and
`VectorizedColumnReader` (both Java). Will use Java-friendly methods
(`getVectorUpdaterOrNull`, `isLazyDecodingSupportedFor`) to dispatch from Java
code paths into the Scala `ParquetTypeOps`.
2. **Filter-pushdown integration** — `ParquetFilters` via a
`FrameworkFilterOps` custom extractor added to 7 `PartialFunction`s plus
framework-first `valueCanMakeFilterOn`. Requires a custom extractor because
`ParquetSchemaType` is a private inner class that cannot be referenced from
outside `ParquetFilters`.
3. **Second reference implementation (struct-backed)** — `TimeType` is
`INT64`-backed and exercises only the primitive paths. A struct-backed
reference type would harden `parquetStructSchema`, `clipParquetType`
framework-first dispatch, and the extended `newConverter` overload before
downstream consumers (e.g., extended-range nanosecond timestamps, DECFLOAT)
land.
4. **Read-path guard reconciliation
([SPARK-57416](https://issues.apache.org/jira/browse/SPARK-57416))** — decide
the intended `TimeType` read behavior for an `INT64 TIME(MICROS,
isAdjustedToUTC=true)` column (see "user-facing change" below). With the Types
Framework feature flag removed
([SPARK-57372](https://issues.apache.org/jira/browse/SPARK-57372)), the
framework intercepts `TimeType` unconditionally and the legacy `TimeType`
branch in `ParquetRowConverter`'s `*Default` path is d [...]
### Why are the changes needed?
Adding a new data type to Spark currently requires modifying 8+ Parquet
files with scattered, type-specific logic. This PR enables the framework to
handle all Parquet concerns for new types — a new type implements
`ParquetTypeOps` and registers in the companion's `apply()`, and the Parquet
infrastructure files dispatch through it automatically.
This is the first storage format integration (Phase 3a). `TimeType` serves
as the reference implementation validating the paths wired in this PR.
### Does this PR introduce _any_ user-facing change?
Yes — one narrow behavior change on the **row-based** read path. For the
schema-conversion, write, and row-based read paths this is a pure refactoring
that routes `TimeType` through the framework with otherwise identical behavior.
The exception: the framework read-path guard
`TimeTypeParquetOps.requireCompatibleParquetType` (used by the row-based
converter `ParquetRowConverter`) is stricter than the legacy guard it replaces.
For an `INT64 TIME(MICROS, isAdjustedToUTC=true)` column rea [...]
This rejection is **not unconditional** — it only takes effect when the
read actually goes row-based. Because `TimeTypeParquetOps.isBatchReadSupported
= true`, a top-level `TimeType` column is read by the **vectorized** path by
default, and that path (`ParquetVectorUpdaterFactory`'s `LongAsNanosUpdater`)
never consults `requireCompatibleParquetType` — it keeps the pre-PR lenient
behavior (blind `micros → nanos`). The guard therefore fires only for row-based
reads: when the vectorized [...]
The `spark.sql.types.framework.enabled` flag was removed in
[SPARK-57372](https://issues.apache.org/jira/browse/SPARK-57372), so framework
dispatch for `TimeType` is unconditional — there is no OFF path that restores
the legacy lenient row-based read. Whether the framework should reject
`isAdjustedToUTC=true` at all, and how to make the vectorized and row-based
paths agree, is tracked in
[SPARK-57416](https://issues.apache.org/jira/browse/SPARK-57416).
### How was this patch tested?
All existing Parquet test suites pass with the Types Framework active (now
unconditional — the `spark.sql.types.framework.enabled` flag was removed in
[SPARK-57372](https://issues.apache.org/jira/browse/SPARK-57372)):
- `ParquetSchemaSuite`: 131 tests passed
- `ParquetIOSuite`: 88 tests passed (including "Read TimeType for the
logical TIME type")
- `ParquetVectorizedSuite`: 25 tests passed
- `ParquetV1FilterSuite` + `ParquetV2FilterSuite`: 101 tests passed
(including "SPARK-51687: filter pushdown - time")
New unit test `TimeTypeParquetOpsSuite` pins the accept/reject set of
`requireCompatibleParquetType`, including the `isAdjustedToUTC=true` rejection
on the row-based path noted above (cross-referenced to SPARK-57416).
Note: `ParquetVectorizedSuite` and the V1/V2 filter suites exercise the
*existing* (non-framework) `TimeType` code paths, since vectorized-read and
filter-pushdown integration are deferred to the follow-ups above.
Re-validated after merging the latest `master` (post-SPARK-57372): the
affected Parquet suites were re-run on Java 17 — `TimeTypeParquetOpsSuite` +
`ParquetSchemaSuite` (139) and `ParquetIOSuite` (94) pass.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 4.6)
Closes #55326 from davidm-db/davidm-db/types_framework_3a.
Lead-authored-by: David Milicevic <[email protected]>
Co-authored-by: Stevo Mitric <[email protected]>
Co-authored-by: Stevo Mitric <[email protected]>
Signed-off-by: Max Gekk <[email protected]>
---
.../datasources/parquet/ParquetFileFormat.scala | 8 +-
.../datasources/parquet/ParquetReadSupport.scala | 68 +++---
.../datasources/parquet/ParquetRowConverter.scala | 15 ++
.../parquet/ParquetSchemaConverter.scala | 14 +-
.../datasources/parquet/ParquetUtils.scala | 8 +-
.../datasources/parquet/ParquetWriteSupport.scala | 15 +-
.../parquet/types/ops/ParquetTypeOps.scala | 227 +++++++++++++++++++++
.../parquet/types/ops/TimeTypeParquetOps.scala | 117 +++++++++++
.../types/ops/TimeTypeParquetOpsSuite.scala | 128 ++++++++++++
9 files changed, 567 insertions(+), 33 deletions(-)
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFileFormat.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFileFormat.scala
index cf2626c2d63e..733e55a8609e 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFileFormat.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFileFormat.scala
@@ -49,6 +49,7 @@ import
org.apache.spark.sql.catalyst.types.DataTypeUtils.toAttributes
import org.apache.spark.sql.catalyst.util.{DateTimeUtils, RebaseDateTime}
import org.apache.spark.sql.errors.QueryExecutionErrors
import org.apache.spark.sql.execution.datasources._
+import
org.apache.spark.sql.execution.datasources.parquet.types.ops.ParquetTypeOps
import org.apache.spark.sql.execution.vectorized.{ConstantColumnVector,
OffHeapColumnVector, OnHeapColumnVector}
import org.apache.spark.sql.internal.{SessionStateHelper, SQLConf}
import org.apache.spark.sql.internal.SQLConf._
@@ -414,7 +415,12 @@ class ParquetFileFormat
}
}
- override def supportDataType(dataType: DataType): Boolean = dataType match {
+ override def supportDataType(dataType: DataType): Boolean =
+ // Types Framework: framework FIRST, original match as fallback.
+ ParquetTypeOps(dataType).map(_.supportDataType)
+ .getOrElse(supportDataTypeDefault(dataType))
+
+ private def supportDataTypeDefault(dataType: DataType): Boolean = dataType
match {
// GeoSpatial data types in Parquet are limited only to types with
supported SRIDs.
case g: GeometryType => GeometryType.isSridSupported(g.srid)
case g: GeographyType => GeographyType.isSridSupported(g.srid)
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetReadSupport.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetReadSupport.scala
index 7ee5b4d224b3..e92858f6a1e9 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetReadSupport.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetReadSupport.scala
@@ -36,6 +36,7 @@ import org.apache.spark.sql.catalyst.InternalRow
import org.apache.spark.sql.catalyst.util.RebaseDateTime.RebaseSpec
import org.apache.spark.sql.errors.QueryExecutionErrors
import org.apache.spark.sql.execution.datasources.VariantMetadata
+import
org.apache.spark.sql.execution.datasources.parquet.types.ops.ParquetTypeOps
import org.apache.spark.sql.internal.{LegacyBehaviorPolicy, SQLConf}
import org.apache.spark.sql.types._
@@ -215,32 +216,15 @@ object ParquetReadSupport extends Logging {
caseSensitive: Boolean,
useFieldId: Boolean,
returnNullStructIfAllFieldsMissing: Boolean): Type = {
- val newParquetType = catalystType match {
- case t: ArrayType if ParquetSchemaConverter.isComplexType(t.elementType)
=>
- // Only clips array types with nested type as element type.
- clipParquetListType(parquetType.asGroupType(), t.elementType,
caseSensitive, useFieldId,
- returnNullStructIfAllFieldsMissing)
-
- case t: MapType
- if ParquetSchemaConverter.isComplexType(t.keyType) ||
- ParquetSchemaConverter.isComplexType(t.valueType) =>
- // Only clips map types with nested key type or value type
- clipParquetMapType(
- parquetType.asGroupType(), t.keyType, t.valueType, caseSensitive,
useFieldId,
- returnNullStructIfAllFieldsMissing)
-
- case t: StructType if VariantMetadata.isVariantStruct(t) =>
- clipVariantSchema(parquetType.asGroupType(), t,
returnNullStructIfAllFieldsMissing)
-
- case t: StructType =>
- clipParquetGroup(parquetType.asGroupType(), t, caseSensitive,
useFieldId,
- returnNullStructIfAllFieldsMissing)
-
- case _ =>
- // UDTs and primitive types are not clipped. For UDTs, a clipped
version might not be able
- // to be mapped to desired user-space types. So UDTs shouldn't
participate schema merging.
- parquetType
- }
+ // Types Framework: framework FIRST for struct-backed types that declare
+ // parquetStructSchema. Primitive framework types (parquetStructSchema =
None)
+ // fall through to *Default which returns parquetType unchanged.
+ val newParquetType = ParquetTypeOps(catalystType)
+ .flatMap(_.parquetStructSchema)
+ .map(st => clipParquetGroup(parquetType.asGroupType(), st,
caseSensitive, useFieldId,
+ returnNullStructIfAllFieldsMissing))
+ .getOrElse(clipParquetTypeDefault(parquetType, catalystType,
caseSensitive, useFieldId,
+ returnNullStructIfAllFieldsMissing))
if (useFieldId && parquetType.getId != null) {
newParquetType.withId(parquetType.getId.intValue())
@@ -249,6 +233,38 @@ object ParquetReadSupport extends Logging {
}
}
+ private def clipParquetTypeDefault(
+ parquetType: Type,
+ catalystType: DataType,
+ caseSensitive: Boolean,
+ useFieldId: Boolean,
+ returnNullStructIfAllFieldsMissing: Boolean): Type = catalystType match {
+ case t: ArrayType if ParquetSchemaConverter.isComplexType(t.elementType) =>
+ // Only clips array types with nested type as element type.
+ clipParquetListType(parquetType.asGroupType(), t.elementType,
caseSensitive, useFieldId,
+ returnNullStructIfAllFieldsMissing)
+
+ case t: MapType
+ if ParquetSchemaConverter.isComplexType(t.keyType) ||
+ ParquetSchemaConverter.isComplexType(t.valueType) =>
+ // Only clips map types with nested key type or value type
+ clipParquetMapType(
+ parquetType.asGroupType(), t.keyType, t.valueType, caseSensitive,
useFieldId,
+ returnNullStructIfAllFieldsMissing)
+
+ case t: StructType if VariantMetadata.isVariantStruct(t) =>
+ clipVariantSchema(parquetType.asGroupType(), t,
returnNullStructIfAllFieldsMissing)
+
+ case t: StructType =>
+ clipParquetGroup(parquetType.asGroupType(), t, caseSensitive, useFieldId,
+ returnNullStructIfAllFieldsMissing)
+
+ case _ =>
+ // UDTs and primitive types are not clipped. For UDTs, a clipped
version might not be able
+ // to be mapped to desired user-space types. So UDTs shouldn't
participate schema merging.
+ parquetType
+ }
+
/**
* Clips a Parquet [[GroupType]] which corresponds to a Catalyst
[[ArrayType]]. The element type
* of the [[ArrayType]] should also be a nested type, namely an
[[ArrayType]], a [[MapType]], or a
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetRowConverter.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetRowConverter.scala
index 6c9485dc6fc8..c3016d929ac9 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetRowConverter.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetRowConverter.scala
@@ -41,6 +41,7 @@ import
org.apache.spark.sql.catalyst.util.ResolveDefaultColumns._
import org.apache.spark.sql.errors.QueryCompilationErrors
import org.apache.spark.sql.errors.QueryExecutionErrors
import org.apache.spark.sql.execution.datasources.{DataSourceUtils,
VariantMetadata}
+import
org.apache.spark.sql.execution.datasources.parquet.types.ops.ParquetTypeOps
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.types._
import org.apache.spark.unsafe.types.{BinaryView, TimestampNanosVal,
UTF8String, VariantVal}
@@ -306,6 +307,20 @@ private[parquet] class ParquetRowConverter(
parquetType: Type,
catalystType: DataType,
updater: ParentContainerUpdater): Converter with
HasParentContainerUpdater = {
+ // Types Framework: framework FIRST, original match as fallback.
+ // Passes all ParquetRowConverter constructor params to the extended
newConverter overload
+ // so struct-backed types can create recursive converters.
+ ParquetTypeOps(catalystType)
+ .map(_.newConverter(
+ parquetType, updater, schemaConverter, convertTz,
+ datetimeRebaseSpec, int96RebaseSpec))
+ .getOrElse(newConverterDefault(parquetType, catalystType, updater))
+ }
+
+ private def newConverterDefault(
+ parquetType: Type,
+ catalystType: DataType,
+ updater: ParentContainerUpdater): Converter with
HasParentContainerUpdater = {
def isUnsignedIntTypeMatched(bitWidth: Int): Boolean = {
parquetType.getLogicalTypeAnnotation match {
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaConverter.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaConverter.scala
index c479c37b89fd..ba5261200464 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaConverter.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaConverter.scala
@@ -30,6 +30,7 @@ import org.apache.parquet.schema.Type.Repetition._
import org.apache.spark.sql.AnalysisException
import org.apache.spark.sql.errors.QueryCompilationErrors
import org.apache.spark.sql.execution.datasources.VariantMetadata
+import
org.apache.spark.sql.execution.datasources.parquet.types.ops.ParquetTypeOps
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.types._
import org.apache.spark.sql.types.{EdgeInterpolationAlgorithm =>
SparkEdgeInterpolationAlgorithm}
@@ -341,8 +342,7 @@ class ParquetToSparkSchemaConverter(
case time: TimeLogicalTypeAnnotation
if time.getUnit == TimeUnit.MICROS && !time.isAdjustedToUTC =>
TimeType(TimeType.MICROS_PRECISION)
- case _ =>
- illegalType()
+ case _ => illegalType()
}
case INT96 =>
@@ -655,8 +655,15 @@ class SparkToParquetSchemaConverter(
field: StructField,
repetition: Type.Repetition,
inShredded: Boolean): Type = {
+ // Types Framework: framework FIRST, original match as fallback.
+ ParquetTypeOps(field.dataType).map(_.convertToParquetType(field.name,
repetition, inShredded))
+ .getOrElse(convertFieldDefault(field, repetition, inShredded))
+ }
- field.dataType match {
+ private def convertFieldDefault(
+ field: StructField,
+ repetition: Type.Repetition,
+ inShredded: Boolean): Type = field.dataType match {
// ===================
// Simple atomic types
// ===================
@@ -936,7 +943,6 @@ class SparkToParquetSchemaConverter(
case _ =>
throw
QueryCompilationErrors.cannotConvertDataTypeToParquetTypeError(field)
}
- }
}
private[sql] object ParquetSchemaConverter {
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetUtils.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetUtils.scala
index c60754813994..bafc3a9ea52f 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetUtils.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetUtils.scala
@@ -42,6 +42,7 @@ import
org.apache.spark.sql.catalyst.expressions.variant.VariantExpressionEvalUt
import org.apache.spark.sql.catalyst.util.RebaseDateTime.RebaseSpec
import org.apache.spark.sql.connector.expressions.aggregate.{Aggregation,
Count, CountStar, Max, Min}
import org.apache.spark.sql.execution.datasources.{AggregatePushDownUtils,
DataSourceUtils, OutputWriter, OutputWriterFactory}
+import
org.apache.spark.sql.execution.datasources.parquet.types.ops.ParquetTypeOps
import org.apache.spark.sql.execution.datasources.v2.V2ColumnUtils
import org.apache.spark.sql.internal.{LegacyBehaviorPolicy, SQLConf}
import org.apache.spark.sql.internal.SQLConf.PARQUET_AGGREGATE_PUSHDOWN_ENABLED
@@ -206,7 +207,12 @@ object ParquetUtils extends Logging {
sqlConf.parquetVectorizedReaderEnabled &&
schema.forall(f => isBatchReadSupported(sqlConf, f.dataType))
- def isBatchReadSupported(sqlConf: SQLConf, dt: DataType): Boolean = dt match
{
+ def isBatchReadSupported(sqlConf: SQLConf, dt: DataType): Boolean =
+ // Types Framework: framework FIRST, original match as fallback.
+ ParquetTypeOps(dt).map(_.isBatchReadSupported(sqlConf))
+ .getOrElse(isBatchReadSupportedDefault(sqlConf, dt))
+
+ private def isBatchReadSupportedDefault(sqlConf: SQLConf, dt: DataType):
Boolean = dt match {
case _: TimestampNTZNanosType | _: TimestampLTZNanosType =>
false
case _: AtomicType =>
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetWriteSupport.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetWriteSupport.scala
index d7fd5991c75f..18c0a47facda 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetWriteSupport.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetWriteSupport.scala
@@ -37,6 +37,7 @@ import
org.apache.spark.sql.catalyst.expressions.SpecializedGetters
import org.apache.spark.sql.catalyst.util.{DateTimeConstants, DateTimeUtils,
STUtils}
import org.apache.spark.sql.errors.QueryExecutionErrors
import org.apache.spark.sql.execution.datasources.DataSourceUtils
+import
org.apache.spark.sql.execution.datasources.parquet.types.ops.ParquetTypeOps
import org.apache.spark.sql.internal.{LegacyBehaviorPolicy, SQLConf}
import org.apache.spark.sql.types._
import org.apache.spark.types.variant.Variant
@@ -204,6 +205,19 @@ class ParquetWriteSupport extends
WriteSupport[InternalRow] with Logging {
// `inShredded` indicates whether the current traversal is nested within a
shredded Variant
// schema. This affects how timestamp values are written.
private def makeWriter(dataType: DataType, inShredded: Boolean): ValueWriter
= {
+ // Types Framework: framework FIRST, original match as fallback.
+ // The recursive callback passes makeWriter (framework-first) so that
sub-fields of
+ // struct-backed types also go through the framework, consistent with
schema conversion.
+ // NOTE: recordConsumer is null during init() when makeWriter is first
called -
+ // it's set later in prepareForWrite(). The existing code works because
closures
+ // over `this.recordConsumer` (a var field) capture the var reference, not
its value.
+ // We wrap in a lambda to achieve the same lazy evaluation for the ops
method.
+ ParquetTypeOps(dataType)
+ .map(_.makeWriter(() => recordConsumer, makeWriter(_, inShredded)))
+ .getOrElse(makeWriterDefault(dataType, inShredded))
+ }
+
+ private def makeWriterDefault(dataType: DataType, inShredded: Boolean):
ValueWriter =
dataType match {
case NullType => // No values of NullType should ever be written, as all
values are null.
(_: SpecializedGetters, _: Int) => throw
SparkUnsupportedOperationException()
@@ -358,7 +372,6 @@ class ParquetWriteSupport extends WriteSupport[InternalRow]
with Logging {
case _ => throw SparkException.internalError(s"Unsupported data type
$dataType.")
}
- }
private def makeDecimalWriter(precision: Int, scale: Int): ValueWriter = {
assert(
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/ParquetTypeOps.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/ParquetTypeOps.scala
new file mode 100644
index 000000000000..76296500a792
--- /dev/null
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/ParquetTypeOps.scala
@@ -0,0 +1,227 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.datasources.parquet.types.ops
+
+import java.time.ZoneId
+
+import org.apache.parquet.io.api.{Converter, RecordConsumer}
+import org.apache.parquet.schema.Type
+import org.apache.parquet.schema.Type.Repetition
+
+import org.apache.spark.sql.catalyst.expressions.SpecializedGetters
+import org.apache.spark.sql.catalyst.util.RebaseDateTime.RebaseSpec
+import
org.apache.spark.sql.execution.datasources.parquet.{HasParentContainerUpdater,
ParentContainerUpdater, ParquetToSparkSchemaConverter}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.{DataType, StructType, TimeType}
+
+/**
+ * Optional trait for Parquet storage format integration in the Types
Framework.
+ *
+ * Implement this trait to enable Parquet read/write support for a framework
type. Each framework
+ * type that supports Parquet provides a concrete implementation and registers
it in the companion
+ * object's apply() method.
+ *
+ * The trait covers these Parquet concerns:
+ * - Schema conversion: Spark DataType -> Parquet schema type (write path)
+ * - Value write: writing values to Parquet RecordConsumer
+ * - Row-based read: creating Parquet converters for reading into InternalRow
+ * - Type gates: declaring Parquet support (supportDataType) and the
vectorized-read
+ * capability flag (isBatchReadSupported)
+ * - Schema clipping: declaring internal struct schema for column pruning
+ *
+ * NOT yet on the trait (deferred to follow-ups): vectorized-read batch
updaters and
+ * filter-pushdown predicates. Only the isBatchReadSupported capability gate
exists today;
+ * the actual vectorized updater and filter predicate hooks are not
implemented here.
+ *
+ * DISPATCH PATTERN: Framework FIRST at all integration sites. Each Parquet
infrastructure
+ * method wraps itself with:
+ * {{{
+ * ParquetTypeOps(dt).map(_.method(...)).getOrElse(methodDefault(dt, ...))
+ * }}}
+ * The original code is extracted to a *Default method unchanged. For a
framework-managed type
+ * the ops handles it; for any other type ParquetTypeOps(dt) is None and the
*Default fallback
+ * executes the original code path.
+ *
+ * STRUCT-BACKED TYPES: Types stored as Parquet groups should override the
+ * extended newConverter overload (which provides
schemaConverter/convertTz/rebase specs for
+ * recursive field conversion) and declare parquetStructSchema for column
pruning.
+ *
+ * @see TimeTypeParquetOps for a reference implementation (primitive
Long-backed type)
+ * @since 4.3.0
+ */
+private[parquet] trait ParquetTypeOps extends Serializable {
+
+ // ==================== Schema Conversion ====================
+
+ /**
+ * Converts this Spark DataType to a Parquet schema Type (for the write
path).
+ *
+ * For primitive types: returns a PrimitiveType with the appropriate
annotation.
+ * For struct-backed types: returns a GroupType with sub-fields and a
logical type annotation.
+ *
+ * @param fieldName the column/field name in the Parquet schema
+ * @param repetition REQUIRED, OPTIONAL, or REPEATED
+ * @param inShredded whether the field is nested within a shredded Variant
schema
+ * @return the Parquet Type for this DataType
+ */
+ def convertToParquetType(
+ fieldName: String, repetition: Repetition, inShredded: Boolean = false):
Type
+
+ // ==================== Value Write ====================
+
+ /**
+ * Creates a value writer that writes values of this type to a Parquet
RecordConsumer.
+ *
+ * The writer is a closure that captures the RecordConsumer and is called
once per row during
+ * Parquet file writing. The RecordConsumer is instance-specific (one per
output file).
+ *
+ * IMPORTANT: The RecordConsumer is passed as a lazy supplier (() =>
RecordConsumer) because
+ * makeWriter is called during ParquetWriteSupport.init(), when
recordConsumer is still null.
+ * It gets set later in prepareForWrite(). The closure must evaluate the
supplier at write
+ * time, not at creation time. The existing infrastructure closures work
because they capture
+ * `this.recordConsumer` (a var field - Scala closures over vars capture the
reference, not
+ * the value). The supplier lambda achieves the same lazy evaluation for ops
code.
+ *
+ * For primitive types: directly calls recordConsumer.addLong/addBinary/etc.
with any
+ * necessary conversion (e.g., nanos to micros for TimeType).
+ *
+ * @param recordConsumer lazy supplier for the Parquet output stream (null
during init)
+ * @param makeFieldWriter callback into ParquetWriteSupport for recursive
field writer creation
+ * (struct-backed types use this to create writers
for sub-fields)
+ * @return a closure that writes a value from a row at the given ordinal
+ */
+ def makeWriter(
+ recordConsumer: () => RecordConsumer,
+ makeFieldWriter: DataType => (SpecializedGetters, Int) => Unit
+ ): (SpecializedGetters, Int) => Unit
+
+ // ==================== Row-Based Read ====================
+
+ /**
+ * Creates a Parquet Converter for reading values of this type (simple
version).
+ *
+ * Primitive types override this method. The converter typically extends
+ * ParquetPrimitiveConverter and overrides addLong/addBinary/etc. to perform
any necessary
+ * conversion (e.g., micros to nanos for TimeType).
+ *
+ * WARNING: Struct-backed types must override the EXTENDED overload below
instead. This simple
+ * version does not provide the schemaConverter, timezone, or rebase specs
needed for recursive
+ * field conversion. Overriding only this method for a struct-backed type
will compile but
+ * produce incorrect behavior at runtime (missing timezone conversion,
calendar rebasing, etc.).
+ *
+ * @param parquetType the Parquet schema type for this field
+ * @param updater the parent container to set converted values into
+ * @return a Converter that reads Parquet values into the parent container
+ */
+ def newConverter(
+ parquetType: Type,
+ updater: ParentContainerUpdater): Converter with
HasParentContainerUpdater
+
+ /**
+ * Creates a Parquet Converter for reading values of this type (extended
version).
+ *
+ * Struct-backed types override this method to receive the extra context
needed for recursive
+ * field conversion (schemaConverter for nested type resolution, timezone
for timestamp
+ * conversion, rebase specs for calendar rebasing).
+ *
+ * Default delegates to the simple version - primitive types inherit this
default and
+ * ignore the extra parameters.
+ *
+ * @param parquetType the Parquet schema type for this field
+ * @param updater the parent container to set converted values into
+ * @param schemaConverter for resolving nested Parquet schemas to Spark types
+ * @param convertTz timezone for timestamp conversion
+ * @param datetimeRebaseSpec calendar rebasing spec for datetime types
+ * @param int96RebaseSpec calendar rebasing spec for INT96 timestamps
+ * @return a Converter that reads Parquet values into the parent container
+ */
+ def newConverter(
+ parquetType: Type,
+ updater: ParentContainerUpdater,
+ schemaConverter: ParquetToSparkSchemaConverter,
+ convertTz: Option[ZoneId],
+ datetimeRebaseSpec: RebaseSpec,
+ int96RebaseSpec: RebaseSpec): Converter with HasParentContainerUpdater =
+ newConverter(parquetType, updater)
+
+ // ==================== Type Gates ====================
+
+ /**
+ * Whether this type is supported by the Parquet data source.
+ * Used by ParquetFileFormat.supportDataType.
+ */
+ def supportDataType: Boolean = true
+
+ /**
+ * Whether vectorized (batch) reading is supported for this type.
+ * Used by ParquetUtils.isBatchReadSupported. Default is false - types must
opt in
+ * by overriding to true. When false, Spark uses the row-based read path
(newConverter)
+ * which is always available.
+ *
+ * PRECONDITION: there is no framework vectorized-read hook yet, so
returning true is only
+ * safe for a type the legacy Java vectorized path
(ParquetVectorUpdaterFactory /
+ * VectorizedColumnReader) already handles. A new type that returns true
without that
+ * legacy support would route into a factory that does not recognize it.
TimeType is safe
+ * here precisely because the legacy path handles it; until the vectorized
integration
+ * (follow-up) lands, other types should leave this false.
+ *
+ * @param sqlConf the active SQL configuration
+ */
+ def isBatchReadSupported(sqlConf: SQLConf): Boolean = false
+
+ // ==================== Schema Clipping (Struct-Backed Types)
====================
+
+ /**
+ * The Parquet-level struct schema for column pruning.
+ *
+ * Struct-backed types (stored as a Parquet GROUP) return the field schema
so that
+ * ParquetReadSupport.clipParquetType can prune sub-fields based on the
query's
+ * requested columns.
+ *
+ * This is independent of PhysicalDataType - Parquet storage representation
may differ from
+ * internal row representation. A type could be PhysicalBinaryType in rows
but a GROUP in
+ * Parquet (e.g., a type stored as binary in rows but as a GROUP with
metadata fields on disk).
+ *
+ * Primitive types return None (no sub-fields to clip).
+ */
+ def parquetStructSchema: Option[StructType] = None
+}
+
+/**
+ * Factory object for creating ParquetTypeOps instances.
+ *
+ * Provides forward lookup (DataType -> ops) for framework-first dispatch at
Parquet
+ * integration sites. apply() returns Some only for framework-managed types,
so callers
+ * fall back to the legacy path for everything else.
+ */
+private[parquet] object ParquetTypeOps {
+
+ /**
+ * Returns a ParquetTypeOps instance for the given DataType, if supported.
+ *
+ * Returns None if the type has no Parquet ops.
+ * This is the single registration point for all Parquet type operations.
+ */
+ def apply(dt: DataType): Option[ParquetTypeOps] = {
+ dt match {
+ case tt: TimeType => Some(TimeTypeParquetOps(tt))
+ // Add new types here - single registration point
+ case _ => None
+ }
+ }
+}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/TimeTypeParquetOps.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/TimeTypeParquetOps.scala
new file mode 100644
index 000000000000..7f05361d8f6c
--- /dev/null
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/TimeTypeParquetOps.scala
@@ -0,0 +1,117 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.datasources.parquet.types.ops
+
+import org.apache.parquet.io.api.{Converter, RecordConsumer}
+import org.apache.parquet.schema.{LogicalTypeAnnotation, Type, Types}
+import org.apache.parquet.schema.LogicalTypeAnnotation.TimeUnit
+import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT64
+import org.apache.parquet.schema.Type.Repetition
+
+import org.apache.spark.sql.catalyst.expressions.SpecializedGetters
+import org.apache.spark.sql.catalyst.util.DateTimeUtils
+import org.apache.spark.sql.errors.QueryExecutionErrors
+import
org.apache.spark.sql.execution.datasources.parquet.{HasParentContainerUpdater,
ParentContainerUpdater, ParquetPrimitiveConverter}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.{DataType, TimeType}
+
+/**
+ * Parquet operations for TimeType.
+ *
+ * TimeType is a primitive Long-backed type stored in Parquet as INT64 with the
+ * TIME(isAdjustedToUTC=false, unit=MICROS) logical type annotation.
+ *
+ * IMPORTANT - internal vs Parquet representation:
+ * - Spark internal: nanoseconds since midnight (Long)
+ * - Parquet storage: microseconds since midnight (INT64)
+ * - Write path: nanos -> micros (DateTimeUtils.nanosToMicros)
+ * - Read path: micros -> nanos (DateTimeUtils.microsToNanos)
+ *
+ * @param t the TimeType with precision information
+ * @since 4.3.0
+ */
+case class TimeTypeParquetOps(t: TimeType) extends ParquetTypeOps {
+
+ // ==================== Schema Conversion ====================
+
+ override def convertToParquetType(
+ fieldName: String, repetition: Repetition, inShredded: Boolean): Type =
+ Types.primitive(INT64, repetition)
+ .as(LogicalTypeAnnotation.timeType(false, TimeUnit.MICROS))
+ .named(fieldName)
+
+ // ==================== Value Write ====================
+
+ override def makeWriter(
+ recordConsumer: () => RecordConsumer,
+ makeFieldWriter: DataType => (SpecializedGetters, Int) => Unit
+ ): (SpecializedGetters, Int) => Unit =
+ // Evaluate the supplier at write time (not creation time) because
recordConsumer
+ // is null during init() and set later in prepareForWrite().
+ (row: SpecializedGetters, ordinal: Int) =>
+
recordConsumer().addLong(DateTimeUtils.nanosToMicros(row.getLong(ordinal)))
+
+ // ==================== Row-Based Read ====================
+
+ override def newConverter(
+ parquetType: Type,
+ updater: ParentContainerUpdater): Converter with
HasParentContainerUpdater = {
+ // Framework-first dispatch in ParquetRowConverter routes here whenever the
+ // requested Spark type is TimeType, regardless of the actual Parquet
encoding.
+ // Without this guard, files whose column is raw INT64, INT64 TIME(NANOS),
+ // INT64 TIMESTAMP(MICROS), INT32 TIME(MILLIS), etc. would silently decode
as
+ // microsToNanos(value) and produce wrong results. Mirrors the inline guard
+ // that existed in ParquetRowConverter before the framework dispatch.
+ TimeTypeParquetOps.requireCompatibleParquetType(t, parquetType)
+ new ParquetPrimitiveConverter(updater) {
+ override def addLong(value: Long): Unit = {
+ this.updater.setLong(DateTimeUtils.microsToNanos(value))
+ }
+ }
+ }
+
+ // ==================== Vectorized Read ====================
+
+ override def isBatchReadSupported(sqlConf: SQLConf): Boolean = true
+}
+
+private[ops] object TimeTypeParquetOps {
+
+ /**
+ * Validates that a Parquet field can be decoded as TimeType. TimeType is
stored
+ * as INT64 with TIME(MICROS, isAdjustedToUTC=false). Any other encoding (raw
+ * INT64, INT64 TIME(NANOS), INT32 TIME(MILLIS), INT64 TIMESTAMP(_), decimal-
+ * annotated, etc.) cannot be decoded as TimeType - throw the same error as
+ * the legacy ParquetRowConverter path so reads fail loudly instead of
+ * silently misinterpreting bytes.
+ */
+ private[ops] def requireCompatibleParquetType(
+ sparkType: TimeType, parquetType: Type): Unit = {
+ val ok = parquetType.isPrimitive &&
+ parquetType.asPrimitiveType.getPrimitiveTypeName == INT64 &&
+ (parquetType.getLogicalTypeAnnotation match {
+ case t: LogicalTypeAnnotation.TimeLogicalTypeAnnotation =>
+ t.getUnit == TimeUnit.MICROS && !t.isAdjustedToUTC
+ case _ => false
+ })
+ if (!ok) {
+ throw QueryExecutionErrors.cannotCreateParquetConverterForDataTypeError(
+ sparkType, parquetType.toString)
+ }
+ }
+}
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/TimeTypeParquetOpsSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/TimeTypeParquetOpsSuite.scala
new file mode 100644
index 000000000000..9a21b5e3f4bb
--- /dev/null
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/TimeTypeParquetOpsSuite.scala
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.datasources.parquet.types.ops
+
+import org.apache.parquet.schema.{LogicalTypeAnnotation, Type, Types}
+import org.apache.parquet.schema.LogicalTypeAnnotation.TimeUnit
+import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.{INT32, INT64}
+import org.apache.parquet.schema.Type.Repetition.REQUIRED
+
+import org.apache.spark.{SparkFunSuite, SparkRuntimeException}
+import org.apache.spark.sql.types.TimeType
+
+/**
+ * Unit tests for [[TimeTypeParquetOps.requireCompatibleParquetType]].
+ *
+ * TimeType is stored in Parquet as INT64 TIME(MICROS, isAdjustedToUTC=false).
+ * The read-path guard accepts only that canonical encoding and rejects every
+ * other primitive/annotation combination so that reading fails loudly rather
+ * than silently mis-decoding (e.g. interpreting NANOS as MICROS, which would
+ * be off by 1000x).
+ *
+ * Note: rejecting isAdjustedToUTC=true is stricter than the legacy
+ * ParquetRowConverter guard, which accepts that encoding. This is a known,
+ * intentional divergence between the framework and legacy paths for this
+ * single case; reconciling it (either by relaxing the framework guard or
+ * tightening the legacy one) is tracked by SPARK-57416.
+ */
+class TimeTypeParquetOpsSuite extends SparkFunSuite {
+
+ private val timeMicros = TimeType(TimeType.MICROS_PRECISION)
+
+ // ---------- accept ----------
+
+ test("accepts INT64 TIME(MICROS, isAdjustedToUTC=false) - the canonical
encoding") {
+ val field = Types.primitive(INT64, REQUIRED)
+ .as(LogicalTypeAnnotation.timeType(false, TimeUnit.MICROS))
+ .named("c")
+ // Must not throw.
+ TimeTypeParquetOps.requireCompatibleParquetType(timeMicros, field)
+ }
+
+ // ---------- the four primary reject paths ----------
+
+ test("rejects raw INT64 with no logical type annotation") {
+ val field = Types.primitive(INT64, REQUIRED).named("c")
+ assertRejects(timeMicros, field)
+ }
+
+ test("rejects INT64 TIME(NANOS, isAdjustedToUTC=false)") {
+ val field = Types.primitive(INT64, REQUIRED)
+ .as(LogicalTypeAnnotation.timeType(false, TimeUnit.NANOS))
+ .named("c")
+ assertRejects(timeMicros, field)
+ }
+
+ test("rejects INT32 TIME(MILLIS, isAdjustedToUTC=false)") {
+ // Per Parquet spec TIME(MILLIS) is INT32; the primitive-type guard
catches it.
+ val field = Types.primitive(INT32, REQUIRED)
+ .as(LogicalTypeAnnotation.timeType(false, TimeUnit.MILLIS))
+ .named("c")
+ assertRejects(timeMicros, field)
+ }
+
+ test("rejects INT64 TIME(MICROS, isAdjustedToUTC=true)") {
+ // The intended framework behavior is to reject this encoding: the
canonical
+ // TimeType representation is local-time (isAdjustedToUTC=false). The
legacy
+ // ParquetRowConverter guard accepts the encoding, so this is a known,
+ // intentional framework-vs-legacy divergence; reconciliation is tracked by
+ // SPARK-57416.
+ val field = Types.primitive(INT64, REQUIRED)
+ .as(LogicalTypeAnnotation.timeType(true, TimeUnit.MICROS))
+ .named("c")
+ assertRejects(timeMicros, field)
+ }
+
+ // ---------- additional rejects for full reject-set coverage ----------
+
+ test("rejects INT64 TIMESTAMP(MICROS) - wrong annotation kind") {
+ val field = Types.primitive(INT64, REQUIRED)
+ .as(LogicalTypeAnnotation.timestampType(false, TimeUnit.MICROS))
+ .named("c")
+ assertRejects(timeMicros, field)
+ }
+
+ test("rejects INT64 DECIMAL - wrong annotation kind") {
+ val field = Types.primitive(INT64, REQUIRED)
+ .as(LogicalTypeAnnotation.decimalType(2, 18))
+ .named("c")
+ assertRejects(timeMicros, field)
+ }
+
+ test("rejects non-primitive (group) type") {
+ val field: Type = Types.buildGroup(REQUIRED).named("c")
+ assertRejects(timeMicros, field)
+ }
+
+ // Note: a "BINARY with TIME(MICROS) annotation" combination is impossible to
+ // construct - the parquet-mr Types builder itself rejects it with
+ // IllegalStateException("TIME(MICROS,false) can only annotate INT64"). So
the
+ // wrong-primitive branch of requireCompatibleParquetType is unreachable for
+ // the TIME annotation; the raw-INT64 / TIMESTAMP / DECIMAL / group tests
+ // above already exercise the !isPrimitive and "non-TIME annotation"
branches.
+
+ // ---------- helper ----------
+
+ private def assertRejects(sparkType: TimeType, field: Type): Unit = {
+ val ex = intercept[SparkRuntimeException] {
+ TimeTypeParquetOps.requireCompatibleParquetType(sparkType, field)
+ }
+ assert(ex.getCondition === "PARQUET_CONVERSION_FAILURE.UNSUPPORTED",
+ s"expected PARQUET_CONVERSION_FAILURE.UNSUPPORTED, got
${ex.getCondition}")
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]