Skip to main content

datafusion_ducklake/
sort.rs

1//! DuckLake table sort order: sort spec model.
2//!
3//! A sorted DuckLake table records, in the catalog, a **sort spec**
4//! (`ducklake_sort_info` + `ducklake_sort_expression`). Unlike a partition spec, a
5//! sort spec is *not* a pruning mechanism and carries no per-file catalog rows: its
6//! sole job is to order rows *within* each data file on write, which tightens the
7//! per-file min/max statistics so the existing statistics-based file pruner skips
8//! more files at query time. It is the DuckLake analogue of an Iceberg sort order;
9//! there is no Z-order / multi-dimensional clustering.
10//!
11//! Following the DuckLake spec, each sort key is stored as an **expression** string
12//! (with a `dialect`, always `"duckdb"`), plus a sort direction (`ASC`/`DESC`) and a
13//! null ordering (`NULLS_FIRST`/`NULLS_LAST`). Storing an expression (rather than a
14//! `column_id`) is what lets DuckDB sort by arbitrary expressions/macros.
15//!
16//! Scope note: this crate *produces* sort orders only for **bare column references**
17//! (`SORTED BY (device_id, ts DESC)`). A spec whose expression is anything more
18//! complex is *tolerated on read* and round-tripped verbatim. Any operation that
19//! would write or rewrite data rejects the unsupported expression before committing,
20//! because silently producing unsorted files would violate the table's active sort
21//! contract.
22
23/// A sort key's direction. Serializes to the catalog `sort_direction` string
24/// (`"ASC"` / `"DESC"`), matching DuckLake's on-disk form.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum SortDirection {
27    Asc,
28    Desc,
29}
30
31impl SortDirection {
32    /// Parse a catalog `sort_direction` string. Case-insensitive; matches DuckLake,
33    /// which treats `"DESC"` as descending and everything else as ascending.
34    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    /// The catalog `sort_direction` string this direction serializes to.
43    pub fn to_catalog_string(self) -> &'static str {
44        match self {
45            SortDirection::Asc => "ASC",
46            SortDirection::Desc => "DESC",
47        }
48    }
49}
50
51/// A sort key's null ordering. Serializes to the catalog `null_order` string
52/// (`"NULLS_FIRST"` / `"NULLS_LAST"`, underscore form), matching DuckLake.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum NullOrder {
55    NullsFirst,
56    NullsLast,
57}
58
59impl NullOrder {
60    /// Parse a catalog `null_order` string. Case-insensitive; matches DuckLake,
61    /// which treats `"NULLS_FIRST"` as nulls-first and everything else as nulls-last.
62    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    /// The catalog `null_order` string this ordering serializes to.
71    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    /// Whether nulls sort first, as the boolean Arrow / DataFusion sort options use.
79    pub fn nulls_first(self) -> bool {
80        matches!(self, NullOrder::NullsFirst)
81    }
82}
83
84/// The catalog `dialect` value this crate writes for every sort expression it
85/// produces. DuckLake round-trips sort expressions through the DuckDB parser, so a
86/// bare column name written under this dialect is read back identically.
87pub const DUCKDB_DIALECT: &str = "duckdb";
88
89/// One key of a sort spec: an expression to sort by, plus direction and null order.
90///
91/// The key is an expression *string* (`ducklake_sort_expression.expression`), not a
92/// `column_id` — DuckLake sort keys are expression-based. For a spec this crate
93/// produced, `expression` is a bare column name; for a spec written by DuckDB it may
94/// be any expression, in which case [`SortField::column_candidate`] returns `None`
95/// and the write path rejects the unsupported sort contract.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct SortField {
98    /// 0-based position of this key within the sort order.
99    pub sort_key_index: i32,
100    /// The sort expression (`ducklake_sort_expression.expression`).
101    pub expression: String,
102    /// The expression dialect (`ducklake_sort_expression.dialect`), e.g. `"duckdb"`.
103    pub dialect: String,
104    /// Ascending or descending.
105    pub direction: SortDirection,
106    /// Where nulls sort.
107    pub null_order: NullOrder,
108}
109
110impl SortField {
111    /// Build a bare-column sort field this crate can produce. `expression` is the
112    /// column name; dialect is set to [`DUCKDB_DIALECT`].
113    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    /// Interpret this key's expression as a bare column reference, returning the
129    /// column name if it is one. A bare column is either an unquoted simple
130    /// identifier (`ts`, `device_id`) or a double-quoted identifier (`"My Col"`,
131    /// unquoted here). Anything else — function calls, arithmetic, qualified names,
132    /// multiple tokens — yields `None`.
133    pub fn column_candidate(&self) -> Option<String> {
134        parse_bare_column(&self.expression)
135    }
136}
137
138/// Parse an expression string as a single bare column reference. Returns the column
139/// name (with surrounding double quotes stripped) or `None` if it is not a lone
140/// identifier. Deliberately conservative: only what we can safely map to one Arrow
141/// column.
142fn parse_bare_column(expr: &str) -> Option<String> {
143    let trimmed = expr.trim();
144    if trimmed.is_empty() {
145        return None;
146    }
147    // Double-quoted identifier: "..." with doubled "" escapes inside.
148    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    // Unquoted simple identifier: [A-Za-z_][A-Za-z0-9_]*
155    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/// A table's active sort spec (one generation of `ducklake_sort_info`).
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct SortSpec {
173    /// `ducklake_sort_info.sort_id` for this spec generation.
174    pub sort_id: i64,
175    /// Sort keys, ordered by `sort_key_index` (primary key first).
176    pub fields: Vec<SortField>,
177}
178
179impl SortSpec {
180    /// Whether this crate can *apply* this sort on write: true only when every key
181    /// is a bare column reference. A spec containing any non-column expression is
182    /// not producible and must be rejected by data-writing operations.
183    pub fn is_producible(&self) -> bool {
184        !self.fields.is_empty() && self.fields.iter().all(|f| f.column_candidate().is_some())
185    }
186
187    /// The producible sort keys as `(column_name, direction, null_order)`, in order,
188    /// or `None` if any key is not a bare column (see [`SortSpec::is_producible`]).
189    pub fn producible_columns(&self) -> Option<Vec<(String, SortDirection, NullOrder)>> {
190        // Skip fields whose dialect is not `duckdb`, matching official DuckLake, which
191        // `continue`s past them when building the sort expression
192        // (`ducklake_sort_data.cpp`). Their expression is written in some other
193        // engine's dialect, so evaluating it here would sort by something official
194        // ignores entirely.
195        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    /// Build a spec from catalog rows `(sort_id, sort_key_index, expression, dialect,
203    /// sort_direction, null_order)` — the join of `ducklake_sort_info` and
204    /// `ducklake_sort_expression` for the single LIVE generation, ordered by
205    /// `sort_key_index`. Returns `None` when there are no rows (unsorted). Every row
206    /// is expected to carry the same `sort_id`; the first row's id is used.
207    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/// Reorder rows by a table's sort order, returning ONE sorted batch.
229///
230/// `batches` may carry trailing columns beyond `data_schema` (a compaction output
231/// embeds the rowid, and for a partial file the per-row snapshot id); sort keys
232/// resolve to `data_schema` positions — the leading columns — so those trailing
233/// columns travel with their rows.
234///
235/// This is a **global** sort across all of `batches`, matching official DuckLake,
236/// which sorts a write by planting a blocking `PhysicalOrder` above the insert plan
237/// (`ducklake_insert.cpp`). That is what makes successive rolled files cover
238/// contiguous, non-overlapping value ranges — the property that lets a reader skip
239/// whole files. Sorting *within* a file would not achieve it: a file's min/max is
240/// the min/max of its rows regardless of their order, so only its row-group bounds
241/// would tighten.
242///
243/// Returns `batches` unchanged when there is no producible sort order, a key is not
244/// in the schema, or there are no rows — sort order affects statistics locality
245/// only, never correctness.
246#[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        // DuckLake: anything that isn't DESC is ASC.
307        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        // DuckLake: anything that isn't NULLS_FIRST is NULLS_LAST.
318        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        // double-quoted identifier with spaces
334        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}