diff --git a/Cargo.lock b/Cargo.lock index 3ddb32f60ffd5..eb5b22952ad6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2187,6 +2187,7 @@ dependencies = [ "indexmap 2.14.0", "insta", "itertools 0.15.0", + "rstest", ] [[package]] diff --git a/datafusion/expr-common/Cargo.toml b/datafusion/expr-common/Cargo.toml index 072c8f14da503..026c1e0bc316b 100644 --- a/datafusion/expr-common/Cargo.toml +++ b/datafusion/expr-common/Cargo.toml @@ -48,3 +48,4 @@ itertools = { workspace = true } [dev-dependencies] insta = { workspace = true } +rstest = { workspace = true } diff --git a/datafusion/expr-common/src/sort_properties.rs b/datafusion/expr-common/src/sort_properties.rs index 74d644f79faef..afa8fe8bad60f 100644 --- a/datafusion/expr-common/src/sort_properties.rs +++ b/datafusion/expr-common/src/sort_properties.rs @@ -50,11 +50,12 @@ impl SortProperties { (Self::Singleton, _) => *rhs, (_, Self::Singleton) => *self, (Self::Ordered(lhs), Self::Ordered(rhs)) - if lhs.descending == rhs.descending => + if lhs.descending == rhs.descending + && lhs.nulls_first == rhs.nulls_first => { Self::Ordered(SortOptions { descending: lhs.descending, - nulls_first: lhs.nulls_first || rhs.nulls_first, + nulls_first: lhs.nulls_first, }) } _ => Self::Unordered, @@ -70,11 +71,12 @@ impl SortProperties { }), (_, Self::Singleton) => *self, (Self::Ordered(lhs), Self::Ordered(rhs)) - if lhs.descending != rhs.descending => + if lhs.descending != rhs.descending + && lhs.nulls_first == rhs.nulls_first => { Self::Ordered(SortOptions { descending: lhs.descending, - nulls_first: lhs.nulls_first || rhs.nulls_first, + nulls_first: lhs.nulls_first, }) } _ => Self::Unordered, @@ -89,35 +91,410 @@ impl SortProperties { }), (_, Self::Singleton) => *self, (Self::Ordered(lhs), Self::Ordered(rhs)) - if lhs.descending != rhs.descending => + if lhs.descending != rhs.descending + && lhs.nulls_first == rhs.nulls_first => { *self } _ => Self::Unordered, } } + pub fn and(&self, rhs: &Self) -> Self { + // `descending == nulls_first` selects ASC NULLS LAST / DESC NULLS FIRST. + self.kleene(rhs, |opt| opt.descending == opt.nulls_first) + } + + pub fn or(&self, rhs: &Self) -> Self { + // `descending != nulls_first` selects ASC NULLS FIRST / DESC NULLS LAST. + self.kleene(rhs, |opt| opt.descending != opt.nulls_first) + } + #[deprecated( + since = "55.0.0", + note = "`AND` and `OR` propagate orderings differently under three-valued logic; use `and` or `or`" + )] pub fn and_or(&self, rhs: &Self) -> Self { + // Only the behavior on which `and` and `or` agree survives; their + // `Ordered` conditions are mutually exclusive. match (self, rhs) { - (Self::Ordered(lhs), Self::Ordered(rhs)) - if lhs.descending == rhs.descending => + (Self::Singleton, Self::Singleton) => Self::Singleton, + _ => Self::Unordered, + } + } + + /// Shared logic of [`Self::and`] and [`Self::or`]: `preserves` accepts + /// the sort options whose physical order realizes the operator's truth + /// ordering. + fn kleene(&self, rhs: &Self, preserves: fn(&SortOptions) -> bool) -> Self { + match (self, rhs) { + (Self::Singleton, Self::Singleton) => Self::Singleton, + (Self::Ordered(opt), Self::Singleton) + | (Self::Singleton, Self::Ordered(opt)) + if preserves(opt) => { - Self::Ordered(SortOptions { - descending: lhs.descending, - nulls_first: lhs.nulls_first || rhs.nulls_first, - }) + Self::Ordered(*opt) + } + (Self::Ordered(lhs), Self::Ordered(rhs)) if lhs == rhs && preserves(lhs) => { + Self::Ordered(*lhs) } - (Self::Ordered(opt), Self::Singleton) - | (Self::Singleton, Self::Ordered(opt)) => Self::Ordered(SortOptions { - descending: opt.descending, - nulls_first: opt.nulls_first, - }), - (Self::Singleton, Self::Singleton) => Self::Singleton, _ => Self::Unordered, } } } +#[cfg(test)] +mod sort_properties_test { + use super::{SortOptions, SortProperties}; + use rstest::rstest; + + const fn ordered(descending: bool, nulls_first: bool) -> SortProperties { + SortProperties::Ordered(SortOptions { + descending, + nulls_first, + }) + } + + const ASC_NF: SortProperties = ordered(false, true); + const ASC_NL: SortProperties = ordered(false, false); + const DESC_NF: SortProperties = ordered(true, true); + const DESC_NL: SortProperties = ordered(true, false); + const UNORDERED: SortProperties = SortProperties::Unordered; + const SINGLETON: SortProperties = SortProperties::Singleton; + + type BinOp = fn(&SortProperties, &SortProperties) -> SortProperties; + + /// Each method's direction rule and its `Singleton` arms. + /// + /// Operands that *disagree* on null placement are deliberately absent: + /// that half is covered exhaustively by + /// [`conflicting_null_placement_is_never_ordered`]. + #[test] + fn ordering_propagation() { + let cases: &[(&str, BinOp, SortProperties, SortProperties, SortProperties)] = &[ + // `add` preserves ordering when both operands run in the same + // direction. It is commutative, so one argument order suffices. + ( + "add: same direction is preserved", + SortProperties::add, + ASC_NF, + ASC_NF, + ASC_NF, + ), + ( + "add: nulls_last placement is preserved", + SortProperties::add, + ASC_NL, + ASC_NL, + ASC_NL, + ), + ( + "add: opposing directions are unordered", + SortProperties::add, + ASC_NF, + DESC_NF, + UNORDERED, + ), + ( + "add: literal with ordered", + SortProperties::add, + SINGLETON, + ASC_NF, + ASC_NF, + ), + ( + "add: two literals stay a literal", + SortProperties::add, + SINGLETON, + SINGLETON, + SINGLETON, + ), + // `and`/`or` back Kleene `AND`/`OR`, where NULL does not simply + // propagate (`NULL AND false = false`). Each operator is monotone + // w.r.t. its truth ordering (`false < NULL < true` for `AND`, + // `NULL < false < true` for `OR`), so each preserves exactly the + // two physical orderings realizing that truth ordering. A + // `Singleton` may be a literal NULL, hence the same restriction + // with a literal operand. Both are commutative. + ( + "and: ASC NULLS LAST is preserved", + SortProperties::and, + ASC_NL, + ASC_NL, + ASC_NL, + ), + ( + "and: DESC NULLS FIRST is preserved", + SortProperties::and, + DESC_NF, + DESC_NF, + DESC_NF, + ), + ( + "and: ASC NULLS FIRST is unordered", + SortProperties::and, + ASC_NF, + ASC_NF, + UNORDERED, + ), + ( + "and: DESC NULLS LAST is unordered", + SortProperties::and, + DESC_NL, + DESC_NL, + UNORDERED, + ), + ( + "and: mixed preserving orderings are unordered", + SortProperties::and, + ASC_NL, + DESC_NF, + UNORDERED, + ), + ( + "and: literal with ASC NULLS LAST", + SortProperties::and, + SINGLETON, + ASC_NL, + ASC_NL, + ), + ( + "and: literal with ASC NULLS FIRST is unordered", + SortProperties::and, + SINGLETON, + ASC_NF, + UNORDERED, + ), + ( + "and: two literals stay a literal", + SortProperties::and, + SINGLETON, + SINGLETON, + SINGLETON, + ), + ( + "or: ASC NULLS FIRST is preserved", + SortProperties::or, + ASC_NF, + ASC_NF, + ASC_NF, + ), + ( + "or: DESC NULLS LAST is preserved", + SortProperties::or, + DESC_NL, + DESC_NL, + DESC_NL, + ), + ( + "or: ASC NULLS LAST is unordered", + SortProperties::or, + ASC_NL, + ASC_NL, + UNORDERED, + ), + ( + "or: DESC NULLS FIRST is unordered", + SortProperties::or, + DESC_NF, + DESC_NF, + UNORDERED, + ), + ( + "or: mixed preserving orderings are unordered", + SortProperties::or, + ASC_NF, + DESC_NL, + UNORDERED, + ), + ( + "or: literal with ASC NULLS FIRST", + SortProperties::or, + SINGLETON, + ASC_NF, + ASC_NF, + ), + ( + "or: literal with ASC NULLS LAST is unordered", + SortProperties::or, + SINGLETON, + ASC_NL, + UNORDERED, + ), + ( + "or: two literals stay a literal", + SortProperties::or, + SINGLETON, + SINGLETON, + SINGLETON, + ), + // `sub` needs the *opposite* rule: an ascending column minus a + // descending one still ascends. It is not commutative + ( + "sub: opposing directions are preserved", + SortProperties::sub, + ASC_NF, + DESC_NF, + ASC_NF, + ), + ( + "sub: result follows the left operand", + SortProperties::sub, + DESC_NF, + ASC_NF, + DESC_NF, + ), + ( + "sub: nulls_last placement is preserved", + SortProperties::sub, + ASC_NL, + DESC_NL, + ASC_NL, + ), + ( + "sub: same direction is unordered", + SortProperties::sub, + ASC_NF, + ASC_NF, + UNORDERED, + ), + ( + "sub: literal minus ordered flips the direction", + SortProperties::sub, + SINGLETON, + ASC_NF, + DESC_NF, + ), + ( + "sub: ordered minus literal keeps the direction", + SortProperties::sub, + ASC_NF, + SINGLETON, + ASC_NF, + ), + ( + "sub: two literals stay a literal", + SortProperties::sub, + SINGLETON, + SINGLETON, + SINGLETON, + ), + // `gt_or_gteq` compares into a boolean column, which is ordered by + // `false < true`. Same direction rule as `sub`, also asymmetric. + ( + "gt_or_gteq: opposing directions are preserved", + SortProperties::gt_or_gteq, + ASC_NF, + DESC_NF, + ASC_NF, + ), + ( + "gt_or_gteq: result follows the left operand", + SortProperties::gt_or_gteq, + DESC_NF, + ASC_NF, + DESC_NF, + ), + ( + "gt_or_gteq: nulls_last placement is preserved", + SortProperties::gt_or_gteq, + DESC_NL, + ASC_NL, + DESC_NL, + ), + ( + "gt_or_gteq: same direction is unordered", + SortProperties::gt_or_gteq, + ASC_NF, + ASC_NF, + UNORDERED, + ), + ( + "gt_or_gteq: literal on the left flips the direction", + SortProperties::gt_or_gteq, + SINGLETON, + ASC_NF, + DESC_NF, + ), + ( + "gt_or_gteq: literal on the right keeps the direction", + SortProperties::gt_or_gteq, + ASC_NF, + SINGLETON, + ASC_NF, + ), + ( + "gt_or_gteq: two literals stay a literal", + SortProperties::gt_or_gteq, + SINGLETON, + SINGLETON, + SINGLETON, + ), + ]; + + for &(name, op, lhs, rhs, expected) in cases { + assert_eq!(op(&lhs, &rhs), expected, "case: {name}"); + } + + // `add`, `and` and `or` are commutative, which is what lets the + // table above cover only one argument order for them. + for (lhs, rhs) in [ + (ASC_NF, DESC_NL), + (ASC_NF, SINGLETON), + (ASC_NL, SINGLETON), + (ASC_NL, DESC_NF), + ] { + assert_eq!(lhs.add(&rhs), rhs.add(&lhs), "add is commutative"); + assert_eq!(lhs.and(&rhs), rhs.and(&lhs), "and is commutative"); + assert_eq!(lhs.or(&rhs), rhs.or(&lhs), "or is commutative"); + } + } + + /// The deprecated `and_or` cannot distinguish the operators, whose + /// preserved orderings are disjoint, so it keeps only the behavior on + /// which `and` and `or` agree. + #[test] + #[expect(deprecated)] + fn deprecated_and_or_is_conservative() { + assert_eq!(SINGLETON.and_or(&SINGLETON), SINGLETON); + assert_eq!(ASC_NF.and_or(&ASC_NF), UNORDERED); + assert_eq!(ASC_NL.and_or(&ASC_NL), UNORDERED); + assert_eq!(ASC_NL.and_or(&SINGLETON), UNORDERED); + } + + /// If two ordered operands disagree on null placement, the result is + /// always Unordered, no matter which operator or direction is used. + /// Checked below for every combination. + /// + /// For the arithmetic and comparison operators, nulls propagate: the + /// result is null wherever either operand is null. `nulls_first` treats + /// those rows as a prefix, `nulls_last` as a suffix. A set that's both + /// can't be described by any `SortOptions`. For `and`/`or` the reason + /// differs (Kleene monotonicity requires *identical* preserving + /// orderings), but the conclusion is the same. + /// + /// The assertion only checks "not Ordered", not which ordering + /// results. That keeps the test from just repeating the logic it's + /// checking. + #[rstest] + #[case::add("add", SortProperties::add)] + #[case::sub("sub", SortProperties::sub)] + #[case::gt_or_gteq("gt_or_gteq", SortProperties::gt_or_gteq)] + #[case::and("and", SortProperties::and)] + #[case::or("or", SortProperties::or)] + fn conflicting_null_placement_is_never_ordered( + #[values(false, true)] l_descending: bool, + #[values(false, true)] r_descending: bool, + #[values(false, true)] l_nulls_first: bool, + #[case] op_name: &str, + #[case] op: BinOp, + ) { + // Negating `l_nulls_first` makes the operands disagree by construction. + let lhs = ordered(l_descending, l_nulls_first); + let rhs = ordered(r_descending, !l_nulls_first); + assert_eq!(op(&lhs, &rhs), UNORDERED, "{op_name}: {lhs:?} and {rhs:?}"); + } +} + impl Neg for SortProperties { type Output = Self; diff --git a/datafusion/physical-expr/src/expressions/binary.rs b/datafusion/physical-expr/src/expressions/binary.rs index 1bd49696bbdca..b911c33065b8e 100644 --- a/datafusion/physical-expr/src/expressions/binary.rs +++ b/datafusion/physical-expr/src/expressions/binary.rs @@ -876,13 +876,13 @@ impl PhysicalExpr for BinaryExpr { strictly_order_preserving: false, }), Operator::And => Ok(ExprProperties { - sort_properties: r_order.and_or(&l_order), + sort_properties: l_order.and(&r_order), range: l_range.and(r_range)?, preserves_lex_ordering: false, strictly_order_preserving: false, }), Operator::Or => Ok(ExprProperties { - sort_properties: r_order.and_or(&l_order), + sort_properties: l_order.or(&r_order), range: l_range.or(r_range)?, preserves_lex_ordering: false, strictly_order_preserving: false, diff --git a/datafusion/sqllogictest/test_files/order.slt b/datafusion/sqllogictest/test_files/order.slt index 4b136d24b0751..3643d9a3767d5 100644 --- a/datafusion/sqllogictest/test_files/order.slt +++ b/datafusion/sqllogictest/test_files/order.slt @@ -1485,7 +1485,9 @@ physical_plan 03)----RepartitionExec: partitioning=RoundRobinBatch(2), input_partitions=1, maintains_sort_order=true 04)------DataSourceExec: file_groups={1 group: [[WORKSPACE_ROOT/datafusion/core/tests/data/window_2.csv]]}, projection=[c], output_ordering=[c@0 ASC NULLS LAST], file_type=csv, has_header=true -# Boolean to integer casts preserve the order. +# The cast preserves the order, but the comparison does not: `inc_col` is +# ASC NULLS LAST while `desc_col` is DESC NULLS FIRST, so `inc_col > desc_col` +# is null at both ends and the sort has to stay. statement ok CREATE EXTERNAL TABLE annotated_data_finite ( ts INTEGER, @@ -1507,9 +1509,159 @@ logical_plan 03)----TableScan: annotated_data_finite projection=[inc_col, desc_col] physical_plan 01)SortPreservingMergeExec: [c@0 ASC NULLS LAST] +02)--SortExec: expr=[c@0 ASC NULLS LAST], preserve_partitioning=[true] +03)----ProjectionExec: expr=[CAST(inc_col@0 > desc_col@1 AS Int32) as c] +04)------RepartitionExec: partitioning=RoundRobinBatch(2), input_partitions=1, maintains_sort_order=true +05)--------DataSourceExec: file_groups={1 group: [[WORKSPACE_ROOT/datafusion/core/tests/data/window_1.csv]]}, projection=[inc_col, desc_col], output_orderings=[[inc_col@0 ASC NULLS LAST], [desc_col@1 DESC]], file_type=csv, has_header=true + +# With matching null placement the comparison keeps its order: no sort needed. +statement ok +CREATE EXTERNAL TABLE annotated_data_finite_nulls_last ( + ts INTEGER, + inc_col INTEGER, + desc_col INTEGER, +) +STORED AS CSV +WITH ORDER (inc_col ASC NULLS LAST) +WITH ORDER (desc_col DESC NULLS LAST) +LOCATION '../core/tests/data/window_1.csv' +OPTIONS ('format.has_header' 'true'); + +query TT +EXPLAIN SELECT CAST((inc_col>desc_col) as integer) as c from annotated_data_finite_nulls_last order by c; +---- +logical_plan +01)Sort: c ASC NULLS LAST +02)--Projection: CAST(annotated_data_finite_nulls_last.inc_col > annotated_data_finite_nulls_last.desc_col AS Int32) AS c +03)----TableScan: annotated_data_finite_nulls_last projection=[inc_col, desc_col] +physical_plan +01)SortPreservingMergeExec: [c@0 ASC NULLS LAST] 02)--ProjectionExec: expr=[CAST(inc_col@0 > desc_col@1 AS Int32) as c] 03)----RepartitionExec: partitioning=RoundRobinBatch(2), input_partitions=1, maintains_sort_order=true -04)------DataSourceExec: file_groups={1 group: [[WORKSPACE_ROOT/datafusion/core/tests/data/window_1.csv]]}, projection=[inc_col, desc_col], output_orderings=[[inc_col@0 ASC NULLS LAST], [desc_col@1 DESC]], file_type=csv, has_header=true +04)------DataSourceExec: file_groups={1 group: [[WORKSPACE_ROOT/datafusion/core/tests/data/window_1.csv]]}, projection=[inc_col, desc_col], output_orderings=[[inc_col@0 ASC NULLS LAST], [desc_col@1 DESC NULLS LAST]], file_type=csv, has_header=true + +# Regression test for #11596. `a` is ASC NULLS FIRST and `b` is ASC NULLS LAST, +# so `a + b` is null at both ends of the scan: (NULL, 3, 6, NULL). No nulls +# placement describes that. +query I +COPY (VALUES (NULL, 1), (1, 2), (2, 4), (3, NULL)) +TO 'test_files/scratch/order/mixed_null_placement.csv' +OPTIONS ('format.has_header' 'true'); +---- +4 + +statement ok +CREATE EXTERNAL TABLE mixed_null_placement ( + a BIGINT, + b BIGINT +) +STORED AS CSV +WITH ORDER (a ASC NULLS FIRST) +WITH ORDER (b ASC NULLS LAST) +LOCATION 'test_files/scratch/order/mixed_null_placement.csv' +OPTIONS ('format.has_header' 'true'); + +# The sort has to happen. Without it, this comes back in scan order. +query I +SELECT a + b AS s FROM mixed_null_placement ORDER BY s; +---- +3 +6 +NULL +NULL + +# Same at the plan level. Two separate things keep this sort: `add()` won't +# merge mismatched `nulls_first`, and `arithmetic_sort_properties` gives up on +# any `col + col` with unbounded ranges, which is every column read from a +# file. The second masks the first today. +query TT +EXPLAIN SELECT a + b AS s FROM mixed_null_placement ORDER BY s; +---- +logical_plan +01)Sort: s ASC NULLS LAST +02)--Projection: mixed_null_placement.a + mixed_null_placement.b AS s +03)----TableScan: mixed_null_placement projection=[a, b] +physical_plan +01)SortPreservingMergeExec: [s@0 ASC NULLS LAST] +02)--SortExec: expr=[s@0 ASC NULLS LAST], preserve_partitioning=[true] +03)----ProjectionExec: expr=[a@0 + b@1 as s] +04)------RepartitionExec: partitioning=RoundRobinBatch(2), input_partitions=1, maintains_sort_order=true +05)--------DataSourceExec: file_groups={1 group: [[WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch/order/mixed_null_placement.csv]]}, projection=[a, b], output_orderings=[[a@0 ASC], [b@1 ASC NULLS LAST]], file_type=csv, has_header=true + +statement ok +DROP TABLE mixed_null_placement; + +# NULLS FIRST inputs stay sorted through OR but not AND. The full +# operator-by-ordering matrix is unit-tested in sort_properties.rs; this +# checks the planner consumes it. +query I +COPY (VALUES + (CAST(NULL AS BOOLEAN), false), + (CAST(NULL AS BOOLEAN), true), + (false, true), + (true, true)) +TO 'test_files/scratch/order/kleene_nulls_first.csv' +OPTIONS ('format.has_header' 'true'); +---- +4 + +statement ok +CREATE EXTERNAL TABLE kleene_nulls_first (a BOOLEAN, b BOOLEAN) +STORED AS CSV +WITH ORDER (a ASC NULLS FIRST) +WITH ORDER (b ASC NULLS FIRST) +LOCATION 'test_files/scratch/order/kleene_nulls_first.csv' +OPTIONS ('format.has_header' 'true'); + +# a AND b = (false, NULL, false, true) in scan order: the sort has to happen. +query B +SELECT a AND b AS c FROM kleene_nulls_first ORDER BY c ASC NULLS FIRST; +---- +NULL +false +false +true + +# Same at the plan level: AND must not claim ASC NULLS FIRST. +query TT +EXPLAIN SELECT a AND b AS c FROM kleene_nulls_first ORDER BY c ASC NULLS FIRST; +---- +logical_plan +01)Sort: c ASC NULLS FIRST +02)--Projection: kleene_nulls_first.a AND kleene_nulls_first.b AS c +03)----TableScan: kleene_nulls_first projection=[a, b] +physical_plan +01)SortPreservingMergeExec: [c@0 ASC] +02)--SortExec: expr=[c@0 ASC], preserve_partitioning=[true] +03)----ProjectionExec: expr=[a@0 AND b@1 as c] +04)------RepartitionExec: partitioning=RoundRobinBatch(2), input_partitions=1, maintains_sort_order=true +05)--------DataSourceExec: file_groups={1 group: [[WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch/order/kleene_nulls_first.csv]]}, projection=[a, b], output_orderings=[[a@0 ASC], [b@1 ASC]], file_type=csv, has_header=true + +# a OR b = (NULL, true, true, true) in scan order: already sorted, and OR may +# claim it, so no SortExec. +query B +SELECT a OR b AS c FROM kleene_nulls_first ORDER BY c ASC NULLS FIRST; +---- +NULL +true +true +true + +query TT +EXPLAIN SELECT a OR b AS c FROM kleene_nulls_first ORDER BY c ASC NULLS FIRST; +---- +logical_plan +01)Sort: c ASC NULLS FIRST +02)--Projection: kleene_nulls_first.a OR kleene_nulls_first.b AS c +03)----TableScan: kleene_nulls_first projection=[a, b] +physical_plan +01)SortPreservingMergeExec: [c@0 ASC] +02)--ProjectionExec: expr=[a@0 OR b@1 as c] +03)----RepartitionExec: partitioning=RoundRobinBatch(2), input_partitions=1, maintains_sort_order=true +04)------DataSourceExec: file_groups={1 group: [[WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch/order/kleene_nulls_first.csv]]}, projection=[a, b], output_orderings=[[a@0 ASC], [b@1 ASC]], file_type=csv, has_header=true + +statement ok +DROP TABLE kleene_nulls_first; # Union a query with the actual data and one with a constant query I diff --git a/docs/source/library-user-guide/upgrading/55.0.0.md b/docs/source/library-user-guide/upgrading/55.0.0.md index 2811fb4df2900..437e302d4d350 100644 --- a/docs/source/library-user-guide/upgrading/55.0.0.md +++ b/docs/source/library-user-guide/upgrading/55.0.0.md @@ -1178,6 +1178,35 @@ following the pattern in [`parquet/examples/object_store.rs`] See [PR #24030](https://github.com/apache/datafusion/pull/24030) for details. +### `SortProperties::and_or` split into `and` and `or` + +`AND` and `OR` do not propagate the same sort orders. Under three-valued logic +`AND` preserves an ordering only when nulls sort after `true` (`ASC NULLS LAST` +or `DESC NULLS FIRST`). `OR` preserves one only when nulls sort before +`false` (`ASC NULLS FIRST` or `DESC NULLS LAST`). A single shared method could +not be correct for both. + +`datafusion_expr_common::sort_properties::SortProperties::and_or` is deprecated +in favor of `and` and `or`. Both operators agree on: `Singleton` for two `Singleton` +operands, `Unordered` otherwise. + +**Who is affected:** + +- Users who call `SortProperties::and_or` directly. + +**Migration guide:** + +```rust,ignore +// Before +let props = lhs.and_or(&rhs); + +// After +let props = lhs.and(&rhs); // for AND +let props = lhs.or(&rhs); // for OR +``` + +See [PR](https://github.com/apache/datafusion/pull/24276) #24276 for details. + [`parquet` crate]: https://crates.io/crates/parquet [`parquetobjectreader`]: https://docs.rs/parquet/59.1.0/parquet/arrow/async_reader/struct.ParquetObjectReader.html [`parquetobjectwriter`]: https://docs.rs/parquet/59.1.0/parquet/arrow/async_writer/struct.ParquetObjectWriter.html