Skip to main content

cratestack_sql/
order.rs

1use crate::filter::VectorMetric;
2
3// Not `Eq`: `OrderTarget::VectorDistance` carries a `Vec<f32>` query
4// vector, and `f32` has no sound total-equality impl (NaN != NaN) —
5// same reason `FilterExpr`/`SpatialFilter` stop at `PartialEq`.
6#[derive(Debug, Clone, PartialEq)]
7pub struct OrderClause {
8    pub target: OrderTarget,
9    pub direction: SortDirection,
10    pub null_order: NullOrder,
11}
12
13/// Where NULLs sort relative to non-NULL values. PostgreSQL's default is
14/// `NULLS LAST` for `ASC` and `NULLS FIRST` for `DESC`; SQLite's default
15/// is `NULLS FIRST` for both. CrateStack pins the framework default to
16/// `NULLS LAST` so listings stay deterministic across backends and so
17/// soft-deleted rows (typed `Option<DateTime>` that surface as `None`
18/// for visible rows) don't muscle their way to the top of every listing.
19/// Override per-clause via [`OrderClause::nulls_first`] when scheduler /
20/// outbox queries want fresh-as-null tasks at the head of the queue.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum NullOrder {
23    First,
24    #[default]
25    Last,
26}
27
28#[derive(Debug, Clone, PartialEq)]
29pub enum OrderTarget {
30    Column(&'static str),
31    /// Order by `column` at the end of a to-one relation path, rendered
32    /// as nested correlated subqueries — one per hop, each applying that
33    /// hop's [`crate::RelatedReadScope`], so a related row the caller
34    /// cannot read yields `NULL` (GHSA-p55v-6xv5-93p3).
35    ///
36    /// The path is carried as hops rather than a pre-rendered SQL string:
37    /// baking it per path at macro-expansion time is what made codegen
38    /// exponential in relation-graph connectivity (cratestack#252), and a
39    /// raw string cannot carry per-hop scopes. Build it with
40    /// [`OrderClause::relation_scalar`] or [`OrderClause::relation_path`];
41    /// `hops` must not be empty.
42    RelationScalar {
43        hops: Vec<crate::RelationHop>,
44        column: &'static str,
45    },
46    /// Order by distance to a query vector on a `Vector(n)` column (see
47    /// `docs/design/extensions.md` §6/§7, cratestack#163). Built via
48    /// `FieldRef::distance_to(...).asc()`/`.desc()` or the
49    /// `order_by_distance` shorthand. PG-only (pgvector) — the
50    /// embedded rusqlite backend doesn't ship pgvector, so its
51    /// renderer fails loud, mirroring how `FilterExpr::Spatial` is
52    /// handled there.
53    VectorDistance {
54        column: &'static str,
55        metric: VectorMetric,
56        query_vector: Vec<f32>,
57    },
58    /// Order by `ST_Distance(col::geography, point::geography)` — great-
59    /// circle metres to a reference point (cratestack#842 item 5).
60    /// Built via `FieldRef::order_by_distance_to(point)`.
61    ///
62    /// This is the ordering half of the pair whose filtering half is
63    /// [`crate::SpatialFilter::DWithinGeographyPoint`]: `DWithin` picks
64    /// the rows inside a radius, this sorts them nearest-first, so
65    /// "closest N within X metres" no longer needs the distance
66    /// recomputed in application code after the radius filter returns.
67    ///
68    /// PG-only (PostGIS) — the embedded rusqlite backend doesn't ship
69    /// SpatiaLite, so its renderer fails loud, exactly as it does for
70    /// `FilterExpr::Spatial`.
71    #[cfg(feature = "postgis")]
72    SpatialDistance {
73        column: &'static str,
74        lng: f64,
75        lat: f64,
76    },
77}
78
79impl OrderClause {
80    pub const fn column(column: &'static str, direction: SortDirection) -> Self {
81        Self {
82            target: OrderTarget::Column(column),
83            direction,
84            null_order: NullOrder::Last,
85        }
86    }
87
88    /// Order by `related_table.column`, one to-one hop away. `scope` is the
89    /// related model's read scope — `<RELATED>_MODEL.related_read_scope()`,
90    /// or [`crate::RelatedReadScope::Unscoped`] only when ordering by rows
91    /// the caller may not read is the point.
92    pub fn relation_scalar(
93        parent_table: &'static str,
94        parent_column: &'static str,
95        related_table: &'static str,
96        related_column: &'static str,
97        column: &'static str,
98        scope: crate::RelatedReadScope,
99        direction: SortDirection,
100    ) -> Self {
101        let hop = crate::RelationHop::new(
102            parent_table,
103            parent_column,
104            related_table,
105            related_column,
106            crate::RelationQuantifier::ToOne,
107            scope,
108        );
109        Self::relation_path(&[hop], column, direction)
110    }
111
112    /// Order by `column` at the end of `hops` (all to-one), each hop
113    /// carrying its own read scope.
114    ///
115    /// Panics if `hops` is empty: a relation sort traverses at least one
116    /// relation (generated callers always have).
117    pub fn relation_path(
118        hops: &[crate::RelationHop],
119        column: &'static str,
120        direction: SortDirection,
121    ) -> Self {
122        assert!(!hops.is_empty(), "relation_path requires at least one hop");
123        Self {
124            target: OrderTarget::RelationScalar {
125                hops: hops.to_vec(),
126                column,
127            },
128            direction,
129            null_order: NullOrder::Last,
130        }
131    }
132
133    /// Place NULL values *before* non-NULL ones for this clause. Use on
134    /// scheduler / outbox listings where "no scheduled time yet" should
135    /// sort ahead of every retry-scheduled row.
136    pub fn nulls_first(mut self) -> Self {
137        self.null_order = NullOrder::First;
138        self
139    }
140
141    /// Place NULL values *after* non-NULL ones (the framework default).
142    /// Mostly useful when overriding a programmatically-built clause
143    /// that previously asked for `nulls_first`.
144    pub fn nulls_last(mut self) -> Self {
145        self.null_order = NullOrder::Last;
146        self
147    }
148
149    pub fn is_relation_scalar(&self) -> bool {
150        matches!(self.target, OrderTarget::RelationScalar { .. })
151    }
152
153    pub fn targets_column(&self, column: &str) -> bool {
154        matches!(self.target, OrderTarget::Column(candidate) if candidate == column)
155    }
156
157    pub fn direction(&self) -> SortDirection {
158        self.direction
159    }
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
163#[serde(rename_all = "camelCase")]
164pub enum SortDirection {
165    Asc,
166    Desc,
167}