1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum SortDirection {
27 Asc,
28 Desc,
29}
30
31impl SortDirection {
32 pub fn parse(value: &str) -> Self {
35 if value.trim().eq_ignore_ascii_case("DESC") {
36 SortDirection::Desc
37 } else {
38 SortDirection::Asc
39 }
40 }
41
42 pub fn to_catalog_string(self) -> &'static str {
44 match self {
45 SortDirection::Asc => "ASC",
46 SortDirection::Desc => "DESC",
47 }
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum NullOrder {
55 NullsFirst,
56 NullsLast,
57}
58
59impl NullOrder {
60 pub fn parse(value: &str) -> Self {
63 if value.trim().eq_ignore_ascii_case("NULLS_FIRST") {
64 NullOrder::NullsFirst
65 } else {
66 NullOrder::NullsLast
67 }
68 }
69
70 pub fn to_catalog_string(self) -> &'static str {
72 match self {
73 NullOrder::NullsFirst => "NULLS_FIRST",
74 NullOrder::NullsLast => "NULLS_LAST",
75 }
76 }
77
78 pub fn nulls_first(self) -> bool {
80 matches!(self, NullOrder::NullsFirst)
81 }
82}
83
84pub const DUCKDB_DIALECT: &str = "duckdb";
88
89#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct SortField {
98 pub sort_key_index: i32,
100 pub expression: String,
102 pub dialect: String,
104 pub direction: SortDirection,
106 pub null_order: NullOrder,
108}
109
110impl SortField {
111 pub fn column(
114 sort_key_index: i32,
115 column: impl Into<String>,
116 direction: SortDirection,
117 null_order: NullOrder,
118 ) -> Self {
119 SortField {
120 sort_key_index,
121 expression: column.into(),
122 dialect: DUCKDB_DIALECT.to_string(),
123 direction,
124 null_order,
125 }
126 }
127
128 pub fn column_candidate(&self) -> Option<String> {
134 parse_bare_column(&self.expression)
135 }
136}
137
138fn parse_bare_column(expr: &str) -> Option<String> {
143 let trimmed = expr.trim();
144 if trimmed.is_empty() {
145 return None;
146 }
147 if let Some(inner) = trimmed.strip_prefix('"').and_then(|s| s.strip_suffix('"'))
149 && !inner.is_empty()
150 && !inner.contains('"')
151 {
152 return Some(inner.to_string());
153 }
154 let mut chars = trimmed.chars();
156 let first = chars.next()?;
157 if !(first.is_ascii_alphabetic() || first == '_') {
158 return None;
159 }
160 if trimmed
161 .chars()
162 .all(|c| c.is_ascii_alphanumeric() || c == '_')
163 {
164 Some(trimmed.to_string())
165 } else {
166 None
167 }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct SortSpec {
173 pub sort_id: i64,
175 pub fields: Vec<SortField>,
177}
178
179impl SortSpec {
180 pub fn is_producible(&self) -> bool {
184 !self.fields.is_empty() && self.fields.iter().all(|f| f.column_candidate().is_some())
185 }
186
187 pub fn producible_columns(&self) -> Option<Vec<(String, SortDirection, NullOrder)>> {
190 self.fields
196 .iter()
197 .filter(|f| f.dialect.eq_ignore_ascii_case("duckdb"))
198 .map(|f| f.column_candidate().map(|c| (c, f.direction, f.null_order)))
199 .collect()
200 }
201
202 pub fn from_rows(rows: Vec<(i64, i32, String, String, String, String)>) -> Option<SortSpec> {
208 let sort_id = rows.first()?.0;
209 let fields = rows
210 .into_iter()
211 .map(
212 |(_, sort_key_index, expression, dialect, sort_direction, null_order)| SortField {
213 sort_key_index,
214 expression,
215 dialect,
216 direction: SortDirection::parse(&sort_direction),
217 null_order: NullOrder::parse(&null_order),
218 },
219 )
220 .collect();
221 Some(SortSpec {
222 sort_id,
223 fields,
224 })
225 }
226}
227
228#[cfg(feature = "write")]
247pub(crate) fn sort_batches_by_spec(
248 batches: Vec<arrow::record_batch::RecordBatch>,
249 data_schema: &arrow::datatypes::Schema,
250 sort_spec: Option<&SortSpec>,
251) -> crate::Result<Vec<arrow::record_batch::RecordBatch>> {
252 use arrow::array::{ArrayRef, RecordBatch};
253 use std::sync::Arc;
254
255 let Some(keys) = sort_spec.and_then(|spec| spec.producible_columns()) else {
256 return Ok(batches);
257 };
258 if keys.is_empty() || batches.iter().all(|b| b.num_rows() == 0) {
259 return Ok(batches);
260 }
261 let mut resolved = Vec::with_capacity(keys.len());
262 for (name, direction, null_order) in &keys {
263 let Ok(index) = data_schema.index_of(name) else {
264 return Ok(batches);
265 };
266 resolved.push((
267 index,
268 arrow::compute::SortOptions {
269 descending: matches!(direction, SortDirection::Desc),
270 nulls_first: null_order.nulls_first(),
271 },
272 ));
273 }
274 let full_schema = batches[0].schema();
275 let combined = arrow::compute::concat_batches(&full_schema, &batches)?;
276 let sort_columns: Vec<arrow::compute::SortColumn> = resolved
277 .iter()
278 .map(|(index, options)| arrow::compute::SortColumn {
279 values: Arc::clone(combined.column(*index)),
280 options: Some(*options),
281 })
282 .collect();
283 let indices = arrow::compute::lexsort_to_indices(&sort_columns, None)?;
284 let sorted_columns = combined
285 .columns()
286 .iter()
287 .map(|c| arrow::compute::take(c, &indices, None))
288 .collect::<std::result::Result<Vec<ArrayRef>, _>>()?;
289 let sorted_columns = sorted_columns
290 .iter()
291 .zip(full_schema.fields())
292 .map(|(column, field)| crate::column_rename::coerce_column(column, field.data_type()))
293 .collect::<datafusion::common::Result<Vec<_>>>()?;
294 Ok(vec![RecordBatch::try_new(full_schema, sorted_columns)?])
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn direction_roundtrip_and_case_insensitive() {
303 assert_eq!(SortDirection::parse("ASC"), SortDirection::Asc);
304 assert_eq!(SortDirection::parse("desc"), SortDirection::Desc);
305 assert_eq!(SortDirection::parse("DESC"), SortDirection::Desc);
306 assert_eq!(SortDirection::parse("whatever"), SortDirection::Asc);
308 assert_eq!(SortDirection::Asc.to_catalog_string(), "ASC");
309 assert_eq!(SortDirection::Desc.to_catalog_string(), "DESC");
310 }
311
312 #[test]
313 fn null_order_roundtrip_and_case_insensitive() {
314 assert_eq!(NullOrder::parse("NULLS_FIRST"), NullOrder::NullsFirst);
315 assert_eq!(NullOrder::parse("nulls_first"), NullOrder::NullsFirst);
316 assert_eq!(NullOrder::parse("NULLS_LAST"), NullOrder::NullsLast);
317 assert_eq!(NullOrder::parse("anything"), NullOrder::NullsLast);
319 assert_eq!(NullOrder::NullsFirst.to_catalog_string(), "NULLS_FIRST");
320 assert_eq!(NullOrder::NullsLast.to_catalog_string(), "NULLS_LAST");
321 assert!(NullOrder::NullsFirst.nulls_first());
322 assert!(!NullOrder::NullsLast.nulls_first());
323 }
324
325 #[test]
326 fn bare_column_expressions_are_producible() {
327 assert_eq!(parse_bare_column("ts"), Some("ts".to_string()));
328 assert_eq!(
329 parse_bare_column(" device_id "),
330 Some("device_id".to_string())
331 );
332 assert_eq!(parse_bare_column("_x1"), Some("_x1".to_string()));
333 assert_eq!(parse_bare_column("\"My Col\""), Some("My Col".to_string()));
335 }
336
337 #[test]
338 fn non_column_expressions_are_not_producible() {
339 for expr in ["", "date_trunc('day', ts)", "a + b", "t.ts", "1", "ts, device_id", "\"\""] {
340 assert_eq!(
341 parse_bare_column(expr),
342 None,
343 "expr {expr:?} should not be a bare column"
344 );
345 }
346 }
347
348 #[test]
349 fn producible_only_when_all_keys_are_columns() {
350 let ok = SortSpec {
351 sort_id: 1,
352 fields: vec![
353 SortField::column(0, "device_id", SortDirection::Asc, NullOrder::NullsLast),
354 SortField::column(1, "ts", SortDirection::Desc, NullOrder::NullsFirst),
355 ],
356 };
357 assert!(ok.is_producible());
358 assert_eq!(
359 ok.producible_columns().unwrap(),
360 vec![
361 (
362 "device_id".to_string(),
363 SortDirection::Asc,
364 NullOrder::NullsLast
365 ),
366 ("ts".to_string(), SortDirection::Desc, NullOrder::NullsFirst),
367 ]
368 );
369
370 let mixed = SortSpec {
371 sort_id: 2,
372 fields: vec![
373 SortField::column(0, "device_id", SortDirection::Asc, NullOrder::NullsLast),
374 SortField {
375 sort_key_index: 1,
376 expression: "date_trunc('day', ts)".to_string(),
377 dialect: DUCKDB_DIALECT.to_string(),
378 direction: SortDirection::Asc,
379 null_order: NullOrder::NullsLast,
380 },
381 ],
382 };
383 assert!(!mixed.is_producible());
384 assert_eq!(mixed.producible_columns(), None);
385 }
386
387 #[test]
388 fn from_rows_orders_and_parses() {
389 let rows = vec![
390 (
391 7,
392 0,
393 "device_id".to_string(),
394 "duckdb".to_string(),
395 "ASC".to_string(),
396 "NULLS_LAST".to_string(),
397 ),
398 (
399 7,
400 1,
401 "ts".to_string(),
402 "duckdb".to_string(),
403 "DESC".to_string(),
404 "NULLS_FIRST".to_string(),
405 ),
406 ];
407 let spec = SortSpec::from_rows(rows).unwrap();
408 assert_eq!(spec.sort_id, 7);
409 assert_eq!(spec.fields.len(), 2);
410 assert_eq!(spec.fields[0].direction, SortDirection::Asc);
411 assert_eq!(spec.fields[0].null_order, NullOrder::NullsLast);
412 assert_eq!(spec.fields[1].direction, SortDirection::Desc);
413 assert_eq!(spec.fields[1].null_order, NullOrder::NullsFirst);
414 assert!(spec.is_producible());
415 }
416
417 #[test]
418 fn from_rows_empty_is_none() {
419 assert_eq!(SortSpec::from_rows(vec![]), None);
420 }
421
422 #[cfg(feature = "write")]
423 #[test]
424 fn sort_batches_preserves_nested_field_metadata() {
425 use std::{collections::HashMap, sync::Arc};
426
427 use arrow::{
428 array::{ArrayRef, Int32Array, Int64Array, StringArray, StructArray},
429 datatypes::{DataType, Field, Schema},
430 record_batch::RecordBatch,
431 };
432
433 let field_id =
434 |value: &str| HashMap::from([("PARQUET:field_id".to_string(), value.to_string())]);
435 let money_fields = vec![
436 Arc::new(Field::new("amount", DataType::Int32, false).with_metadata(field_id("2"))),
437 Arc::new(Field::new("currency", DataType::Utf8, false).with_metadata(field_id("3"))),
438 ];
439 let money: ArrayRef = Arc::new(StructArray::new(
440 money_fields.clone().into(),
441 vec![
442 Arc::new(Int32Array::from(vec![20, 10])),
443 Arc::new(StringArray::from(vec!["EUR", "USD"])),
444 ],
445 None,
446 ));
447 let schema = Arc::new(Schema::new(vec![
448 Field::new("id", DataType::Int64, false),
449 Field::new("money", DataType::Struct(money_fields.into()), false),
450 ]));
451 let batch = RecordBatch::try_new(
452 schema.clone(),
453 vec![Arc::new(Int64Array::from(vec![2, 1])), money],
454 )
455 .unwrap();
456 let sort_spec = SortSpec {
457 sort_id: 1,
458 fields: vec![SortField::column(0, "id", SortDirection::Asc, NullOrder::NullsLast)],
459 };
460
461 let sorted = sort_batches_by_spec(vec![batch], &schema, Some(&sort_spec)).unwrap();
462
463 assert_eq!(
464 sorted[0]
465 .column(0)
466 .as_any()
467 .downcast_ref::<Int64Array>()
468 .unwrap()
469 .values(),
470 &[1, 2]
471 );
472 assert_eq!(
473 sorted[0].schema().field(1).data_type(),
474 sorted[0].column(1).data_type()
475 );
476 let DataType::Struct(fields) = sorted[0].column(1).data_type() else {
477 panic!("money must remain a struct");
478 };
479 assert_eq!(
480 fields[0].metadata().get("PARQUET:field_id"),
481 Some(&"2".into())
482 );
483 assert_eq!(
484 fields[1].metadata().get("PARQUET:field_id"),
485 Some(&"3".into())
486 );
487 }
488}