1use std::fmt;
7
8#[derive(Debug, Clone)]
40pub struct OrderByClause {
41 pub field: String,
43
44 pub field_source: FieldSource,
46
47 pub direction: SortOrder,
49
50 pub collation: Option<String>,
54
55 pub nulls_handling: Option<NullsHandling>,
57}
58
59impl OrderByClause {
60 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 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 pub fn with_collation(mut self, collation: impl Into<String>) -> Self {
84 self.collation = Some(collation.into());
85 self
86 }
87
88 pub const fn with_nulls(mut self, handling: NullsHandling) -> Self {
90 self.nulls_handling = Some(handling);
91 self
92 }
93
94 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 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 if let Some(ref collation) = self.collation {
118 if collation.is_empty() {
119 return Err("Collation name cannot be empty".to_string());
120 }
121 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 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 if let Some(ref collation) = self.collation {
159 sql.push_str(&format!(" COLLATE \"{}\"", collation));
160 }
161
162 let direction = match self.direction {
164 SortOrder::Asc => "ASC",
165 SortOrder::Desc => "DESC",
166 };
167 sql.push(' ');
168 sql.push_str(direction);
169
170 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195#[non_exhaustive]
196pub enum FieldSource {
197 JsonbPayload,
199
200 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215#[non_exhaustive]
216pub enum SortOrder {
217 Asc,
219
220 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235#[non_exhaustive]
236pub enum NullsHandling {
237 First,
239
240 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#[derive(Debug, Clone, PartialEq, Eq)]
258#[non_exhaustive]
259pub enum Collation {
260 C,
262
263 Utf8,
265
266 Custom(String),
268}
269
270impl Collation {
271 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)] 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}