Skip to main content

fraiseql_wire/operators/
order_by.rs

1//! ORDER BY clause specification
2//!
3//! Type-safe representation of ORDER BY clauses with support for
4//! collation, NULLS FIRST/LAST, and mixed JSONB/direct column ordering.
5
6use std::fmt;
7
8/// Represents a complete ORDER BY clause
9///
10/// Supports:
11/// - Both JSONB fields and direct columns
12/// - PostgreSQL collations
13/// - NULLS FIRST/LAST handling
14/// - Mixed multi-field ordering
15///
16/// # Examples
17///
18/// ```rust
19/// use fraiseql_wire::operators::{OrderByClause, FieldSource, SortOrder, NullsHandling};
20///
21/// // Order by JSONB field with collation
22/// let _ = OrderByClause {
23///     field: "name".to_string(),
24///     field_source: FieldSource::JsonbPayload,
25///     direction: SortOrder::Asc,
26///     collation: Some("en-US".to_string()),
27///     nulls_handling: None,
28/// };
29///
30/// // Order by direct column with NULLS LAST
31/// let _ = OrderByClause {
32///     field: "created_at".to_string(),
33///     field_source: FieldSource::DirectColumn,
34///     direction: SortOrder::Desc,
35///     collation: None,
36///     nulls_handling: Some(NullsHandling::Last),
37/// };
38/// ```
39#[derive(Debug, Clone)]
40pub struct OrderByClause {
41    /// Field name (validated separately based on `field_source`)
42    pub field: String,
43
44    /// Where the field comes from
45    pub field_source: FieldSource,
46
47    /// Sort direction
48    pub direction: SortOrder,
49
50    /// Optional collation name (e.g., "en-US", "C", "de_DE.UTF-8")
51    ///
52    /// When specified, generates: `field COLLATE "collation_name"`
53    pub collation: Option<String>,
54
55    /// Optional NULLS handling
56    pub nulls_handling: Option<NullsHandling>,
57}
58
59impl OrderByClause {
60    /// Create a new ORDER BY clause for a JSONB field
61    pub fn jsonb_field(field: impl Into<String>, direction: SortOrder) -> Self {
62        Self {
63            field: field.into(),
64            field_source: FieldSource::JsonbPayload,
65            direction,
66            collation: None,
67            nulls_handling: None,
68        }
69    }
70
71    /// Create a new ORDER BY clause for a direct column
72    pub fn direct_column(field: impl Into<String>, direction: SortOrder) -> Self {
73        Self {
74            field: field.into(),
75            field_source: FieldSource::DirectColumn,
76            direction,
77            collation: None,
78            nulls_handling: None,
79        }
80    }
81
82    /// Add collation to this clause
83    pub fn with_collation(mut self, collation: impl Into<String>) -> Self {
84        self.collation = Some(collation.into());
85        self
86    }
87
88    /// Add NULLS handling to this clause
89    pub const fn with_nulls(mut self, handling: NullsHandling) -> Self {
90        self.nulls_handling = Some(handling);
91        self
92    }
93
94    /// Validate field name to prevent SQL injection
95    ///
96    /// # Errors
97    ///
98    /// Returns an error string if the field name is empty, contains invalid characters,
99    /// or the collation name (if set) contains invalid characters.
100    pub fn validate(&self) -> Result<(), String> {
101        if self.field.is_empty() {
102            return Err("Field name cannot be empty".to_string());
103        }
104
105        // Validate field name: alphanumeric + underscore
106        if !self.field.chars().all(|c| c.is_alphanumeric() || c == '_')
107            || self
108                .field
109                .chars()
110                .next()
111                .is_some_and(|c| !c.is_alphabetic() && c != '_')
112        {
113            return Err(format!("Invalid field name: {}", self.field));
114        }
115
116        // Validate collation name if provided
117        if let Some(ref collation) = self.collation {
118            if collation.is_empty() {
119                return Err("Collation name cannot be empty".to_string());
120            }
121            // Allow common PostgreSQL collation patterns
122            // Format: language_REGION.encoding (e.g., en_US.UTF-8, C.UTF-8)
123            // or simple names like "C", custom names
124            // We validate format loosely - PostgreSQL will validate at execution time
125            if !collation
126                .chars()
127                .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '@')
128            {
129                return Err(format!("Invalid collation name: {}", collation));
130            }
131        }
132
133        Ok(())
134    }
135
136    /// Generate SQL for this clause
137    ///
138    /// # Examples
139    ///
140    /// - JSONB with collation: `(data->'name') COLLATE "en-US" ASC`
141    /// - Direct column: `created_at DESC`
142    /// - With NULLS: `status ASC NULLS LAST`
143    ///
144    /// # Errors
145    ///
146    /// Returns an error string if the clause fails validation (see [`Self::validate`]).
147    pub fn to_sql(&self) -> Result<String, String> {
148        self.validate()?;
149
150        let field_expr = match self.field_source {
151            FieldSource::JsonbPayload => format!("(data->'{}')", self.field),
152            FieldSource::DirectColumn => self.field.clone(),
153        };
154
155        let mut sql = field_expr;
156
157        // Add collation if specified
158        if let Some(ref collation) = self.collation {
159            sql.push_str(&format!(" COLLATE \"{}\"", collation));
160        }
161
162        // Add direction
163        let direction = match self.direction {
164            SortOrder::Asc => "ASC",
165            SortOrder::Desc => "DESC",
166        };
167        sql.push(' ');
168        sql.push_str(direction);
169
170        // Add NULLS handling
171        if let Some(nulls) = self.nulls_handling {
172            let nulls_str = match nulls {
173                NullsHandling::First => "NULLS FIRST",
174                NullsHandling::Last => "NULLS LAST",
175            };
176            sql.push(' ');
177            sql.push_str(nulls_str);
178        }
179
180        Ok(sql)
181    }
182}
183
184impl fmt::Display for OrderByClause {
185    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186        match self.to_sql() {
187            Ok(sql) => write!(f, "{}", sql),
188            Err(e) => write!(f, "ERROR: {}", e),
189        }
190    }
191}
192
193/// Specifies where a field comes from in ORDER BY
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195#[non_exhaustive]
196pub enum FieldSource {
197    /// Field is inside the JSONB `data` column: `data->>'field'`
198    JsonbPayload,
199
200    /// Field is a direct database column: `column_name`
201    DirectColumn,
202}
203
204impl fmt::Display for FieldSource {
205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206        match self {
207            FieldSource::JsonbPayload => write!(f, "JSONB"),
208            FieldSource::DirectColumn => write!(f, "DIRECT_COLUMN"),
209        }
210    }
211}
212
213/// Sort direction for ORDER BY
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215#[non_exhaustive]
216pub enum SortOrder {
217    /// Ascending order (default)
218    Asc,
219
220    /// Descending order
221    Desc,
222}
223
224impl fmt::Display for SortOrder {
225    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226        match self {
227            SortOrder::Asc => write!(f, "ASC"),
228            SortOrder::Desc => write!(f, "DESC"),
229        }
230    }
231}
232
233/// NULL handling in ORDER BY
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235#[non_exhaustive]
236pub enum NullsHandling {
237    /// NULL values come first
238    First,
239
240    /// NULL values come last
241    Last,
242}
243
244impl fmt::Display for NullsHandling {
245    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246        match self {
247            NullsHandling::First => write!(f, "NULLS FIRST"),
248            NullsHandling::Last => write!(f, "NULLS LAST"),
249        }
250    }
251}
252
253/// PostgreSQL collation specifications
254///
255/// Supports common collations and custom names.
256/// When used, generates: `field COLLATE "collation_name"`
257#[derive(Debug, Clone, PartialEq, Eq)]
258#[non_exhaustive]
259pub enum Collation {
260    /// Binary collation (fast, deterministic)
261    C,
262
263    /// UTF-8 binary collation
264    Utf8,
265
266    /// Custom collation name (e.g., "en-US", "de_DE.UTF-8")
267    Custom(String),
268}
269
270impl Collation {
271    /// Get the PostgreSQL collation name as a string
272    pub fn as_str(&self) -> &str {
273        match self {
274            Collation::C => "C",
275            Collation::Utf8 => "C.UTF-8",
276            Collation::Custom(name) => name,
277        }
278    }
279}
280
281impl fmt::Display for Collation {
282    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283        write!(f, "{}", self.as_str())
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
290    use super::*;
291
292    #[test]
293    fn test_jsonb_field_ordering() {
294        let clause = OrderByClause::jsonb_field("name", SortOrder::Asc);
295        let sql = clause.to_sql().unwrap();
296        assert_eq!(sql, "(data->'name') ASC");
297    }
298
299    #[test]
300    fn test_direct_column_ordering() {
301        let clause = OrderByClause::direct_column("created_at", SortOrder::Desc);
302        let sql = clause.to_sql().unwrap();
303        assert_eq!(sql, "created_at DESC");
304    }
305
306    #[test]
307    fn test_ordering_with_collation() {
308        let clause = OrderByClause::jsonb_field("name", SortOrder::Asc).with_collation("en-US");
309        let sql = clause.to_sql().unwrap();
310        assert_eq!(sql, "(data->'name') COLLATE \"en-US\" ASC");
311    }
312
313    #[test]
314    fn test_ordering_with_nulls_last() {
315        let clause =
316            OrderByClause::direct_column("status", SortOrder::Asc).with_nulls(NullsHandling::Last);
317        let sql = clause.to_sql().unwrap();
318        assert_eq!(sql, "status ASC NULLS LAST");
319    }
320
321    #[test]
322    fn test_ordering_with_collation_and_nulls() {
323        let clause = OrderByClause::jsonb_field("email", SortOrder::Desc)
324            .with_collation("C")
325            .with_nulls(NullsHandling::First);
326        let sql = clause.to_sql().unwrap();
327        assert_eq!(sql, "(data->'email') COLLATE \"C\" DESC NULLS FIRST");
328    }
329
330    #[test]
331    fn test_field_validation() {
332        OrderByClause::jsonb_field("valid_name", SortOrder::Asc)
333            .validate()
334            .unwrap_or_else(|e| panic!("expected Ok for 'valid_name': {e}"));
335
336        let result = OrderByClause::jsonb_field("123invalid", SortOrder::Asc).validate();
337        assert!(
338            result.is_err(),
339            "expected Err for '123invalid', got: {result:?}"
340        );
341
342        let result = OrderByClause::jsonb_field("bad-name", SortOrder::Asc).validate();
343        assert!(
344            result.is_err(),
345            "expected Err for 'bad-name', got: {result:?}"
346        );
347    }
348
349    #[test]
350    fn test_collation_validation() {
351        let clause = OrderByClause::jsonb_field("name", SortOrder::Asc).with_collation("en-US");
352        clause
353            .validate()
354            .unwrap_or_else(|e| panic!("expected Ok for collation 'en-US': {e}"));
355
356        let clause = OrderByClause::jsonb_field("name", SortOrder::Asc).with_collation("C.UTF-8");
357        clause
358            .validate()
359            .unwrap_or_else(|e| panic!("expected Ok for collation 'C.UTF-8': {e}"));
360
361        let clause =
362            OrderByClause::jsonb_field("name", SortOrder::Asc).with_collation("invalid!!!special");
363        let result = clause.validate();
364        assert!(
365            result.is_err(),
366            "expected Err for collation 'invalid!!!special', got: {result:?}"
367        );
368    }
369
370    #[test]
371    fn test_sort_order_display() {
372        assert_eq!(SortOrder::Asc.to_string(), "ASC");
373        assert_eq!(SortOrder::Desc.to_string(), "DESC");
374    }
375
376    #[test]
377    fn test_field_source_display() {
378        assert_eq!(FieldSource::JsonbPayload.to_string(), "JSONB");
379        assert_eq!(FieldSource::DirectColumn.to_string(), "DIRECT_COLUMN");
380    }
381
382    #[test]
383    fn test_collation_enum() {
384        assert_eq!(Collation::C.as_str(), "C");
385        assert_eq!(Collation::Utf8.as_str(), "C.UTF-8");
386        assert_eq!(Collation::Custom("de-DE".to_string()).as_str(), "de-DE");
387    }
388}