@@ -71,24 +71,24 @@ fn requires_unsigned_byte_array_order(column: &ColumnDescriptor) -> bool {
7171/// Arrow's string and binary comparisons. Even the modern bounds cannot be
7272/// interpreted without the corresponding footer `column_orders` entry.
7373/// Signed logical types, such as decimals, retain their existing behavior.
74- /// Columns with undefined sort orders, such as `INT96`, never have usable
75- /// min/max bounds regardless of their physical type. The `INT96` check is
76- /// defensive because parquet-rs does not currently expose those bounds .
74+ /// Columns with undefined sort orders never have usable min/max bounds.
75+ /// `INT96` timestamp bounds require an explicit `INT96_TIMESTAMP_ORDER`
76+ /// footer entry; the schema's timestamp sort order alone is not sufficient .
7777pub ( crate ) fn has_untrusted_min_max_order (
7878 parquet_schema : & SchemaDescriptor ,
7979 column_orders : Option < & [ ColumnOrder ] > ,
8080 parquet_column_index : usize ,
8181) -> bool {
8282 let column = parquet_schema. column ( parquet_column_index) ;
83- // As of arrow 60, INT96 columns report `SortOrder::INT96_TIMESTAMP`
84- // rather than `UNDEFINED`; keep treating their min/max as untrusted.
85- // until <https://github.com/apache/datafusion/issues/25484>
86- if matches ! (
87- column. sort_order( ) ,
88- SortOrder :: UNDEFINED | SortOrder :: INT96_TIMESTAMP
89- ) {
83+ if column. sort_order ( ) == SortOrder :: UNDEFINED {
9084 return true ;
9185 }
86+ if column. sort_order ( ) == SortOrder :: INT96_TIMESTAMP {
87+ return column_orders
88+ . and_then ( |orders| orders. get ( parquet_column_index) )
89+ . copied ( )
90+ != Some ( ColumnOrder :: INT96_TIMESTAMP_ORDER ) ;
91+ }
9292 requires_unsigned_byte_array_order ( & column)
9393 && ( column. sort_order ( ) != SortOrder :: UNSIGNED
9494 || column_orders
@@ -222,7 +222,7 @@ impl<'a> DFParquetMetadata<'a> {
222222 }
223223
224224 /// Set the [`TimeUnit`] that INT96 timestamp columns should be coerced
225- /// to when reading the schema.
225+ /// to when reading the schema and statistics .
226226 ///
227227 /// INT96 in Parquet has no defined unit or timezone, so leaving this
228228 /// `None` reads INT96 columns as nanosecond timestamps with no timezone
@@ -464,7 +464,12 @@ impl<'a> DFParquetMetadata<'a> {
464464 /// the statistics in the metadata using [`Self::statistics_from_parquet_metadata`]
465465 pub async fn fetch_statistics ( & self , table_schema : & SchemaRef ) -> Result < Statistics > {
466466 let metadata = self . fetch_metadata ( ) . await ?;
467- Self :: statistics_from_parquet_metadata ( & metadata, table_schema)
467+ Self :: statistics_from_parquet_metadata_with_coercion (
468+ & metadata,
469+ table_schema,
470+ self . coerce_int96 ,
471+ self . coerce_int96_tz . clone ( ) ,
472+ )
468473 }
469474
470475 /// Convert statistics in [`ParquetMetaData`] into [`Statistics`] using [`StatisticsConverter`]
@@ -499,6 +504,10 @@ impl<'a> DFParquetMetadata<'a> {
499504 /// 2. The column is in arrow schema, but not in parquet schema due to schema revolution, min/max values are set to Precision::Exact(null)
500505 /// - Null counts are set to Precision::Exact(num_rows) (conservatively assuming all values could be null)
501506 ///
507+ /// INT96 timestamps use the reader's default nanosecond resolution. Use
508+ /// [`Self::fetch_statistics`] with [`Self::with_coerce_int96`] when the
509+ /// reader is configured to decode INT96 at another resolution.
510+ ///
502511 /// # Byte Size Calculation:
503512 ///
504513 /// - For primitive types with known fixed size, exact byte size is calculated as (byte width * number of rows)
@@ -507,6 +516,21 @@ impl<'a> DFParquetMetadata<'a> {
507516 pub fn statistics_from_parquet_metadata (
508517 metadata : & ParquetMetaData ,
509518 logical_file_schema : & SchemaRef ,
519+ ) -> Result < Statistics > {
520+ Self :: statistics_from_parquet_metadata_with_coercion (
521+ metadata,
522+ logical_file_schema,
523+ None ,
524+ None ,
525+ )
526+ }
527+
528+ /// Convert statistics using the same INT96 settings as the data reader.
529+ pub ( crate ) fn statistics_from_parquet_metadata_with_coercion (
530+ metadata : & ParquetMetaData ,
531+ logical_file_schema : & SchemaRef ,
532+ coerce_int96 : Option < TimeUnit > ,
533+ coerce_int96_tz : Option < Arc < str > > ,
510534 ) -> Result < Statistics > {
511535 let row_groups_metadata = metadata. row_groups ( ) ;
512536
@@ -540,6 +564,21 @@ impl<'a> DFParquetMetadata<'a> {
540564 physical_file_schema = merged;
541565 }
542566
567+ // Match the reader's configured INT96 resolution, rather than assuming
568+ // that the table's timestamp type is the type physically read. In
569+ // particular, converting through nanoseconds can wrap wider timestamps.
570+ if let Some ( unit) = coerce_int96
571+ && let Some ( coerced) = Int96Coercer :: new (
572+ file_metadata. schema_descr ( ) ,
573+ & physical_file_schema,
574+ & unit,
575+ )
576+ . with_timezone ( coerce_int96_tz)
577+ . coerce ( )
578+ {
579+ physical_file_schema = coerced;
580+ }
581+
543582 statistics. column_statistics =
544583 if has_statistics {
545584 let ( mut max_accs, mut min_accs) =
@@ -567,11 +606,21 @@ impl<'a> DFParquetMetadata<'a> {
567606 stats_converter. with_missing_null_counts_as_zero ( false ) ;
568607 let parquet_index = stats_converter. parquet_column_index ( ) ;
569608 if parquet_index. is_some_and ( |index| {
570- has_untrusted_min_max_order (
571- file_metadata. schema_descr ( ) ,
572- file_metadata. column_orders ( ) . map ( Vec :: as_slice) ,
573- index,
574- )
609+ // A schema cast is not an INT96 read coercion.
610+ // Keep bounds unknown if the actual reader unit
611+ // differs from the table's timestamp type.
612+ ( file_metadata
613+ . schema_descr ( )
614+ . column ( index)
615+ . physical_type ( )
616+ == PhysicalType :: INT96
617+ && stats_converter. arrow_field ( ) . data_type ( )
618+ != field. data_type ( ) )
619+ || has_untrusted_min_max_order (
620+ file_metadata. schema_descr ( ) ,
621+ file_metadata. column_orders ( ) . map ( Vec :: as_slice) ,
622+ index,
623+ )
575624 } ) || has_untrusted_byte_array_stats (
576625 file_metadata. schema_descr ( ) ,
577626 parquet_index,
0 commit comments