use std::fmt;
#[derive(Debug, Clone)]
pub struct OrderByClause {
pub field: String,
pub field_source: FieldSource,
pub direction: SortOrder,
pub collation: Option<String>,
pub nulls_handling: Option<NullsHandling>,
}
impl OrderByClause {
pub fn jsonb_field(field: impl Into<String>, direction: SortOrder) -> Self {
Self {
field: field.into(),
field_source: FieldSource::JsonbPayload,
direction,
collation: None,
nulls_handling: None,
}
}
pub fn direct_column(field: impl Into<String>, direction: SortOrder) -> Self {
Self {
field: field.into(),
field_source: FieldSource::DirectColumn,
direction,
collation: None,
nulls_handling: None,
}
}
pub fn with_collation(mut self, collation: impl Into<String>) -> Self {
self.collation = Some(collation.into());
self
}
pub const fn with_nulls(mut self, handling: NullsHandling) -> Self {
self.nulls_handling = Some(handling);
self
}
pub fn validate(&self) -> Result<(), String> {
if self.field.is_empty() {
return Err("Field name cannot be empty".to_string());
}
if !self.field.chars().all(|c| c.is_alphanumeric() || c == '_')
|| self
.field
.chars()
.next()
.is_some_and(|c| !c.is_alphabetic() && c != '_')
{
return Err(format!("Invalid field name: {}", self.field));
}
if let Some(ref collation) = self.collation {
if collation.is_empty() {
return Err("Collation name cannot be empty".to_string());
}
if !collation
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '@')
{
return Err(format!("Invalid collation name: {}", collation));
}
}
Ok(())
}
pub fn to_sql(&self) -> Result<String, String> {
self.validate()?;
let field_expr = match self.field_source {
FieldSource::JsonbPayload => format!("(data->'{}')", self.field),
FieldSource::DirectColumn => self.field.clone(),
};
let mut sql = field_expr;
if let Some(ref collation) = self.collation {
sql.push_str(&format!(" COLLATE \"{}\"", collation));
}
let direction = match self.direction {
SortOrder::Asc => "ASC",
SortOrder::Desc => "DESC",
};
sql.push(' ');
sql.push_str(direction);
if let Some(nulls) = self.nulls_handling {
let nulls_str = match nulls {
NullsHandling::First => "NULLS FIRST",
NullsHandling::Last => "NULLS LAST",
};
sql.push(' ');
sql.push_str(nulls_str);
}
Ok(sql)
}
}
impl fmt::Display for OrderByClause {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.to_sql() {
Ok(sql) => write!(f, "{}", sql),
Err(e) => write!(f, "ERROR: {}", e),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FieldSource {
JsonbPayload,
DirectColumn,
}
impl fmt::Display for FieldSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
FieldSource::JsonbPayload => write!(f, "JSONB"),
FieldSource::DirectColumn => write!(f, "DIRECT_COLUMN"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SortOrder {
Asc,
Desc,
}
impl fmt::Display for SortOrder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SortOrder::Asc => write!(f, "ASC"),
SortOrder::Desc => write!(f, "DESC"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum NullsHandling {
First,
Last,
}
impl fmt::Display for NullsHandling {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
NullsHandling::First => write!(f, "NULLS FIRST"),
NullsHandling::Last => write!(f, "NULLS LAST"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Collation {
C,
Utf8,
Custom(String),
}
impl Collation {
pub fn as_str(&self) -> &str {
match self {
Collation::C => "C",
Collation::Utf8 => "C.UTF-8",
Collation::Custom(name) => name,
}
}
}
impl fmt::Display for Collation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)] use super::*;
#[test]
fn test_jsonb_field_ordering() {
let clause = OrderByClause::jsonb_field("name", SortOrder::Asc);
let sql = clause.to_sql().unwrap();
assert_eq!(sql, "(data->'name') ASC");
}
#[test]
fn test_direct_column_ordering() {
let clause = OrderByClause::direct_column("created_at", SortOrder::Desc);
let sql = clause.to_sql().unwrap();
assert_eq!(sql, "created_at DESC");
}
#[test]
fn test_ordering_with_collation() {
let clause = OrderByClause::jsonb_field("name", SortOrder::Asc).with_collation("en-US");
let sql = clause.to_sql().unwrap();
assert_eq!(sql, "(data->'name') COLLATE \"en-US\" ASC");
}
#[test]
fn test_ordering_with_nulls_last() {
let clause =
OrderByClause::direct_column("status", SortOrder::Asc).with_nulls(NullsHandling::Last);
let sql = clause.to_sql().unwrap();
assert_eq!(sql, "status ASC NULLS LAST");
}
#[test]
fn test_ordering_with_collation_and_nulls() {
let clause = OrderByClause::jsonb_field("email", SortOrder::Desc)
.with_collation("C")
.with_nulls(NullsHandling::First);
let sql = clause.to_sql().unwrap();
assert_eq!(sql, "(data->'email') COLLATE \"C\" DESC NULLS FIRST");
}
#[test]
fn test_field_validation() {
OrderByClause::jsonb_field("valid_name", SortOrder::Asc)
.validate()
.unwrap_or_else(|e| panic!("expected Ok for 'valid_name': {e}"));
let result = OrderByClause::jsonb_field("123invalid", SortOrder::Asc).validate();
assert!(
result.is_err(),
"expected Err for '123invalid', got: {result:?}"
);
let result = OrderByClause::jsonb_field("bad-name", SortOrder::Asc).validate();
assert!(
result.is_err(),
"expected Err for 'bad-name', got: {result:?}"
);
}
#[test]
fn test_collation_validation() {
let clause = OrderByClause::jsonb_field("name", SortOrder::Asc).with_collation("en-US");
clause
.validate()
.unwrap_or_else(|e| panic!("expected Ok for collation 'en-US': {e}"));
let clause = OrderByClause::jsonb_field("name", SortOrder::Asc).with_collation("C.UTF-8");
clause
.validate()
.unwrap_or_else(|e| panic!("expected Ok for collation 'C.UTF-8': {e}"));
let clause =
OrderByClause::jsonb_field("name", SortOrder::Asc).with_collation("invalid!!!special");
let result = clause.validate();
assert!(
result.is_err(),
"expected Err for collation 'invalid!!!special', got: {result:?}"
);
}
#[test]
fn test_sort_order_display() {
assert_eq!(SortOrder::Asc.to_string(), "ASC");
assert_eq!(SortOrder::Desc.to_string(), "DESC");
}
#[test]
fn test_field_source_display() {
assert_eq!(FieldSource::JsonbPayload.to_string(), "JSONB");
assert_eq!(FieldSource::DirectColumn.to_string(), "DIRECT_COLUMN");
}
#[test]
fn test_collation_enum() {
assert_eq!(Collation::C.as_str(), "C");
assert_eq!(Collation::Utf8.as_str(), "C.UTF-8");
assert_eq!(Collation::Custom("de-DE".to_string()).as_str(), "de-DE");
}
}