Skip to content

Commit a2a3ff2

Browse files
committed
feat: optional timezone for coerce_int96
Add a new TableParquetOptions field coerce_int96_tz that, when set, makes coerce_int96_to_resolution produce Timestamp(unit, Some(tz)) instead of the default Timestamp(unit, None). Threads the new option through ParquetSource, ParquetOpener, and DFParquetMetadata. Spark and other systems write INT96 as UTC-adjusted instants, so downstream readers that need the resulting Arrow type to carry the LTZ signal (for example, Comet's schema adapter rejecting INT96 -> TimestampNTZ on Spark 3.x) can set this to "UTC". Default behavior is unchanged when the option is unset.
1 parent 47655fd commit a2a3ff2

6 files changed

Lines changed: 47 additions & 4 deletions

File tree

datafusion/common/src/config.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -908,6 +908,16 @@ config_namespace! {
908908
/// nanosecond resolution.
909909
pub coerce_int96: Option<String>, transform = str::to_lowercase, default = None
910910

911+
/// (reading) Optional timezone applied to INT96 columns when `coerce_int96`
912+
/// is set. When `Some`, INT96 columns coerce to
913+
/// `Timestamp(<coerce_int96>, Some(<tz>))` instead of the default
914+
/// `Timestamp(<coerce_int96>, None)`. Spark and other systems write INT96
915+
/// values as UTC-adjusted instants, so callers that need the resulting
916+
/// Arrow type to be timezone-aware (e.g. for Spark `TimestampType`
917+
/// semantics) should set this to `"UTC"`. No effect when `coerce_int96`
918+
/// is `None`.
919+
pub coerce_int96_tz: Option<String>, default = None
920+
911921
/// (reading) Use any available bloom filters when reading parquet files
912922
pub bloom_filter_on_read: bool, default = true
913923

datafusion/common/src/file_options/parquet_writer.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ impl ParquetOptions {
208208
schema_force_view_types: _,
209209
binary_as_string: _, // not used for writer props
210210
coerce_int96: _, // not used for writer props
211+
coerce_int96_tz: _, // not used for writer props
211212
skip_arrow_metadata: _,
212213
max_predicate_cache_size: _,
213214
} = self;

datafusion/datasource-parquet/src/file_format.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -713,10 +713,17 @@ pub fn apply_file_schema_type_coercions(
713713
}
714714

715715
/// Coerces the file schema's Timestamps to the provided TimeUnit if Parquet schema contains INT96.
716+
///
717+
/// When `timezone` is `Some`, INT96-derived columns coerce to
718+
/// `Timestamp(time_unit, Some(timezone))`; otherwise they coerce to
719+
/// `Timestamp(time_unit, None)` (the historical default). Spark and other
720+
/// systems write INT96 as UTC-adjusted instants, so callers that need the
721+
/// resulting Arrow type to be timezone-aware should pass `Some("UTC".into())`.
716722
pub fn coerce_int96_to_resolution(
717723
parquet_schema: &SchemaDescriptor,
718724
file_schema: &Schema,
719725
time_unit: &TimeUnit,
726+
timezone: Option<Arc<str>>,
720727
) -> Option<Schema> {
721728
// Traverse the parquet_schema columns looking for int96 physical types. If encountered, insert
722729
// the field's full path into a set.
@@ -879,11 +886,11 @@ pub fn coerce_int96_to_resolution(
879886
(DataType::Timestamp(TimeUnit::Nanosecond, None), None)
880887
if int96_fields.contains(parquet_path.concat().as_str()) =>
881888
// We found a timestamp(nanos) and it originated as int96. Coerce it to the correct
882-
// time_unit.
889+
// time_unit, optionally attaching the requested timezone.
883890
{
884891
parent_fields.borrow_mut().push(field_with_new_type(
885892
current_field,
886-
DataType::Timestamp(*time_unit, None),
893+
DataType::Timestamp(*time_unit, timezone.clone()),
887894
));
888895
}
889896
// Other types can be cloned as they are.
@@ -1852,7 +1859,7 @@ mod tests {
18521859
let arrow_schema = parquet_to_arrow_schema(&descr, None).unwrap();
18531860

18541861
let result =
1855-
coerce_int96_to_resolution(&descr, &arrow_schema, &TimeUnit::Microsecond)
1862+
coerce_int96_to_resolution(&descr, &arrow_schema, &TimeUnit::Microsecond, None)
18561863
.unwrap();
18571864

18581865
// Only the first field (c0) should be converted to a microsecond timestamp because it's the
@@ -1939,7 +1946,7 @@ mod tests {
19391946
let arrow_schema = parquet_to_arrow_schema(&descr, None).unwrap();
19401947

19411948
let result =
1942-
coerce_int96_to_resolution(&descr, &arrow_schema, &TimeUnit::Microsecond)
1949+
coerce_int96_to_resolution(&descr, &arrow_schema, &TimeUnit::Microsecond, None)
19431950
.unwrap();
19441951

19451952
let expected_schema = Schema::new(vec![

datafusion/datasource-parquet/src/metadata.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ pub struct DFParquetMetadata<'a> {
7272
file_metadata_cache: Option<Arc<dyn FileMetadataCache>>,
7373
/// timeunit to coerce INT96 timestamps to
7474
pub coerce_int96: Option<TimeUnit>,
75+
/// Optional timezone applied to INT96-coerced timestamps.
76+
pub coerce_int96_tz: Option<Arc<str>>,
7577
}
7678

7779
impl<'a> DFParquetMetadata<'a> {
@@ -83,6 +85,7 @@ impl<'a> DFParquetMetadata<'a> {
8385
decryption_properties: None,
8486
file_metadata_cache: None,
8587
coerce_int96: None,
88+
coerce_int96_tz: None,
8689
}
8790
}
8891

@@ -116,6 +119,12 @@ impl<'a> DFParquetMetadata<'a> {
116119
self
117120
}
118121

122+
/// Set the optional timezone applied to INT96-coerced timestamps.
123+
pub fn with_coerce_int96_tz(mut self, timezone: Option<Arc<str>>) -> Self {
124+
self.coerce_int96_tz = timezone;
125+
self
126+
}
127+
119128
/// Fetch parquet metadata from the remote object store
120129
pub async fn fetch_metadata(&self) -> Result<Arc<ParquetMetaData>> {
121130
// implementation to fetch parquet metadata
@@ -222,6 +231,7 @@ impl<'a> DFParquetMetadata<'a> {
222231
file_metadata.schema_descr(),
223232
&schema,
224233
time_unit,
234+
self.coerce_int96_tz.clone(),
225235
)
226236
})
227237
.unwrap_or(schema);

datafusion/datasource-parquet/src/opener.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ pub(super) struct ParquetMorselizer {
119119
pub enable_row_group_stats_pruning: bool,
120120
/// Coerce INT96 timestamps to specific TimeUnit
121121
pub coerce_int96: Option<TimeUnit>,
122+
/// Optional timezone applied to INT96-coerced timestamps. When `Some`, the
123+
/// coerced column type becomes `Timestamp(<coerce_int96>, Some(<tz>))`.
124+
/// No effect when `coerce_int96` is `None`.
125+
pub coerce_int96_tz: Option<Arc<str>>,
122126
/// Optional parquet FileDecryptionProperties
123127
#[cfg(feature = "parquet_encryption")]
124128
pub file_decryption_properties: Option<Arc<FileDecryptionProperties>>,
@@ -281,6 +285,7 @@ struct PreparedParquetOpen {
281285
enable_row_group_stats_pruning: bool,
282286
limit: Option<usize>,
283287
coerce_int96: Option<TimeUnit>,
288+
coerce_int96_tz: Option<Arc<str>>,
284289
expr_adapter_factory: Arc<dyn PhysicalExprAdapterFactory>,
285290
predicate_creation_errors: Count,
286291
max_predicate_cache_size: Option<usize>,
@@ -651,6 +656,7 @@ impl ParquetMorselizer {
651656
enable_row_group_stats_pruning: self.enable_row_group_stats_pruning,
652657
limit: self.limit,
653658
coerce_int96: self.coerce_int96,
659+
coerce_int96_tz: self.coerce_int96_tz.clone(),
654660
expr_adapter_factory: Arc::clone(&self.expr_adapter_factory),
655661
predicate_creation_errors,
656662
max_predicate_cache_size: self.max_predicate_cache_size,
@@ -782,6 +788,7 @@ impl MetadataLoadedParquetOpen {
782788
reader_metadata.parquet_schema(),
783789
&physical_file_schema,
784790
coerce,
791+
prepared.coerce_int96_tz.clone(),
785792
)
786793
{
787794
physical_file_schema = Arc::new(merged);
@@ -1748,6 +1755,7 @@ mod test {
17481755
enable_bloom_filter: self.enable_bloom_filter,
17491756
enable_row_group_stats_pruning: self.enable_row_group_stats_pruning,
17501757
coerce_int96: self.coerce_int96,
1758+
coerce_int96_tz: None,
17511759
#[cfg(feature = "parquet_encryption")]
17521760
file_decryption_properties: None,
17531761
expr_adapter_factory: Arc::new(DefaultPhysicalExprAdapterFactory),

datafusion/datasource-parquet/src/source.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,12 @@ impl FileSource for ParquetSource {
557557
.coerce_int96
558558
.as_ref()
559559
.map(|time_unit| parse_coerce_int96_string(time_unit.as_str()).unwrap());
560+
let coerce_int96_tz = self
561+
.table_parquet_options
562+
.global
563+
.coerce_int96_tz
564+
.as_ref()
565+
.map(|tz| Arc::<str>::from(tz.as_str()));
560566

561567
Ok(Box::new(ParquetMorselizer {
562568
partition_index: partition,
@@ -578,6 +584,7 @@ impl FileSource for ParquetSource {
578584
enable_bloom_filter: self.bloom_filter_on_read(),
579585
enable_row_group_stats_pruning: self.table_parquet_options.global.pruning,
580586
coerce_int96,
587+
coerce_int96_tz,
581588
#[cfg(feature = "parquet_encryption")]
582589
file_decryption_properties,
583590
expr_adapter_factory,

0 commit comments

Comments
 (0)