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 a descriptive error string if the field name is empty, contains invalid characters,
99    /// or the collation name (if provided) is empty or 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 a descriptive error string if field validation fails (see [`validate`](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)]
195pub enum FieldSource {
196    /// Field is inside the JSONB `data` column: `data->>'field'`
197    JsonbPayload,
198
199    /// Field is a direct database column: `column_name`
200    DirectColumn,
201}
202
203impl fmt::Display for FieldSource {
204    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205        match self {
206            FieldSource::JsonbPayload => write!(f, "JSONB"),
207            FieldSource::DirectColumn => write!(f, "DIRECT_COLUMN"),
208        }
209    }
210}
211
212/// Sort direction for ORDER BY
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub enum SortOrder {
215    /// Ascending order (default)
216    Asc,
217
218    /// Descending order
219    Desc,
220}
221
222impl fmt::Display for SortOrder {
223    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224        match self {
225            SortOrder::Asc => write!(f, "ASC"),
226            SortOrder::Desc => write!(f, "DESC"),
227        }
228    }
229}
230
231/// NULL handling in ORDER BY
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub enum NullsHandling {
234    /// NULL values come first
235    First,
236
237    /// NULL values come last
238    Last,
239}
240
241impl fmt::Display for NullsHandling {
242    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243        match self {
244            NullsHandling::First => write!(f, "NULLS FIRST"),
245            NullsHandling::Last => write!(f, "NULLS LAST"),
246        }
247    }
248}
249
250/// PostgreSQL collation specifications
251///
252/// Supports common collations and custom names.
253/// When used, generates: `field COLLATE "collation_name"`
254#[derive(Debug, Clone, PartialEq, Eq)]
255pub enum Collation {
256    /// Binary collation (fast, deterministic)
257    C,
258
259    /// UTF-8 binary collation
260    Utf8,
261
262    /// Custom collation name (e.g., "en-US", "de_DE.UTF-8")
263    Custom(String),
264}
265
266impl Collation {
267    /// Get the PostgreSQL collation name as a string
268    pub fn as_str(&self) -> &str {
269        match self {
270            Collation::C => "C",
271            Collation::Utf8 => "C.UTF-8",
272            Collation::Custom(name) => name,
273        }
274    }
275}
276
277impl fmt::Display for Collation {
278    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
279        write!(f, "{}", self.as_str())
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
286    use super::*;
287
288    #[test]
289    fn test_jsonb_field_ordering() {
290        let clause = OrderByClause::jsonb_field("name", SortOrder::Asc);
291        let sql = clause.to_sql().unwrap();
292        assert_eq!(sql, "(data->'name') ASC");
293    }
294
295    #[test]
296    fn test_direct_column_ordering() {
297        let clause = OrderByClause::direct_column("created_at", SortOrder::Desc);
298        let sql = clause.to_sql().unwrap();
299        assert_eq!(sql, "created_at DESC");
300    }
301
302    #[test]
303    fn test_ordering_with_collation() {
304        let clause = OrderByClause::jsonb_field("name", SortOrder::Asc).with_collation("en-US");
305        let sql = clause.to_sql().unwrap();
306        assert_eq!(sql, "(data->'name') COLLATE \"en-US\" ASC");
307    }
308
309    #[test]
310    fn test_ordering_with_nulls_last() {
311        let clause =
312            OrderByClause::direct_column("status", SortOrder::Asc).with_nulls(NullsHandling::Last);
313        let sql = clause.to_sql().unwrap();
314        assert_eq!(sql, "status ASC NULLS LAST");
315    }
316
317    #[test]
318    fn test_ordering_with_collation_and_nulls() {
319        let clause = OrderByClause::jsonb_field("email", SortOrder::Desc)
320            .with_collation("C")
321            .with_nulls(NullsHandling::First);
322        let sql = clause.to_sql().unwrap();
323        assert_eq!(sql, "(data->'email') COLLATE \"C\" DESC NULLS FIRST");
324    }
325
326    #[test]
327    fn test_field_validation() {
328        assert!(OrderByClause::jsonb_field("valid_name", SortOrder::Asc)
329            .validate()
330            .is_ok());
331        assert!(OrderByClause::jsonb_field("123invalid", SortOrder::Asc)
332            .validate()
333            .is_err());
334        assert!(OrderByClause::jsonb_field("bad-name", SortOrder::Asc)
335            .validate()
336            .is_err());
337    }
338
339    #[test]
340    fn test_collation_validation() {
341        let clause = OrderByClause::jsonb_field("name", SortOrder::Asc).with_collation("en-US");
342        assert!(clause.validate().is_ok());
343
344        let clause = OrderByClause::jsonb_field("name", SortOrder::Asc).with_collation("C.UTF-8");
345        assert!(clause.validate().is_ok());
346
347        let clause =
348            OrderByClause::jsonb_field("name", SortOrder::Asc).with_collation("invalid!!!special");
349        assert!(clause.validate().is_err());
350    }
351
352    #[test]
353    fn test_sort_order_display() {
354        assert_eq!(SortOrder::Asc.to_string(), "ASC");
355        assert_eq!(SortOrder::Desc.to_string(), "DESC");
356    }
357
358    #[test]
359    fn test_field_source_display() {
360        assert_eq!(FieldSource::JsonbPayload.to_string(), "JSONB");
361        assert_eq!(FieldSource::DirectColumn.to_string(), "DIRECT_COLUMN");
362    }
363
364    #[test]
365    fn test_collation_enum() {
366        assert_eq!(Collation::C.as_str(), "C");
367        assert_eq!(Collation::Utf8.as_str(), "C.UTF-8");
368        assert_eq!(Collation::Custom("de-DE".to_string()).as_str(), "de-DE");
369    }
370}