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