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;