Skip to content

Commit 5b9b435

Browse files
Added: order by clause sanitization
1 parent 363b268 commit 5b9b435

File tree

3 files changed

+228
-1
lines changed

3 files changed

+228
-1
lines changed

src/builders/select.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,22 @@ impl FromReady {
5959
self
6060
}
6161

62+
/// Adds an `ORDER BY` term for the given field and options.
63+
///
64+
/// Trailing SurrealDB order modifiers embedded in `field` (`ASC`, `DESC`,
65+
/// `NUMERIC`, `COLLATE`) are automatically stripped and discarded before
66+
/// building the term. Final ordering is determined exclusively by the
67+
/// explicit `order` argument (or its defaults when none is provided).
68+
///
69+
/// This prevents duplicate tokens such as `"name DESC DESC"` when callers
70+
/// accidentally include direction keywords in the field string.
6271
pub fn order_by(mut self, field: &str, order: impl Into<OrderOptions>) -> Self {
72+
let sanitized_field = OrderTerm::sanitize_field(field);
73+
6374
let opt = order.into();
6475

6576
let order_term = OrderTerm {
66-
field: field.to_string(),
77+
field: sanitized_field,
6778
direction: opt.direction,
6879
numeric: opt.numeric,
6980
collate: opt.collate,

src/types/select.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,57 @@ pub struct OrderTerm {
3030
pub collate: bool,
3131
}
3232

33+
impl OrderTerm {
34+
/// Strips trailing SurrealDB order modifiers (`ASC`, `DESC`, `NUMERIC`, `COLLATE`)
35+
/// from the end of a field string.
36+
///
37+
/// Returns the sanitized field string (trailing modifiers removed).
38+
/// Any trailing modifiers are discarded by this function and are not returned;
39+
/// they are intended only for diagnostics in callers and do not influence ordering.
40+
/// Final ordering is always determined by the explicit [`OrderOptions`]/[`Sort`]
41+
/// provided to the API.
42+
///
43+
/// # Examples
44+
///
45+
/// ```
46+
/// # use surrealex::types::select::OrderTerm;
47+
/// let field = OrderTerm::sanitize_field("name DESC");
48+
/// assert_eq!(field, "name");
49+
///
50+
/// let field = OrderTerm::sanitize_field("score COLLATE NUMERIC DESC");
51+
/// assert_eq!(field, "score");
52+
///
53+
/// let field = OrderTerm::sanitize_field("LOWER(name) DESC");
54+
/// assert_eq!(field, "LOWER(name)");
55+
/// ```
56+
pub fn sanitize_field(s: &str) -> String {
57+
const MODIFIERS: &[&str] = &["ASC", "DESC", "NUMERIC", "COLLATE"];
58+
59+
let mut remaining = s.trim_end().to_string();
60+
61+
loop {
62+
let upper = remaining.to_ascii_uppercase();
63+
let mut found = false;
64+
65+
for &modifier in MODIFIERS {
66+
let suffix = format!(" {modifier}");
67+
if upper.ends_with(&suffix) {
68+
let new_len = remaining.len() - suffix.len();
69+
remaining = remaining[..new_len].trim_end().to_string();
70+
found = true;
71+
break;
72+
}
73+
}
74+
75+
if !found {
76+
break;
77+
}
78+
}
79+
80+
remaining
81+
}
82+
}
83+
3384
impl Display for OrderTerm {
3485
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3586
if self.numeric && self.collate {

tests/select.rs

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,171 @@ fn select_star_and_subquery_field_builds() {
509509
);
510510
}
511511

512+
#[test]
513+
fn order_by_field_with_trailing_desc_uses_default_asc() {
514+
// Suffix "DESC" is stripped; OrderOptions::default() => Sort::Asc applies.
515+
let sql = QueryBuilder::select(surrealex::fields!("id"))
516+
.from("users")
517+
.order_by(
518+
"name DESC",
519+
surrealex::types::select::OrderOptions::default(),
520+
)
521+
.build();
522+
assert_eq!(sql, "SELECT id FROM users ORDER BY name ASC");
523+
}
524+
525+
#[test]
526+
fn order_by_field_with_trailing_desc_and_explicit_desc_no_duplication() {
527+
// Suffix "DESC" is stripped; explicit Sort::Desc still produces a single DESC.
528+
let sql = QueryBuilder::select(surrealex::fields!("id"))
529+
.from("users")
530+
.order_by("name DESC", Sort::Desc)
531+
.build();
532+
assert_eq!(sql, "SELECT id FROM users ORDER BY name DESC");
533+
}
534+
535+
#[test]
536+
fn order_by_field_with_trailing_desc_explicit_asc_wins() {
537+
// Suffix "DESC" is stripped and discarded; explicit Sort::Asc wins.
538+
let sql = QueryBuilder::select(surrealex::fields!("id"))
539+
.from("users")
540+
.order_by("name DESC", Sort::Asc)
541+
.build();
542+
assert_eq!(sql, "SELECT id FROM users ORDER BY name ASC");
543+
}
544+
545+
#[test]
546+
fn order_by_function_call_with_trailing_desc_uses_default_asc() {
547+
// Suffix after closing paren is stripped; default Sort::Asc applies.
548+
let sql = QueryBuilder::select(surrealex::fields!("id"))
549+
.from("users")
550+
.order_by("LOWER(name) DESC", ())
551+
.build();
552+
assert_eq!(sql, "SELECT id FROM users ORDER BY LOWER(name) ASC");
553+
}
554+
555+
#[test]
556+
fn order_by_collate_numeric_desc_combination_all_stripped() {
557+
// All three trailing modifiers are stripped; default Sort::Asc applies.
558+
let sql = QueryBuilder::select(surrealex::fields!("id"))
559+
.from("scores")
560+
.order_by("score COLLATE NUMERIC DESC", ())
561+
.build();
562+
assert_eq!(sql, "SELECT id FROM scores ORDER BY score ASC");
563+
}
564+
565+
#[test]
566+
fn order_by_trailing_asc_stripped_explicit_desc_wins() {
567+
// Trailing "ASC" in field is stripped; explicit Sort::Desc applies.
568+
let sql = QueryBuilder::select(surrealex::fields!("id"))
569+
.from("t")
570+
.order_by("name ASC", Sort::Desc)
571+
.build();
572+
assert_eq!(sql, "SELECT id FROM t ORDER BY name DESC");
573+
}
574+
575+
#[test]
576+
fn order_by_trailing_numeric_stripped_explicit_sort_applies() {
577+
// Trailing "NUMERIC" is stripped; explicit Sort::Asc applies.
578+
let sql = QueryBuilder::select(surrealex::fields!("id"))
579+
.from("t")
580+
.order_by("score NUMERIC", Sort::Asc)
581+
.build();
582+
assert_eq!(sql, "SELECT id FROM t ORDER BY score ASC");
583+
}
584+
585+
#[test]
586+
fn order_by_no_trailing_modifier_unchanged() {
587+
// No suffix present — field passes through unmodified.
588+
let sql = QueryBuilder::select(surrealex::fields!("id"))
589+
.from("t")
590+
.order_by("name", Sort::Desc)
591+
.build();
592+
assert_eq!(sql, "SELECT id FROM t ORDER BY name DESC");
593+
}
594+
595+
#[test]
596+
fn order_by_lowercase_trailing_desc_stripped() {
597+
// Stripping is case-insensitive; lowercase "desc" is stripped.
598+
let sql = QueryBuilder::select(surrealex::fields!("id"))
599+
.from("t")
600+
.order_by("name desc", Sort::Asc)
601+
.build();
602+
assert_eq!(sql, "SELECT id FROM t ORDER BY name ASC");
603+
}
604+
605+
#[test]
606+
fn order_by_mixed_case_trailing_modifier_stripped() {
607+
// Mixed-case modifier is stripped.
608+
let sql = QueryBuilder::select(surrealex::fields!("id"))
609+
.from("t")
610+
.order_by("name Desc", Sort::Desc)
611+
.build();
612+
assert_eq!(sql, "SELECT id FROM t ORDER BY name DESC");
613+
}
614+
615+
#[test]
616+
fn order_by_field_name_containing_desc_substring_not_stripped() {
617+
// "described" does not end with the token " DESC", so nothing is stripped.
618+
let sql = QueryBuilder::select(surrealex::fields!("id"))
619+
.from("t")
620+
.order_by("described", Sort::Asc)
621+
.build();
622+
assert_eq!(sql, "SELECT id FROM t ORDER BY described ASC");
623+
}
624+
625+
#[test]
626+
fn order_by_explicit_numeric_collate_options_still_applied_after_sanitize() {
627+
// Even when a suffix is present, explicit numeric+collate flags from Sort::Desc are kept.
628+
let sql = QueryBuilder::select(surrealex::fields!("id"))
629+
.from("t")
630+
.order_by("name DESC", Sort::Desc.collate().numeric())
631+
.build();
632+
assert_eq!(sql, "SELECT id FROM t ORDER BY name COLLATE NUMERIC DESC");
633+
}
634+
635+
#[test]
636+
fn sanitize_field_strips_single_desc() {
637+
use surrealex::types::select::OrderTerm;
638+
let field = OrderTerm::sanitize_field("name DESC");
639+
assert_eq!(field, "name");
640+
}
641+
642+
#[test]
643+
fn sanitize_field_strips_collate_numeric_desc_chain() {
644+
use surrealex::types::select::OrderTerm;
645+
let field = OrderTerm::sanitize_field("score COLLATE NUMERIC DESC");
646+
assert_eq!(field, "score");
647+
}
648+
649+
#[test]
650+
fn sanitize_field_strips_suffix_after_function_call() {
651+
use surrealex::types::select::OrderTerm;
652+
let field = OrderTerm::sanitize_field("LOWER(name) DESC");
653+
assert_eq!(field, "LOWER(name)");
654+
}
655+
656+
#[test]
657+
fn sanitize_field_no_modifier_returns_unchanged() {
658+
use surrealex::types::select::OrderTerm;
659+
let field = OrderTerm::sanitize_field("name");
660+
assert_eq!(field, "name");
661+
}
662+
663+
#[test]
664+
fn sanitize_field_case_insensitive_strip() {
665+
use surrealex::types::select::OrderTerm;
666+
let field = OrderTerm::sanitize_field("name desc");
667+
assert_eq!(field, "name");
668+
}
669+
670+
#[test]
671+
fn sanitize_field_embedded_desc_substring_not_stripped() {
672+
use surrealex::types::select::OrderTerm;
673+
let field = OrderTerm::sanitize_field("described");
674+
assert_eq!(field, "described");
675+
}
676+
512677
#[test]
513678
fn explain_simple_builds() {
514679
let sql = QueryBuilder::select(surrealex::fields!("id"))

0 commit comments

Comments
 (0)