@@ -19,17 +19,19 @@ use std::fs::{File, create_dir_all, remove_dir_all};
1919use std:: path:: Path ;
2020use std:: sync:: Arc ;
2121
22- use arrow:: array:: { ArrayRef , Int32Array } ;
22+ use arrow:: array:: {
23+ ArrayRef , Int32Array , Int64Array , StringArray , TimestampNanosecondArray ,
24+ } ;
2325use arrow:: compute:: SortOptions ;
24- use arrow:: datatypes:: { DataType , Field , Schema , SchemaRef } ;
26+ use arrow:: datatypes:: { DataType , Field , Schema , SchemaRef , TimeUnit } ;
2527use arrow:: record_batch:: RecordBatch ;
2628use datafusion:: catalog:: streaming:: StreamingTable ;
2729use datafusion:: common:: { ScalarValue , SplitPoint } ;
2830use datafusion:: datasource:: file_format:: parquet:: ParquetFormat ;
2931use datafusion:: datasource:: listing:: {
3032 ListingOptions , ListingTable , ListingTableConfig , ListingTableUrl ,
3133} ;
32- use datafusion:: logical_expr:: { Partitioning , RangePartitioning , col} ;
34+ use datafusion:: logical_expr:: { Partitioning , RangePartitioning , SortExpr , col} ;
3335use datafusion:: parquet:: arrow:: ArrowWriter ;
3436use datafusion:: physical_expr:: {
3537 Partitioning as PhysicalPartitioning , PhysicalSortExpr ,
@@ -95,8 +97,9 @@ pub(super) fn register_range_partitioned_table(ctx: &SessionContext) {
9597 "range_partitioned" ,
9698 & range_table_dir,
9799 Arc :: clone ( & schema) ,
98- RANGE_PARTITIONS ,
100+ range_batches ( & schema , RANGE_PARTITIONS ) ,
99101 output_partitioning,
102+ None ,
100103 ) ;
101104
102105 register_unbounded_range_stream_table (
@@ -132,8 +135,9 @@ pub(super) fn register_range_partitioned_table(ctx: &SessionContext) {
132135 Path :: new ( env ! ( "CARGO_MANIFEST_DIR" ) )
133136 . join ( "test_files/scratch_range_partitioning/range_partitioned_shifted" ) ,
134137 Arc :: clone ( & schema) ,
135- SHIFTED_RANGE_PARTITIONS ,
138+ range_batches ( & schema , SHIFTED_RANGE_PARTITIONS ) ,
136139 shifted_output_partitioning,
140+ None ,
137141 ) ;
138142
139143 // Same rows as `range_partitioned` but split into only three range
@@ -156,8 +160,9 @@ pub(super) fn register_range_partitioned_table(ctx: &SessionContext) {
156160 Path :: new ( env ! ( "CARGO_MANIFEST_DIR" ) )
157161 . join ( "test_files/scratch_range_partitioning/range_partitioned_narrow" ) ,
158162 Arc :: clone ( & schema) ,
159- NARROW_RANGE_PARTITIONS ,
163+ range_batches ( & schema , NARROW_RANGE_PARTITIONS ) ,
160164 narrow_output_partitioning,
165+ None ,
161166 ) ;
162167
163168 let sparse_output_partitioning = Partitioning :: Range (
@@ -178,8 +183,9 @@ pub(super) fn register_range_partitioned_table(ctx: &SessionContext) {
178183 Path :: new ( env ! ( "CARGO_MANIFEST_DIR" ) )
179184 . join ( "test_files/scratch_range_partitioning/range_partitioned_sparse" ) ,
180185 Arc :: clone ( & schema) ,
181- SPARSE_RANGE_PARTITIONS ,
186+ range_batches ( & schema , SPARSE_RANGE_PARTITIONS ) ,
182187 sparse_output_partitioning,
188+ None ,
183189 ) ;
184190}
185191
@@ -188,16 +194,16 @@ fn register_parquet_listing_table(
188194 name : & str ,
189195 table_dir : impl AsRef < Path > ,
190196 schema : SchemaRef ,
191- partitions : impl IntoIterator < Item = & ' static [ ( i32 , i32 , i32 ) ] > ,
197+ batches : Vec < RecordBatch > ,
192198 output_partitioning : Partitioning ,
199+ file_sort_order : Option < Vec < Vec < SortExpr > > > ,
193200) {
194201 let table_dir = table_dir. as_ref ( ) ;
195202 if table_dir. exists ( ) {
196203 remove_dir_all ( table_dir) . expect ( "test table dir should be removable" ) ;
197204 }
198205 create_dir_all ( table_dir) . expect ( "test table dir should be created" ) ;
199- for ( idx, rows) in partitions. into_iter ( ) . enumerate ( ) {
200- let batch = range_batch ( Arc :: clone ( & schema) , rows) ;
206+ for ( idx, batch) in batches. into_iter ( ) . enumerate ( ) {
201207 let file = File :: create ( table_dir. join ( format ! ( "part-{idx}.parquet" ) ) )
202208 . expect ( "test table parquet partition should be created" ) ;
203209 let mut writer = ArrowWriter :: try_new ( file, Arc :: clone ( & schema) , None )
@@ -218,8 +224,11 @@ fn register_parquet_listing_table(
218224 ) ;
219225 let table_url =
220226 ListingTableUrl :: parse ( & table_path) . expect ( "test table url should parse" ) ;
221- let options = ListingOptions :: new ( Arc :: new ( ParquetFormat :: default ( ) ) )
227+ let mut options = ListingOptions :: new ( Arc :: new ( ParquetFormat :: default ( ) ) )
222228 . with_output_partitioning ( Some ( output_partitioning) ) ;
229+ if let Some ( file_sort_order) = file_sort_order {
230+ options = options. with_file_sort_order ( file_sort_order) ;
231+ }
223232 let config = ListingTableConfig :: new ( table_url)
224233 . with_listing_options ( options)
225234 . with_schema ( schema) ;
@@ -278,6 +287,16 @@ fn range_stream_partition(
278287 ) ] ) )
279288}
280289
290+ fn range_batches (
291+ schema : & SchemaRef ,
292+ partitions : impl IntoIterator < Item = & ' static [ ( i32 , i32 , i32 ) ] > ,
293+ ) -> Vec < RecordBatch > {
294+ partitions
295+ . into_iter ( )
296+ . map ( |rows| range_batch ( Arc :: clone ( schema) , rows) )
297+ . collect ( )
298+ }
299+
281300fn range_batch ( schema : SchemaRef , rows : & [ ( i32 , i32 , i32 ) ] ) -> RecordBatch {
282301 RecordBatch :: try_new (
283302 schema,
@@ -292,3 +311,128 @@ fn range_batch(schema: SchemaRef, rows: &[(i32, i32, i32)]) -> RecordBatch {
292311 )
293312 . expect ( "range batch should be valid" )
294313}
314+
315+ // ==============================================================================
316+ // Time-bin table: range-partitioned on timestamp, sorted on (key, timestamp)
317+ // ==============================================================================
318+
319+ /// Unix nanoseconds for `2024-01-01 00:00:00 UTC`.
320+ const TIME_BIN_EPOCH_NS : i64 = 1_704_067_200_000_000_000 ;
321+ const NANOS_PER_SECOND : i64 = 1_000_000_000 ;
322+ const NANOS_PER_MINUTE : i64 = 60 * NANOS_PER_SECOND ;
323+
324+ /// Timestamp helper: minutes and seconds after `2024-01-01 00:00:00 UTC`.
325+ fn time_bin_ts ( minutes : i64 , seconds : i64 ) -> i64 {
326+ TIME_BIN_EPOCH_NS + minutes * NANOS_PER_MINUTE + seconds * NANOS_PER_SECOND
327+ }
328+
329+ /// Row: (key, col1, col2, col3, col4, timestamp_ns, value)
330+ type TimeBinRow = (
331+ & ' static str ,
332+ & ' static str ,
333+ & ' static str ,
334+ & ' static str ,
335+ & ' static str ,
336+ i64 ,
337+ i64 ,
338+ ) ;
339+
340+ /// Registers `range_sorted_time_bin` for time-bin aggregation plan tests.
341+ ///
342+ /// Two file groups, each covering a 60-minute timestamp range:
343+ /// - partition 0: `[2024-01-01 00:00, 01:00)`
344+ /// - partition 1: `[2024-01-01 01:00, 02:00)`
345+ ///
346+ /// Files are range-partitioned on `timestamp` and sorted on `(key, timestamp)`.
347+ /// Because `date_bin(60 seconds, timestamp)` does not straddle the hour split,
348+ /// grouping by `(key, time_bin)` is partition-disjoint. Today's planner still
349+ /// inserts a hash shuffle; the test pins that plan so a follow-up can remove it.
350+ pub ( super ) fn register_range_sorted_time_bin_table ( ctx : & SessionContext ) {
351+ let schema = Arc :: new ( Schema :: new ( vec ! [
352+ Field :: new( "key" , DataType :: Utf8 , false ) ,
353+ Field :: new( "col1" , DataType :: Utf8 , false ) ,
354+ Field :: new( "col2" , DataType :: Utf8 , false ) ,
355+ Field :: new( "col3" , DataType :: Utf8 , false ) ,
356+ Field :: new( "col4" , DataType :: Utf8 , false ) ,
357+ Field :: new(
358+ "timestamp" ,
359+ DataType :: Timestamp ( TimeUnit :: Nanosecond , None ) ,
360+ false ,
361+ ) ,
362+ Field :: new( "value" , DataType :: Int64 , false ) ,
363+ ] ) ) ;
364+
365+ // Each partition covers 60 minutes. The split is aligned to the 60-second
366+ // `date_bin` used by the test query, so time bins do not straddle files.
367+ let hour_split = time_bin_ts ( 60 , 0 ) ;
368+ let output_partitioning = Partitioning :: Range (
369+ RangePartitioning :: try_new (
370+ vec ! [ col( "timestamp" ) . sort( true , true ) ] ,
371+ vec ! [ SplitPoint :: new( vec![ ScalarValue :: TimestampNanosecond (
372+ Some ( hour_split) ,
373+ None ,
374+ ) ] ) ] ,
375+ )
376+ . expect ( "time-bin range partitioning should be valid" ) ,
377+ ) ;
378+
379+ // Within each 60-minute file, rows are sorted by (key, timestamp).
380+ let partitions = [
381+ vec ! [
382+ ( "k1" , "x" , "y" , "z" , "a" , time_bin_ts( 0 , 10 ) , 1 ) ,
383+ ( "k1" , "x" , "y" , "z" , "a" , time_bin_ts( 0 , 40 ) , 2 ) ,
384+ ( "k1" , "x" , "y" , "z" , "b" , time_bin_ts( 1 , 10 ) , 99 ) ,
385+ ( "k2" , "x" , "y" , "z" , "a" , time_bin_ts( 30 , 0 ) , 3 ) ,
386+ ( "k2" , "x" , "y" , "z" , "a" , time_bin_ts( 30 , 30 ) , 4 ) ,
387+ ] ,
388+ vec ! [
389+ ( "k1" , "x" , "y" , "z" , "a" , time_bin_ts( 60 , 10 ) , 10 ) ,
390+ ( "k1" , "x" , "y" , "z" , "a" , time_bin_ts( 60 , 40 ) , 20 ) ,
391+ ( "k2" , "x" , "y" , "z" , "a" , time_bin_ts( 90 , 0 ) , 30 ) ,
392+ ( "k2" , "x" , "y" , "z" , "a" , time_bin_ts( 105 , 0 ) , 5 ) ,
393+ ] ,
394+ ] ;
395+
396+ let table_dir = Path :: new ( env ! ( "CARGO_MANIFEST_DIR" ) )
397+ . join ( "test_files/scratch_range_partitioning/range_sorted_time_bin" ) ;
398+ let batches = partitions
399+ . iter ( )
400+ . map ( |rows| time_bin_batch ( Arc :: clone ( & schema) , rows) )
401+ . collect ( ) ;
402+ register_parquet_listing_table (
403+ ctx,
404+ "range_sorted_time_bin" ,
405+ & table_dir,
406+ Arc :: clone ( & schema) ,
407+ batches,
408+ output_partitioning,
409+ Some ( vec ! [ vec![
410+ col( "key" ) . sort( true , true ) ,
411+ col( "timestamp" ) . sort( true , true ) ,
412+ ] ] ) ,
413+ ) ;
414+ }
415+
416+ fn time_bin_batch ( schema : SchemaRef , rows : & [ TimeBinRow ] ) -> RecordBatch {
417+ RecordBatch :: try_new (
418+ schema,
419+ vec ! [
420+ Arc :: new( StringArray :: from_iter_values( rows. iter( ) . map( |row| row. 0 ) ) )
421+ as ArrayRef ,
422+ Arc :: new( StringArray :: from_iter_values( rows. iter( ) . map( |row| row. 1 ) ) )
423+ as ArrayRef ,
424+ Arc :: new( StringArray :: from_iter_values( rows. iter( ) . map( |row| row. 2 ) ) )
425+ as ArrayRef ,
426+ Arc :: new( StringArray :: from_iter_values( rows. iter( ) . map( |row| row. 3 ) ) )
427+ as ArrayRef ,
428+ Arc :: new( StringArray :: from_iter_values( rows. iter( ) . map( |row| row. 4 ) ) )
429+ as ArrayRef ,
430+ Arc :: new( TimestampNanosecondArray :: from_iter_values(
431+ rows. iter( ) . map( |row| row. 5 ) ,
432+ ) ) as ArrayRef ,
433+ Arc :: new( Int64Array :: from_iter_values( rows. iter( ) . map( |row| row. 6 ) ) )
434+ as ArrayRef ,
435+ ] ,
436+ )
437+ . expect ( "time-bin batch should be valid" )
438+ }
0 commit comments