Skip to main content

fraiseql_wire/operators/order_by/
mod.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    #[must_use]
90    pub const fn with_nulls(mut self, handling: NullsHandling) -> Self {
91        self.nulls_handling = Some(handling);
92        self
93    }
94
95    /// Validate field name to prevent SQL injection
96    ///
97    /// # Errors
98    ///
99    /// Returns an error string if the field name is empty, contains invalid characters,
100    /// or the collation name (if set) contains invalid characters.
101    pub fn validate(&self) -> Result<(), String> {
102        if self.field.is_empty() {
103            return Err("Field name cannot be empty".to_string());
104        }
105
106        // Validate field name: alphanumeric + underscore
107        if !self.field.chars().all(|c| c.is_alphanumeric() || c == '_')
108            || self
109                .field
110                .chars()
111                .next()
112                .is_some_and(|c| !c.is_alphabetic() && c != '_')
113        {
114            return Err(format!("Invalid field name: {}", self.field));
115        }
116
117        // Validate collation name if provided
118        if let Some(ref collation) = self.collation {
119            if collation.is_empty() {
120                return Err("Collation name cannot be empty".to_string());
121            }
122            // Allow common PostgreSQL collation patterns
123            // Format: language_REGION.encoding (e.g., en_US.UTF-8, C.UTF-8)
124            // or simple names like "C", custom names
125            // We validate format loosely - PostgreSQL will validate at execution time
126            if !collation
127                .chars()
128                .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '@')
129            {
130                return Err(format!("Invalid collation name: {}", collation));
131            }
132        }
133
134        Ok(())
135    }
136
137    /// Generate SQL for this clause
138    ///
139    /// # Examples
140    ///
141    /// - JSONB with collation: `(data->'name') COLLATE "en-US" ASC`
142    /// - Direct column: `created_at DESC`
143    /// - With NULLS: `status ASC NULLS LAST`
144    ///
145    /// # Errors
146    ///
147    /// Returns an error string if the clause fails validation (see [`Self::validate`]).
148    pub fn to_sql(&self) -> Result<String, String> {
149        self.validate()?;
150
151        let field_expr = match self.field_source {
152            FieldSource::JsonbPayload => format!("(data->'{}')", self.field),
153            FieldSource::DirectColumn => self.field.clone(),
154        };
155
156        let mut sql = field_expr;
157
158        // Add collation if specified
159        if let Some(ref collation) = self.collation {
160            sql.push_str(&format!(" COLLATE \"{}\"", collation));
161        }
162
163        // Add direction
164        let direction = match self.direction {
165            SortOrder::Asc => "ASC",
166            SortOrder::Desc => "DESC",
167        };
168        sql.push(' ');
169        sql.push_str(direction);
170
171        // Add NULLS handling
172        if let Some(nulls) = self.nulls_handling {
173            let nulls_str = match nulls {
174                NullsHandling::First => "NULLS FIRST",
175                NullsHandling::Last => "NULLS LAST",
176            };
177            sql.push(' ');
178            sql.push_str(nulls_str);
179        }
180
181        Ok(sql)
182    }
183}
184
185impl fmt::Display for OrderByClause {
186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187        match self.to_sql() {
188            Ok(sql) => write!(f, "{}", sql),
189            Err(e) => write!(f, "ERROR: {}", e),
190        }
191    }
192}
193
194/// Specifies where a field comes from in ORDER BY
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196#[non_exhaustive]
197pub enum FieldSource {
198    /// Field is inside the JSONB `data` column: `data->>'field'`
199    JsonbPayload,
200
201    /// Field is a direct database column: `column_name`
202    DirectColumn,
203}
204
205impl fmt::Display for FieldSource {
206    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207        match self {
208            FieldSource::JsonbPayload => write!(f, "JSONB"),
209            FieldSource::DirectColumn => write!(f, "DIRECT_COLUMN"),
210        }
211    }
212}
213
214/// Sort direction for ORDER BY
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216#[non_exhaustive]
217pub enum SortOrder {
218    /// Ascending order (default)
219    Asc,
220
221    /// Descending order
222    Desc,
223}
224
225impl fmt::Display for SortOrder {
226    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227        match self {
228            SortOrder::Asc => write!(f, "ASC"),
229            SortOrder::Desc => write!(f, "DESC"),
230        }
231    }
232}
233
234/// NULL handling in ORDER BY
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236#[non_exhaustive]
237pub enum NullsHandling {
238    /// NULL values come first
239    First,
240
241    /// NULL values come last
242    Last,
243}
244
245impl fmt::Display for NullsHandling {
246    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247        match self {
248            NullsHandling::First => write!(f, "NULLS FIRST"),
249            NullsHandling::Last => write!(f, "NULLS LAST"),
250        }
251    }
252}
253
254/// PostgreSQL collation specifications
255///
256/// Supports common collations and custom names.
257/// When used, generates: `field COLLATE "collation_name"`
258#[derive(Debug, Clone, PartialEq, Eq)]
259#[non_exhaustive]
260pub enum Collation {
261    /// Binary collation (fast, deterministic)
262    C,
263
264    /// UTF-8 binary collation
265    Utf8,
266
267    /// Custom collation name (e.g., "en-US", "de_DE.UTF-8")
268    Custom(String),
269}
270
271impl Collation {
272    /// Get the PostgreSQL collation name as a string
273    #[must_use]
274    pub fn as_str(&self) -> &str {
275        match self {
276            Collation::C => "C",
277            Collation::Utf8 => "C.UTF-8",
278            Collation::Custom(name) => name,
279        }
280    }
281}
282
283impl fmt::Display for Collation {
284    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
285        write!(f, "{}", self.as_str())
286    }
287}
288
289#[cfg(test)]
290mod tests;