From 86bc4434e7ec3ce590dd49baf3d718f05e8968e1 Mon Sep 17 00:00:00 2001 From: xelab04 Date: Tue, 24 Feb 2026 22:04:00 +0400 Subject: [PATCH 01/11] add basic code for ordering --- cot/src/db/query.rs | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/cot/src/db/query.rs b/cot/src/db/query.rs index 139a7ca76..7e69307e3 100644 --- a/cot/src/db/query.rs +++ b/cot/src/db/query.rs @@ -3,7 +3,7 @@ use std::marker::PhantomData; use derive_more::with_trait::Debug; -use sea_query::{ExprTrait, IntoColumnRef}; +use sea_query::{ExprTrait, IntoColumnRef, Order}; use crate::db; use crate::db::{ @@ -33,6 +33,7 @@ use crate::db::{ pub struct Query { filter: Option, limit: Option, + order_by: Option<(String, Order)>, offset: Option, phantom_data: PhantomData T>, } @@ -55,6 +56,7 @@ impl Clone for Query { Self { filter: self.filter.clone(), limit: self.limit, + order_by: self.order_by.clone(), offset: self.offset, phantom_data: PhantomData, } @@ -98,6 +100,7 @@ impl Query { Self { filter: None, limit: None, + order_by: None, offset: None, phantom_data: PhantomData, } @@ -149,6 +152,28 @@ impl Query { self } + /// Set an order for records from the query. + /// + /// # Example + /// + /// ``` + /// use cot::db::model; + /// use cot::db::query::{Expr, Query}; + /// + /// #[model] + /// struct User { + /// #[model(primary_key)] + /// id: i32, + /// age: i32, + /// } + /// + /// let query = Query::::new().order_by("age", Order::Asc); // or Order::Desc + /// ``` + pub fn order_by(&mut self, order_by: (String, Order)) -> &mut Self { + self.order_by = Some(self.order_by); + self + } + /// Set the offset for the query. /// /// # Example @@ -244,6 +269,12 @@ impl Query { } } + pub(super) fn add_order_by_to_statement(&self, statement: &mut sea_query::SelectStatement) { + if let Some(order_by) = self.order_by { + statement.order_by(order_by.0, order_by.1); + } + } + pub(super) fn add_offset_to_statement(&self, statement: &mut sea_query::SelectStatement) { if let Some(offset) = self.offset { statement.offset(offset); From 8fb90e8cca17e5b32eea4377d71277a4ee916934 Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 11 Mar 2026 13:12:43 +0400 Subject: [PATCH 02/11] change ordering to use fieldref instead of string --- cot/src/db/query.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cot/src/db/query.rs b/cot/src/db/query.rs index 7e69307e3..6a40f8437 100644 --- a/cot/src/db/query.rs +++ b/cot/src/db/query.rs @@ -33,7 +33,7 @@ use crate::db::{ pub struct Query { filter: Option, limit: Option, - order_by: Option<(String, Order)>, + order_by: Option<(FieldRef, Order)>, offset: Option, phantom_data: PhantomData T>, } @@ -167,10 +167,10 @@ impl Query { /// age: i32, /// } /// - /// let query = Query::::new().order_by("age", Order::Asc); // or Order::Desc + /// let query = Query::::new().order_by(User::age, Order::Asc); // or Order::Desc /// ``` - pub fn order_by(&mut self, order_by: (String, Order)) -> &mut Self { - self.order_by = Some(self.order_by); + pub fn order_by(&mut self, order_by: (FieldRef, Order)) -> &mut Self { + self.order_by = Some(order_by); self } @@ -271,7 +271,8 @@ impl Query { pub(super) fn add_order_by_to_statement(&self, statement: &mut sea_query::SelectStatement) { if let Some(order_by) = self.order_by { - statement.order_by(order_by.0, order_by.1); + let column_name = order_by.0.identifier; + statement.order_by(column_name, order_by.1); } } From 62dbf4efb6d2ef93b932c7e622b6e2e5d77ccc0c Mon Sep 17 00:00:00 2001 From: Elijah Date: Wed, 26 Aug 2026 00:08:41 +0000 Subject: [PATCH 03/11] get this in a much better shape. Still lacking other expressiveness --- cot/src/db.rs | 1 + cot/src/db/query.rs | 16 ++--- cot/src/db/query/expr.rs | 6 ++ cot/src/db/query/expr/order_by.rs | 115 ++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 9 deletions(-) create mode 100644 cot/src/db/query/expr/order_by.rs diff --git a/cot/src/db.rs b/cot/src/db.rs index f4b1004a1..3414800c6 100644 --- a/cot/src/db.rs +++ b/cot/src/db.rs @@ -2572,6 +2572,7 @@ impl Database { let mut select = sea_query::Query::select(); select.columns(columns_to_get).from(T::TABLE_NAME); query.add_filter_to_statement(&mut select, executor.as_sql_query_builder())?; + query.add_order_by_to_statement(&mut select); query.add_limit_to_statement(&mut select); query.add_offset_to_statement(&mut select); diff --git a/cot/src/db/query.rs b/cot/src/db/query.rs index ca4609e90..8cdce8655 100644 --- a/cot/src/db/query.rs +++ b/cot/src/db/query.rs @@ -5,12 +5,11 @@ pub mod expr; use std::marker::PhantomData; use derive_more::with_trait::Debug; -use sea_query::{ExprTrait, IntoColumnRef, Order}; use thiserror::Error; use crate::db; -use crate::db::query::expr::SqlQueryBuilder; pub use crate::db::query::expr::{Expr, ExprAdd, ExprDiv, ExprMul, ExprOrd, ExprSub}; +use crate::db::query::expr::{OrderByExpr, SqlQueryBuilder}; use crate::db::{Auto, DatabaseBackend, ForeignKey, Model, StatementResult, ToDbFieldValue}; const ERROR_PREFIX: &str = "expression error:"; @@ -48,7 +47,7 @@ pub enum QueryBuildingError { pub struct Query { filter: Option, limit: Option, - order_by: Option<(FieldRef, Order)>, + order_by: Vec, offset: Option, phantom_data: PhantomData T>, } @@ -115,7 +114,7 @@ impl Query { Self { filter: None, limit: None, - order_by: None, + order_by: Vec::new(), offset: None, phantom_data: PhantomData, } @@ -184,8 +183,8 @@ impl Query { /// /// let query = Query::::new().order_by(User::age, Order::Asc); // or Order::Desc /// ``` - pub fn order_by(&mut self, order_by: (FieldRef, Order)) -> &mut Self { - self.order_by = Some(order_by); + pub fn order_by(&mut self, order_by: impl IntoIterator) -> &mut Self { + self.order_by = order_by.into_iter().collect(); self } @@ -276,9 +275,8 @@ impl Query { } pub(super) fn add_order_by_to_statement(&self, statement: &mut sea_query::SelectStatement) { - if let Some(order_by) = self.order_by { - let column_name = order_by.0.identifier; - statement.order_by(column_name, order_by.1); + for order_by in &self.order_by { + order_by.add_to_statement(statement); } } diff --git a/cot/src/db/query/expr.rs b/cot/src/db/query/expr.rs index 8946ccd95..ef8805bc6 100644 --- a/cot/src/db/query/expr.rs +++ b/cot/src/db/query/expr.rs @@ -1,5 +1,6 @@ //! Database expressions. pub mod like; +mod order_by; use std::marker::PhantomData; @@ -7,6 +8,7 @@ use cot::db::query::{IntoField, QueryBuildingError}; use cot::db::{DbFieldValue, DbValue, FromDbValue, Identifier, ToDbFieldValue}; pub use like::ExprLike; use like::{CaseSensitivity, LikeExprBuilder, LikeMode}; +pub use order_by::{ExprSort, NullsOrder, OrderByExpr, SortOrder}; use sea_query::{ExprTrait, IntoColumnRef, SimpleExpr}; /// An expression that can be used to filter, update, or delete rows. @@ -1316,6 +1318,10 @@ impl FieldRef { pub fn as_expr(&self) -> Expr { Expr::Field(self.identifier) } + + pub(crate) fn identifier(&self) -> Identifier { + self.identifier + } } /// A trait for types that can be compared in database expressions. diff --git a/cot/src/db/query/expr/order_by.rs b/cot/src/db/query/expr/order_by.rs new file mode 100644 index 000000000..59e1341ed --- /dev/null +++ b/cot/src/db/query/expr/order_by.rs @@ -0,0 +1,115 @@ +use crate::db::Identifier; +use crate::db::query::expr::FieldRef; + +/// Ordering Options +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SortOrder { + /// Sort in Ascending order. + Asc, + /// Sort in Descending Order. + Desc, +} + +impl From for sea_query::Order { + fn from(value: SortOrder) -> Self { + match value { + SortOrder::Asc => sea_query::Order::Asc, + SortOrder::Desc => sea_query::Order::Desc, + } + } +} + +/// The order to sort null values +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum NullsOrder { + /// Null values will appear first + First, + /// Null values will appear last + Last, +} + +impl From for sea_query::NullOrdering { + fn from(value: NullsOrder) -> Self { + match value { + NullsOrder::First => sea_query::NullOrdering::First, + NullsOrder::Last => sea_query::NullOrdering::Last, + } + } +} + +/// An `ORDER BY` term. +/// +/// # Example +/// +/// ``` +/// use cot::db::model; +/// use cot::db::query::{ExprSort, Query}; +/// +/// #[model] +/// struct User { +/// #[model(primary_key)] +/// id: i32, +/// name: String, +/// } +/// +/// let mut query = Query::::new(); +/// query.order_by([ +/// ::Fields::id.asc(), +/// ::Fields::name.desc().nulls_first(), +/// ]); +/// ``` +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct OrderByExpr { + field: Identifier, + order: SortOrder, + nulls: Option, +} + +impl OrderByExpr { + pub(crate) fn new(field: Identifier, order: SortOrder) -> Self { + Self { + field, + order, + nulls: None, + } + } + + #[must_use] + pub fn nulls_first(mut self) -> Self { + self.nulls = Some(NullsOrder::First); + self + } + + #[must_use] + pub fn nulls_last(mut self) -> Self { + self.nulls = Some(NullsOrder::Last); + self + } + + pub(crate) fn add_to_statement(&self, statement: &mut sea_query::SelectStatement) { + match self.nulls { + None => { + statement.order_by(self.field, self.order.into()); + } + Some(nulls) => { + statement.order_by_with_nulls(self.field, self.order.into(), nulls.into()); + } + }; + } +} + +pub trait ExprSort { + fn asc(&self) -> OrderByExpr; + fn desc(&self) -> OrderByExpr; +} + +impl ExprSort for FieldRef { + fn asc(&self) -> OrderByExpr { + OrderByExpr::new(self.identifier(), SortOrder::Asc) + } + + fn desc(&self) -> OrderByExpr { + OrderByExpr::new(self.identifier(), SortOrder::Desc) + } +} From c34fef00c7ffb2666bafe78667b1aa41cd6b2405 Mon Sep 17 00:00:00 2001 From: Elijah Date: Wed, 26 Aug 2026 02:20:39 +0000 Subject: [PATCH 04/11] order_by field. Need a better name --- cot/src/db.rs | 3 ++ cot/src/db/fields.rs | 1 + cot/src/db/query/expr/order_by.rs | 66 ++++++++++++++++++++++++------- 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/cot/src/db.rs b/cot/src/db.rs index 3414800c6..b4fdbe814 100644 --- a/cot/src/db.rs +++ b/cot/src/db.rs @@ -1019,6 +1019,9 @@ pub trait FromDbValue { /// An alias for the value type internally used by the [`sea_query`] crate. pub type DbValue = sea_query::Value; +/// An alias for the values type internally used the [`sea_query`] crate. +pub type DbValues = sea_query::Values; + /// A trait for converting a Rust value to a database value. pub trait ToDbValue: Send + Sync { /// Converts the Rust value to a `sea_query` value. diff --git a/cot/src/db/fields.rs b/cot/src/db/fields.rs index e420143b6..bbbde19ac 100644 --- a/cot/src/db/fields.rs +++ b/cot/src/db/fields.rs @@ -249,6 +249,7 @@ impl_db_field!(Vec, Blob); impl_db_field!(Bytes, Blob, with Vec); impl TextField for String {} +impl TextField for &str {} impl ToDbValue for &str { fn to_db_value(&self) -> DbValue { diff --git a/cot/src/db/query/expr/order_by.rs b/cot/src/db/query/expr/order_by.rs index 59e1341ed..3f7573c70 100644 --- a/cot/src/db/query/expr/order_by.rs +++ b/cot/src/db/query/expr/order_by.rs @@ -1,20 +1,26 @@ -use crate::db::Identifier; +use cot::db::{DbFieldValue, ToDbFieldValue}; +use sea_query::Values; + use crate::db::query::expr::FieldRef; +use crate::db::{DbValues, Identifier, ToDbValue}; /// Ordering Options -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq)] pub enum SortOrder { /// Sort in Ascending order. Asc, /// Sort in Descending Order. Desc, + + Custom(DbValues), } -impl From for sea_query::Order { - fn from(value: SortOrder) -> Self { +impl From<&SortOrder> for sea_query::Order { + fn from(value: &SortOrder) -> Self { match value { SortOrder::Asc => sea_query::Order::Asc, SortOrder::Desc => sea_query::Order::Desc, + SortOrder::Custom(v) => sea_query::Order::Field(v.clone()), } } } @@ -77,34 +83,49 @@ impl OrderByExpr { #[must_use] pub fn nulls_first(mut self) -> Self { - self.nulls = Some(NullsOrder::First); + self.set_nulls(NullsOrder::First); self } #[must_use] pub fn nulls_last(mut self) -> Self { - self.nulls = Some(NullsOrder::Last); + self.set_nulls(NullsOrder::Last); self } + #[track_caller] + fn set_nulls(&mut self, nulls: NullsOrder) { + match &mut self.order { + SortOrder::Asc | SortOrder::Desc => self.nulls = Some(nulls), + SortOrder::Custom(_) => panic!( + "`nulls_first`/`nulls_last` can't be combined with `custom_order`: a custom-order term never produces \ + a NULL sort key, so an explicit NULLS placement would have no effect" + ), + } + } + pub(crate) fn add_to_statement(&self, statement: &mut sea_query::SelectStatement) { - match self.nulls { - None => { - statement.order_by(self.field, self.order.into()); - } - Some(nulls) => { - statement.order_by_with_nulls(self.field, self.order.into(), nulls.into()); - } + let order: sea_query::Order = (&self.order).into(); + if let Some(nulls) = self.nulls { + let nulls: sea_query::NullOrdering = nulls.into(); + statement.order_by_with_nulls(self.field, order, nulls); + } else { + statement.order_by(self.field, order); }; } } -pub trait ExprSort { +pub trait ExprSort { fn asc(&self) -> OrderByExpr; fn desc(&self) -> OrderByExpr; + + fn custom(&self, values: I) -> OrderByExpr + where + I: IntoIterator, + I::Item: ToDbValue; } -impl ExprSort for FieldRef { +impl ExprSort for FieldRef { fn asc(&self) -> OrderByExpr { OrderByExpr::new(self.identifier(), SortOrder::Asc) } @@ -112,4 +133,19 @@ impl ExprSort for FieldRef { fn desc(&self) -> OrderByExpr { OrderByExpr::new(self.identifier(), SortOrder::Desc) } + + fn custom(&self, values: I) -> OrderByExpr + where + I: IntoIterator, + I::Item: ToDbValue, + { + let values = values + .into_iter() + .map(|v| match v.to_db_field_value() { + DbFieldValue::Value(value) => value, + DbFieldValue::Auto => panic!("Cannot order by a non-value field"), + }) + .collect::>(); + OrderByExpr::new(self.identifier(), SortOrder::Custom(Values(values))) + } } From c671e624d8268adaafd8cd309cee863824f11766 Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 28 Aug 2026 03:46:03 +0000 Subject: [PATCH 05/11] much much better API. no tests yet. Also add proper support for custom order. API name needs to change though --- cot/src/db.rs | 2 +- cot/src/db/query.rs | 18 +++- cot/src/db/query/expr.rs | 63 ++++++++++- cot/src/db/query/expr/order_by.rs | 173 ++++++++++++++++++++++++------ 4 files changed, 215 insertions(+), 41 deletions(-) diff --git a/cot/src/db.rs b/cot/src/db.rs index b4fdbe814..be40cbf59 100644 --- a/cot/src/db.rs +++ b/cot/src/db.rs @@ -2575,7 +2575,7 @@ impl Database { let mut select = sea_query::Query::select(); select.columns(columns_to_get).from(T::TABLE_NAME); query.add_filter_to_statement(&mut select, executor.as_sql_query_builder())?; - query.add_order_by_to_statement(&mut select); + query.add_order_by_to_statement(&mut select, executor.as_sql_query_builder())?; query.add_limit_to_statement(&mut select); query.add_offset_to_statement(&mut select); diff --git a/cot/src/db/query.rs b/cot/src/db/query.rs index 8cdce8655..268d072c6 100644 --- a/cot/src/db/query.rs +++ b/cot/src/db/query.rs @@ -57,6 +57,7 @@ impl Debug for Query { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Query") .field("filter", &self.filter) + .field("order_by", &self.order_by) .field("limit", &self.limit) .field("offset", &self.offset) .field("phantom_data", &self.phantom_data) @@ -183,8 +184,12 @@ impl Query { /// /// let query = Query::::new().order_by(User::age, Order::Asc); // or Order::Desc /// ``` - pub fn order_by(&mut self, order_by: impl IntoIterator) -> &mut Self { - self.order_by = order_by.into_iter().collect(); + pub fn order_by(&mut self, order_by: I) -> &mut Self + where + O: Into, + I: IntoIterator, + { + self.order_by = order_by.into_iter().map(Into::into).collect(); self } @@ -274,10 +279,15 @@ impl Query { } } - pub(super) fn add_order_by_to_statement(&self, statement: &mut sea_query::SelectStatement) { + pub(super) fn add_order_by_to_statement( + &self, + statement: &mut sea_query::SelectStatement, + sql_builder: &dyn SqlQueryBuilder, + ) -> Result<(), QueryBuildingError> { for order_by in &self.order_by { - order_by.add_to_statement(statement); + order_by.add_to_statement(statement, sql_builder)?; } + Ok(()) } pub(super) fn add_offset_to_statement(&self, statement: &mut sea_query::SelectStatement) { diff --git a/cot/src/db/query/expr.rs b/cot/src/db/query/expr.rs index ef8805bc6..5d585649b 100644 --- a/cot/src/db/query/expr.rs +++ b/cot/src/db/query/expr.rs @@ -3,14 +3,18 @@ pub mod like; mod order_by; use std::marker::PhantomData; +use std::ops::Add; use cot::db::query::{IntoField, QueryBuildingError}; -use cot::db::{DbFieldValue, DbValue, FromDbValue, Identifier, ToDbFieldValue}; +use cot::db::{DbFieldValue, DbValue, FromDbValue, Identifier, LimitedString, ToDbFieldValue}; pub use like::ExprLike; use like::{CaseSensitivity, LikeExprBuilder, LikeMode}; pub use order_by::{ExprSort, NullsOrder, OrderByExpr, SortOrder}; use sea_query::{ExprTrait, IntoColumnRef, SimpleExpr}; +use crate::db::ToDbValue; +use crate::db::query::expr::order_by::OrderTarget; + /// An expression that can be used to filter, update, or delete rows. /// /// This is used to create complex queries with multiple conditions. Typically, @@ -1199,6 +1203,38 @@ impl Expr { Self::RawLike(Box::new(lhs), Box::new(rhs), CaseSensitivity::Insensitive) } + /// Builds an ascending `ORDER BY` term from this expression. See the + /// note on [`Query::filter`](crate::db::query::Query::filter) about + /// `Expr` not being restricted to field references — the same applies + /// here; ordering by a boolean-producing expression is legal SQL but + /// rarely what you want. + #[must_use] + pub fn asc(self) -> OrderByExpr { + OrderByExpr::directional(OrderTarget::Expression(self), SortOrder::Asc) + } + + /// The descending counterpart of [`Self::asc`]. + #[must_use] + pub fn desc(self) -> OrderByExpr { + OrderByExpr::directional(OrderTarget::Expression(self), SortOrder::Desc) + } + + /// The [`ExprSort::custom`]-equivalent for a compound expression. + /// + /// Takes plain [`ToDbValue`] items rather than [`IntoField`]: unlike + /// [`FieldRef`], a general `Expr` isn't associated with one Rust + /// field type to convert against, so there's no `T` for `IntoField` + /// to key off of. + #[must_use] + pub fn custom(self, values: I) -> OrderByExpr + where + I: IntoIterator, + I::Item: ToDbValue, + { + let values = values.into_iter().map(|v| v.to_db_value()).collect(); + OrderByExpr::custom(OrderTarget::Expression(self), sea_query::Values(values)) + } + /// Returns the expression as a [`sea_query::SimpleExpr`]. /// /// # Example @@ -1324,6 +1360,17 @@ impl FieldRef { } } +impl Add> for FieldRef +where + FieldRef: ExprAdd>, +{ + type Output = Expr; + + fn add(self, rhs: FieldRef) -> Self::Output { + ExprAdd::add(self, rhs) + } +} + /// A trait for types that can be compared in database expressions. pub trait ExprEq { /// Creates an expression that checks if the field is equal to the given @@ -1651,6 +1698,20 @@ impl_num_expr!(u64); impl_num_expr!(f32); impl_num_expr!(f64); +impl ExprAdd for FieldRef { + fn add>(self, other: V) -> Expr { + Expr::add(Expr::field(self.identifier()), Expr::value(other.into())) + } +} + +impl ExprAdd>> for FieldRef> { + fn add>>>(self, other: V) -> Expr { + Expr::add( + Expr::field(self.identifier()), + Expr::field(other.into().identifier()), + ) + } +} #[cfg(test)] mod test { use super::*; diff --git a/cot/src/db/query/expr/order_by.rs b/cot/src/db/query/expr/order_by.rs index 3f7573c70..19562ab50 100644 --- a/cot/src/db/query/expr/order_by.rs +++ b/cot/src/db/query/expr/order_by.rs @@ -1,18 +1,16 @@ use cot::db::{DbFieldValue, ToDbFieldValue}; -use sea_query::Values; -use crate::db::query::expr::FieldRef; -use crate::db::{DbValues, Identifier, ToDbValue}; +use crate::db::Identifier; +use crate::db::query::expr::{FieldRef, SqlQueryBuilder}; +use crate::db::query::{Expr, IntoField, QueryBuildingError}; /// Ordering Options -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq)] pub enum SortOrder { /// Sort in Ascending order. Asc, /// Sort in Descending Order. Desc, - - Custom(DbValues), } impl From<&SortOrder> for sea_query::Order { @@ -20,7 +18,15 @@ impl From<&SortOrder> for sea_query::Order { match value { SortOrder::Asc => sea_query::Order::Asc, SortOrder::Desc => sea_query::Order::Desc, - SortOrder::Custom(v) => sea_query::Order::Field(v.clone()), + } + } +} + +impl From for sea_query::Order { + fn from(value: SortOrder) -> Self { + match value { + SortOrder::Asc => sea_query::Order::Asc, + SortOrder::Desc => sea_query::Order::Desc, } } } @@ -34,6 +40,15 @@ pub enum NullsOrder { Last, } +impl From<&NullsOrder> for sea_query::NullOrdering { + fn from(value: &NullsOrder) -> Self { + match value { + NullsOrder::First => sea_query::NullOrdering::First, + NullsOrder::Last => sea_query::NullOrdering::Last, + } + } +} + impl From for sea_query::NullOrdering { fn from(value: NullsOrder) -> Self { match value { @@ -43,6 +58,25 @@ impl From for sea_query::NullOrdering { } } +/// The type of the order field +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub(crate) enum OrderTarget { + /// Whether the order field is a column + Column(Identifier), + /// Whether the order field is an expression + Expression(Expr), +} + +#[derive(Debug, Clone, PartialEq)] +enum OrderMode { + Directional { + order: SortOrder, + nulls: Option, + }, + Custom(sea_query::Values), +} + /// An `ORDER BY` term. /// /// # Example @@ -67,26 +101,49 @@ impl From for sea_query::NullOrdering { #[derive(Debug, Clone, PartialEq)] #[non_exhaustive] pub struct OrderByExpr { - field: Identifier, - order: SortOrder, - nulls: Option, + target: OrderTarget, + mode: OrderMode, } impl OrderByExpr { - pub(crate) fn new(field: Identifier, order: SortOrder) -> Self { + pub(crate) fn directional(target: OrderTarget, order: SortOrder) -> Self { + Self { + target, + mode: OrderMode::Directional { order, nulls: None }, + } + } + + pub(crate) fn custom(target: OrderTarget, values: sea_query::Values) -> Self { + assert!( + !values.0.is_empty(), + "`custom` requires at least one value to rank by" + ); Self { - field, - order, - nulls: None, + target, + mode: OrderMode::Custom(values), } } + /// Places `NULL` values before all non-`NULL` values for this term, + /// regardless of database backend or sort direction. + /// + /// # Panics + /// + /// Panics if this term was built with [`ExprSort::custom_order`]. A + /// custom-order term never produces a `NULL` sort key, + /// so an explicit `NULLS` placement on top of it can never have any + /// effect. #[must_use] pub fn nulls_first(mut self) -> Self { self.set_nulls(NullsOrder::First); self } + /// Places `NULL` values after all non-`NULL` values for this term. + /// + /// # Panics + /// + /// See [`Self::nulls_first`]. #[must_use] pub fn nulls_last(mut self) -> Self { self.set_nulls(NullsOrder::Last); @@ -95,57 +152,103 @@ impl OrderByExpr { #[track_caller] fn set_nulls(&mut self, nulls: NullsOrder) { - match &mut self.order { - SortOrder::Asc | SortOrder::Desc => self.nulls = Some(nulls), - SortOrder::Custom(_) => panic!( - "`nulls_first`/`nulls_last` can't be combined with `custom_order`: a custom-order term never produces \ - a NULL sort key, so an explicit NULLS placement would have no effect" + match &mut self.mode { + OrderMode::Directional { nulls: n, .. } => *n = Some(nulls), + OrderMode::Custom(_) => panic!( + "`nulls_first`/`nulls_last` can't be combined with `custom`: a custom-order \ + term never produces a NULL sort key, so an explicit NULLS placement would \ + have no effect" ), } } - pub(crate) fn add_to_statement(&self, statement: &mut sea_query::SelectStatement) { - let order: sea_query::Order = (&self.order).into(); - if let Some(nulls) = self.nulls { - let nulls: sea_query::NullOrdering = nulls.into(); - statement.order_by_with_nulls(self.field, order, nulls); - } else { - statement.order_by(self.field, order); + pub(crate) fn add_to_statement( + &self, + statement: &mut sea_query::SelectStatement, + sql_builder: &dyn SqlQueryBuilder, + ) -> Result<(), QueryBuildingError> { + let (sea_order, nulls): (sea_query::Order, Option) = match &self.mode { + OrderMode::Directional { order, nulls } => (order.into(), *nulls), + OrderMode::Custom(values) => (sea_query::Order::Field(values.clone()), None), }; + + match &self.target { + OrderTarget::Column(field) => match nulls { + Some(nulls) => { + statement.order_by_with_nulls(*field, sea_order, nulls.into()); + } + None => { + statement.order_by(*field, sea_order); + } + }, + OrderTarget::Expression(expr) => { + let expr = expr.as_sea_query_expr(sql_builder)?; + match nulls { + Some(nulls) => { + statement.order_by_expr_with_nulls(expr, sea_order, nulls.into()); + } + None => { + statement.order_by_expr(expr, sea_order); + } + } + } + } + Ok(()) + } +} + +impl From> for OrderByExpr { + fn from(field: FieldRef) -> Self { + OrderByExpr::directional(OrderTarget::Column(field.identifier()), SortOrder::Asc) + } +} + +impl From for OrderByExpr { + fn from(expr: Expr) -> Self { + expr.asc() } } +/// A trait for database types that support sorting. pub trait ExprSort { + /// Sort by this field in ascending order. fn asc(&self) -> OrderByExpr; + /// Sort by this field in descending order. fn desc(&self) -> OrderByExpr; + /// Sorts rows by the position of this field's value fn custom(&self, values: I) -> OrderByExpr where I: IntoIterator, - I::Item: ToDbValue; + I::Item: IntoField; } -impl ExprSort for FieldRef { +impl ExprSort for FieldRef { fn asc(&self) -> OrderByExpr { - OrderByExpr::new(self.identifier(), SortOrder::Asc) + OrderByExpr::directional(OrderTarget::Column(self.identifier()), SortOrder::Asc) } fn desc(&self) -> OrderByExpr { - OrderByExpr::new(self.identifier(), SortOrder::Desc) + OrderByExpr::directional(OrderTarget::Column(self.identifier()), SortOrder::Desc) } fn custom(&self, values: I) -> OrderByExpr where I: IntoIterator, - I::Item: ToDbValue, + I::Item: IntoField, { let values = values .into_iter() - .map(|v| match v.to_db_field_value() { + .map(|v| match v.into_field().to_db_field_value() { DbFieldValue::Value(value) => value, - DbFieldValue::Auto => panic!("Cannot order by a non-value field"), + DbFieldValue::Auto => { + panic!("cannot use an auto-generated value as a custom ordering key") + } }) - .collect::>(); - OrderByExpr::new(self.identifier(), SortOrder::Custom(Values(values))) + .collect(); + OrderByExpr::custom( + OrderTarget::Column(self.identifier()), + sea_query::Values(values), + ) } } From 3100b5419e3ce88a1b1f493b1b17ed5b530504d0 Mon Sep 17 00:00:00 2001 From: Elijah Date: Sat, 5 Sep 2026 00:23:46 +0000 Subject: [PATCH 06/11] tests, tests and tests --- cot/src/common_types.rs | 14 + cot/src/db/query/expr.rs | 16 +- cot/tests/db_testing/query.rs | 817 +-------------------- cot/tests/db_testing/query/like.rs | 300 ++++++++ cot/tests/db_testing/query/order_by.rs | 514 +++++++++++++ cot/tests/db_testing/query/transactions.rs | 524 +++++++++++++ 6 files changed, 1361 insertions(+), 824 deletions(-) create mode 100644 cot/tests/db_testing/query/like.rs create mode 100644 cot/tests/db_testing/query/order_by.rs create mode 100644 cot/tests/db_testing/query/transactions.rs diff --git a/cot/src/common_types.rs b/cot/src/common_types.rs index 0f7980e67..7ae8a8468 100644 --- a/cot/src/common_types.rs +++ b/cot/src/common_types.rs @@ -14,6 +14,8 @@ use cot::db::impl_mysql::MySqlValueRef; use cot::db::impl_postgres::PostgresValueRef; #[cfg(feature = "sqlite")] use cot::db::impl_sqlite::SqliteValueRef; +use cot::db::query::expr::FieldRef; +use cot::db::query::{Expr, ExprAdd}; use cot::form::FormFieldValidationError; use email_address::EmailAddress; use securer_string::SecureString; @@ -464,6 +466,12 @@ impl DatabaseField for Url { #[cfg(feature = "db")] impl TextField for Url {} +impl ExprAdd for FieldRef { + fn add>(self, other: V) -> Expr { + Expr::add(self.as_expr(), Expr::value(other.into())) + } +} + /// A validated email address. /// /// This is a newtype wrapper around [`EmailAddress`] that provides validation @@ -813,6 +821,12 @@ impl Display for Email { #[cfg(feature = "db")] impl TextField for Email {} +impl ExprAdd for FieldRef { + fn add>(self, other: V) -> Expr { + Expr::add(self.as_expr(), Expr::value(other.into())) + } +} + #[cfg(test)] mod tests { use std::convert::TryFrom; diff --git a/cot/src/db/query/expr.rs b/cot/src/db/query/expr.rs index 5d585649b..30758bc00 100644 --- a/cot/src/db/query/expr.rs +++ b/cot/src/db/query/expr.rs @@ -1360,14 +1360,11 @@ impl FieldRef { } } -impl Add> for FieldRef -where - FieldRef: ExprAdd>, -{ +impl Add> for FieldRef { type Output = Expr; - fn add(self, rhs: FieldRef) -> Self::Output { - ExprAdd::add(self, rhs) + fn add(self, rhs: FieldRef) -> Self::Output { + Expr::add(self.as_expr(), rhs.as_expr()) } } @@ -1700,16 +1697,13 @@ impl_num_expr!(f64); impl ExprAdd for FieldRef { fn add>(self, other: V) -> Expr { - Expr::add(Expr::field(self.identifier()), Expr::value(other.into())) + Expr::add(self.as_expr(), Expr::value(other.into())) } } impl ExprAdd>> for FieldRef> { fn add>>>(self, other: V) -> Expr { - Expr::add( - Expr::field(self.identifier()), - Expr::field(other.into().identifier()), - ) + Expr::add(self.as_expr(), other.into().as_expr()) } } #[cfg(test)] diff --git a/cot/tests/db_testing/query.rs b/cot/tests/db_testing/query.rs index fa1b560a2..43a7e98f0 100644 --- a/cot/tests/db_testing/query.rs +++ b/cot/tests/db_testing/query.rs @@ -1,3 +1,7 @@ +mod like; +mod order_by; +mod transactions; + use cot::db::migrations::{Field, Operation}; use cot::db::query::expr::ExprEq; use cot::db::{Auto, Database, DatabaseError, DatabaseField, Identifier, Model}; @@ -509,816 +513,3 @@ async fn bulk_insert_with_fixed_pk(test_db: &mut TestDatabase) { .unwrap(); assert_eq!(model300.name, "test300"); } - -async fn seed(test_db: &TestDatabase, names: &[&str]) { - let mut models: Vec = names - .iter() - .map(|n| TestModel { - id: Auto::auto(), - name: (*n).to_owned(), - }) - .collect(); - TestModel::bulk_insert(&**test_db, &mut models) - .await - .unwrap(); -} - -fn names_of(objects: &[TestModel]) -> Vec<&str> { - objects.iter().map(|o| o.name.as_str()).collect() -} - -#[cot_macros::dbtest] -async fn model_query_contains_case_sensitive(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - seed(test_db, &["foo", "Foo", "fOO", "FOO", "bar"]).await; - - let objects = query!(TestModel, $name.contains("oo")) - .all(&**test_db) - .await - .unwrap(); - assert_eq!(names_of(&objects), vec!["foo", "Foo"]); - - let objects = query!(TestModel, $name.contains("fo")) - .all(&**test_db) - .await - .unwrap(); - assert_eq!(names_of(&objects), vec!["foo"]); - - let objects = query!(TestModel, $name.contains("bar")) - .all(&**test_db) - .await - .unwrap(); - assert_eq!(names_of(&objects), vec!["bar"]); - - let objects = query!(TestModel, $name.contains("xyz")) - .all(&**test_db) - .await - .unwrap(); - assert!(objects.is_empty()); - - let objects = query!(TestModel, $name.contains("")) - .all(&**test_db) - .await - .unwrap(); - assert_eq!(objects.len(), 5); -} - -#[cot_macros::dbtest] -async fn model_query_icontains(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - seed(test_db, &["foo", "Foo", "fOO", "FOO", "bar"]).await; - - let mut objects = query!(TestModel, $name.icontains("OO")) - .all(&**test_db) - .await - .unwrap(); - objects.sort_by_key(|a| a.id.unwrap()); - assert_eq!(names_of(&objects), vec!["foo", "Foo", "fOO", "FOO"]); - - let objects = query!(TestModel, $name.icontains("xyz")) - .all(&**test_db) - .await - .unwrap(); - assert!(objects.is_empty()); -} - -#[cot_macros::dbtest] -async fn model_query_starts_with(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - seed(test_db, &["foobar", "Foobar", "barfoo", "foo"]).await; - - let objects = query!(TestModel, $name.starts_with("foo")) - .all(&**test_db) - .await - .unwrap(); - let mut got = names_of(&objects); - got.sort_unstable(); - assert_eq!(got, vec!["foo", "foobar"]); - - let objects = query!(TestModel, $name.starts_with("bar")) - .all(&**test_db) - .await - .unwrap(); - assert_eq!(names_of(&objects), vec!["barfoo"]); - - let objects = query!(TestModel, $name.starts_with("foobarbaz")) - .all(&**test_db) - .await - .unwrap(); - assert!(objects.is_empty()); -} - -#[cot_macros::dbtest] -async fn model_query_istarts_with(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - seed(test_db, &["foobar", "Foobar", "barfoo"]).await; - - let mut objects = query!(TestModel, $name.istarts_with("FOO")) - .all(&**test_db) - .await - .unwrap(); - objects.sort_by_key(|a| a.id.unwrap()); - assert_eq!(names_of(&objects), vec!["foobar", "Foobar"]); -} - -#[cot_macros::dbtest] -async fn model_query_ends_with(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - seed(test_db, &["report.pdf", "report.PDF", "archive.zip", "pdf"]).await; - - let objects = query!(TestModel, $name.ends_with(".pdf")) - .all(&**test_db) - .await - .unwrap(); - assert_eq!(names_of(&objects), vec!["report.pdf"]); - - let objects = query!(TestModel, $name.ends_with("report.pdf.pdf")) - .all(&**test_db) - .await - .unwrap(); - assert!(objects.is_empty()); -} - -#[cot_macros::dbtest] -async fn model_query_iends_with(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - seed(test_db, &["report.pdf", "report.PDF", "archive.zip"]).await; - - let mut objects = query!(TestModel, $name.iends_with(".PDF")) - .all(&**test_db) - .await - .unwrap(); - objects.sort_by_key(|a| a.id.unwrap()); - assert_eq!(names_of(&objects), vec!["report.pdf", "report.PDF"]); -} - -#[cot_macros::dbtest] -async fn model_query_raw_positional(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - seed(test_db, &["faXo", "fooo", "fo", "faXYo", "f_o"]).await; - - let mut objects = query!(TestModel, $name.raw_like("f??o")) - .all(&**test_db) - .await - .unwrap(); - objects.sort_by_key(|a| a.id.unwrap()); - let mut got = names_of(&objects); - got.sort_unstable(); - assert_eq!(got, vec!["faXo", "fooo"]); -} - -#[cot_macros::dbtest] -async fn model_query_raw_middle_wildcards(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - seed( - test_db, - &[ - "foo_bar_baz", - "foo bar baz extra", - "foobarbaz", - "bar_foo_baz", // wrong order, must not match - ], - ) - .await; - - let objects = query!(TestModel, $name.raw_like("*foo*bar*baz*")) - .all(&**test_db) - .await - .unwrap(); - let mut got = names_of(&objects); - got.sort_unstable(); - assert_eq!(got, vec!["foo bar baz extra", "foo_bar_baz", "foobarbaz"]); -} - -#[cot_macros::dbtest] -async fn model_query_raw_escaped_wildcard(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - seed(test_db, &["a*b", "aXb", "a?b"]).await; - - let objects = query!(TestModel, $name.raw_like("a\\*b")) - .all(&**test_db) - .await - .unwrap(); - assert_eq!(names_of(&objects), vec!["a*b"]); - - let mut objects = query!(TestModel, $name.raw_like("a?b")) - .all(&**test_db) - .await - .unwrap(); - objects.sort_by_key(|a| a.id.unwrap()); - let mut got = names_of(&objects); - got.sort_unstable(); - assert_eq!(got, vec!["a*b", "a?b", "aXb"]); -} - -#[cot_macros::dbtest] -async fn model_query_iraw(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - seed(test_db, &["README", "ReadMe", "readme", "READMEE", "REDME"]).await; - - let mut objects = query!(TestModel, $name.iraw_like("re?dme")) - .all(&**test_db) - .await - .unwrap(); - objects.sort_by_key(|a| a.id.unwrap()); - let mut got = names_of(&objects); - got.sort_unstable(); - assert_eq!(got, vec!["README", "ReadMe", "readme",]); -} - -#[cot_macros::dbtest] -async fn model_query_literal_wildcard_characters_in_data(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - seed(test_db, &["100% off", "under_score", "a*b", "aXb"]).await; - - let objects = query!(TestModel, $name.contains("100% off")) - .all(&**test_db) - .await - .unwrap(); - assert_eq!(names_of(&objects), vec!["100% off"]); - - let objects = query!(TestModel, $name.contains("_score")) - .all(&**test_db) - .await - .unwrap(); - assert_eq!(names_of(&objects), vec!["under_score"]); - - let objects = query!(TestModel, $name.contains("a*b")) - .all(&**test_db) - .await - .unwrap(); - assert_eq!(names_of(&objects), vec!["a*b"]); -} - -#[cot_macros::dbtest] -async fn model_query_unicode_case_sensitive(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - seed( - test_db, - &["café", "CAFÉ", "日本語のテスト", "🎉 party time", "naïve"], - ) - .await; - - let objects = query!(TestModel, $name.contains("café")) - .all(&**test_db) - .await - .unwrap(); - assert_eq!(names_of(&objects), vec!["café"]); - - let objects = query!(TestModel, $name.starts_with("日本")) - .all(&**test_db) - .await - .unwrap(); - assert_eq!(names_of(&objects), vec!["日本語のテスト"]); - - let objects = query!(TestModel, $name.ends_with("time")) - .all(&**test_db) - .await - .unwrap(); - assert_eq!(names_of(&objects), vec!["🎉 party time"]); - - let objects = query!(TestModel, $name.raw_like("na?ve")) - .all(&**test_db) - .await - .unwrap(); - assert_eq!(names_of(&objects), vec!["naïve"]); -} - -#[cot_macros::dbtest] -async fn model_query_contains_combined_with_boolean_ops(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - seed(test_db, &["apple pie", "apple tart", "banana split"]).await; - - let objects = query!(TestModel, $name.contains("apple") && $name.contains("pie")) - .all(&**test_db) - .await - .unwrap(); - assert_eq!(names_of(&objects), vec!["apple pie"]); - let mut objects = query!(TestModel, $name.starts_with("apple") || $name.ends_with("split")) - .all(&**test_db) - .await - .unwrap(); - objects.sort_by_key(|a| a.id.unwrap()); - let mut got = names_of(&objects); - got.sort_unstable(); - - assert_eq!(got, vec!["apple pie", "apple tart", "banana split"]); -} - -#[cot_macros::dbtest] -async fn transaction_commit(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - let db = &**test_db; - - let mut transaction = db.begin().await.unwrap(); - let mut model = TestModel { - id: Auto::auto(), - name: "test".to_string(), - }; - model.insert(&mut transaction).await.unwrap(); - transaction.commit().await.unwrap(); - - let exists = TestModel::objects() - .filter(::Fields::name.eq("test")) - .exists(db) - .await - .unwrap(); - assert!(exists); -} - -#[cot_macros::dbtest] -async fn transaction_rollback(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - let db = &**test_db; - - let mut transaction = db.begin().await.unwrap(); - let mut model = TestModel { - id: Auto::auto(), - name: "test_rollback".to_string(), - }; - model.insert(&mut transaction).await.unwrap(); - transaction.rollback().await.unwrap(); - - let exists = TestModel::objects() - .filter(::Fields::name.eq("test_rollback")) - .exists(db) - .await - .unwrap(); - assert!(!exists); -} - -#[cot_macros::dbtest] -async fn transaction_nested(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - let db = &**test_db; - - let mut transaction = db.begin().await.unwrap(); - let mut outer_model = TestModel { - id: Auto::auto(), - name: "outer".to_string(), - }; - outer_model.insert(&mut transaction).await.unwrap(); - - let mut nested = transaction.begin().await.unwrap(); - let mut inner_model = TestModel { - id: Auto::auto(), - name: "inner".to_string(), - }; - inner_model.insert(&mut nested).await.unwrap(); - nested.rollback().await.unwrap(); - - transaction.commit().await.unwrap(); - - let outer_exists = TestModel::objects() - .filter(::Fields::name.eq("outer")) - .exists(db) - .await - .unwrap(); - assert!(outer_exists); - - let inner_exists = TestModel::objects() - .filter(::Fields::name.eq("inner")) - .exists(db) - .await - .unwrap(); - assert!(!inner_exists); -} - -#[cot_macros::dbtest] -async fn transaction_nested_commit(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - let db = &**test_db; - - let mut transaction = db.begin().await.unwrap(); - let mut outer_model = TestModel { - id: Auto::auto(), - name: "outer".to_string(), - }; - outer_model.insert(&mut transaction).await.unwrap(); - - let mut nested = transaction.begin().await.unwrap(); - let mut inner_model = TestModel { - id: Auto::auto(), - name: "inner".to_string(), - }; - inner_model.insert(&mut nested).await.unwrap(); - // Committing the savepoint releases it into the enclosing transaction. - nested.commit().await.unwrap(); - - // Both rows are visible within the still-open outer transaction. - assert_eq!( - TestModel::objects().count(&mut transaction).await.unwrap(), - 2 - ); - - transaction.commit().await.unwrap(); - - // After committing the outer transaction, both rows are persisted. - for name in ["outer", "inner"] { - assert!( - TestModel::objects() - .filter(::Fields::name.eq(name)) - .exists(db) - .await - .unwrap() - ); - } -} - -#[cot_macros::dbtest] -async fn transaction_nested_outer_rollback(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - let db = &**test_db; - - let mut transaction = db.begin().await.unwrap(); - let mut outer_model = TestModel { - id: Auto::auto(), - name: "outer".to_string(), - }; - outer_model.insert(&mut transaction).await.unwrap(); - - let mut nested = transaction.begin().await.unwrap(); - let mut inner_model = TestModel { - id: Auto::auto(), - name: "inner".to_string(), - }; - inner_model.insert(&mut nested).await.unwrap(); - // Releasing the savepoint doesn't durably persist the nested work; it only - // hands it up to the enclosing transaction. - nested.commit().await.unwrap(); - - // Rolling back the outer transaction discards everything, including the - // work from the already-committed nested transaction. - transaction.rollback().await.unwrap(); - - for name in ["outer", "inner"] { - assert!( - !TestModel::objects() - .filter(::Fields::name.eq(name)) - .exists(db) - .await - .unwrap() - ); - } -} - -#[cot_macros::dbtest] -async fn transaction_insert_or_update(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - let db = &**test_db; - - let mut transaction = db.begin().await.unwrap(); - - // insert_or_update on a new primary key takes the insert path. - let mut model = TestModel { - id: Auto::fixed(1), - name: "inserted".to_string(), - }; - model.save(&mut transaction).await.unwrap(); - assert_eq!( - TestModel::get_by_primary_key(&mut transaction, model.id) - .await - .unwrap() - .unwrap() - .name, - "inserted" - ); - - // insert_or_update on an existing primary key takes the update path. - model.name = "updated".to_string(); - model.save(&mut transaction).await.unwrap(); - assert_eq!( - TestModel::get_by_primary_key(&mut transaction, model.id) - .await - .unwrap() - .unwrap() - .name, - "updated" - ); - - transaction.commit().await.unwrap(); - - let saved = TestModel::get_by_primary_key(db, model.id) - .await - .unwrap() - .unwrap(); - assert_eq!(saved.name, "updated"); -} - -#[cot_macros::dbtest] -async fn transaction_bulk_insert(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - let db = &**test_db; - - let mut transaction = db.begin().await.unwrap(); - let mut models = vec![ - TestModel { - id: Auto::auto(), - name: "bulk1".to_string(), - }, - TestModel { - id: Auto::auto(), - name: "bulk2".to_string(), - }, - ]; - TestModel::bulk_insert(&mut transaction, &mut models) - .await - .unwrap(); - assert!(matches!(models[0].id, Auto::Fixed(_))); - assert!(matches!(models[1].id, Auto::Fixed(_))); - - let count_in_transaction = TestModel::objects().count(&mut transaction).await.unwrap(); - assert_eq!(count_in_transaction, 2); - - transaction.commit().await.unwrap(); - - let objects = TestModel::objects().all(db).await.unwrap(); - let names: Vec<_> = objects.iter().map(|m| m.name.as_str()).collect(); - assert!(names.contains(&"bulk1")); - assert!(names.contains(&"bulk2")); -} - -#[cot_macros::dbtest] -async fn transaction_bulk_insert_or_update(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - let db = &**test_db; - - let mut initial = vec![ - TestModel { - id: Auto::auto(), - name: "initial1".to_string(), - }, - TestModel { - id: Auto::auto(), - name: "initial2".to_string(), - }, - ]; - TestModel::bulk_insert(db, &mut initial).await.unwrap(); - - let mut transaction = db.begin().await.unwrap(); - let mut updates = vec![ - TestModel { - id: initial[0].id, - name: "initial1_updated".to_string(), - }, - TestModel { - id: Auto::fixed(9999), - name: "new".to_string(), - }, - ]; - TestModel::bulk_insert_or_update(&mut transaction, &mut updates) - .await - .unwrap(); - - let names_in_transaction: Vec<_> = TestModel::objects() - .all(&mut transaction) - .await - .unwrap() - .into_iter() - .map(|m| m.name) - .collect(); - assert!(names_in_transaction.contains(&"initial1_updated".to_string())); - assert!(names_in_transaction.contains(&"initial2".to_string())); - assert!(names_in_transaction.contains(&"new".to_string())); - - transaction.commit().await.unwrap(); - - let names: Vec<_> = TestModel::objects() - .all(db) - .await - .unwrap() - .into_iter() - .map(|m| m.name) - .collect(); - assert!(names.contains(&"initial1_updated".to_string())); - assert!(names.contains(&"initial2".to_string())); - assert!(names.contains(&"new".to_string())); -} - -#[cot_macros::dbtest] -async fn transaction_query(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - let db = &**test_db; - - let mut transaction = db.begin().await.unwrap(); - let mut model = TestModel { - id: Auto::auto(), - name: "queried".to_string(), - }; - model.insert(&mut transaction).await.unwrap(); - - // The insert isn't committed yet, so it's only visible through the - // transaction that created it. - let objects = query!(TestModel, $name == "queried") - .all(&mut transaction) - .await - .unwrap(); - assert_eq!(objects.len(), 1); - assert_eq!(objects[0].name, "queried"); - - transaction.rollback().await.unwrap(); - - let objects = query!(TestModel, $name == "queried").all(db).await.unwrap(); - assert!(objects.is_empty()); -} - -#[cot_macros::dbtest] -async fn transaction_exists(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - let db = &**test_db; - - let mut transaction = db.begin().await.unwrap(); - let mut model = TestModel { - id: Auto::auto(), - name: "exists_check".to_string(), - }; - model.insert(&mut transaction).await.unwrap(); - - assert!( - TestModel::objects() - .filter(::Fields::name.eq("exists_check")) - .exists(&mut transaction) - .await - .unwrap() - ); - assert!( - !TestModel::objects() - .filter(::Fields::name.eq("does_not_exist")) - .exists(&mut transaction) - .await - .unwrap() - ); - - transaction.commit().await.unwrap(); - - assert!( - TestModel::objects() - .filter(::Fields::name.eq("exists_check")) - .exists(db) - .await - .unwrap() - ); -} - -#[cot_macros::dbtest] -async fn transaction_count(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - let db = &**test_db; - - let mut transaction = db.begin().await.unwrap(); - for name in ["count1", "count2", "count3"] { - let mut model = TestModel { - id: Auto::auto(), - name: name.to_string(), - }; - model.insert(&mut transaction).await.unwrap(); - } - - assert_eq!( - TestModel::objects().count(&mut transaction).await.unwrap(), - 3 - ); - assert_eq!( - TestModel::objects() - .filter(::Fields::name.eq("count2")) - .count(&mut transaction) - .await - .unwrap(), - 1 - ); - - transaction.commit().await.unwrap(); - - assert_eq!(TestModel::objects().count(db).await.unwrap(), 3); -} - -#[cot_macros::dbtest] -async fn transaction_delete(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - let db = &**test_db; - - let mut models = vec![ - TestModel { - id: Auto::auto(), - name: "keep".to_string(), - }, - TestModel { - id: Auto::auto(), - name: "remove".to_string(), - }, - ]; - TestModel::bulk_insert(db, &mut models).await.unwrap(); - - let mut transaction = db.begin().await.unwrap(); - TestModel::objects() - .filter(::Fields::name.eq("remove")) - .delete(&mut transaction) - .await - .unwrap(); - - // The deletion isn't committed yet, but it's already visible through - // the transaction that performed it. - assert_eq!( - TestModel::objects().count(&mut transaction).await.unwrap(), - 1 - ); - - transaction.commit().await.unwrap(); - - let names: Vec<_> = TestModel::objects() - .all(db) - .await - .unwrap() - .into_iter() - .map(|m| m.name) - .collect(); - assert_eq!(names, vec!["keep".to_string()]); -} - -#[cot_macros::dbtest] -async fn transaction_raw(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - let db = &**test_db; - - let mut transaction = db.begin().await.unwrap(); - let result = transaction - .raw("INSERT INTO cot__test_model (name) VALUES ('raw')") - .await - .unwrap(); - assert_eq!(result.rows_affected().0, 1); - - // The insert is visible within the transaction that performed it. - let objects = transaction - .raw_as::("SELECT * FROM cot__test_model") - .await - .unwrap(); - assert_eq!(objects.len(), 1); - assert_eq!(objects[0].name, "raw"); - - transaction.commit().await.unwrap(); - - let objects = TestModel::objects().all(db).await.unwrap(); - assert_eq!(objects.len(), 1); - assert_eq!(objects[0].name, "raw"); -} - -#[cot_macros::dbtest] -async fn transaction_raw_rollback(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - let db = &**test_db; - - let mut transaction = db.begin().await.unwrap(); - transaction - .raw("INSERT INTO cot__test_model (name) VALUES ('raw_rollback')") - .await - .unwrap(); - transaction.rollback().await.unwrap(); - - assert_eq!(TestModel::objects().count(db).await.unwrap(), 0); -} - -// `raw_with`/`raw_as_with` need bound-parameter placeholders in the SQL text -// itself (`?` on SQLite/MySQL vs. `$1, $2, ...` on PostgreSQL), so a single -// `dbtest` function body can't exercise all three backends. These are -// therefore SQLite-only. - -#[cfg(feature = "sqlite")] -#[cot::test] -#[cfg_attr( - miri, - ignore = "unsupported operation: can't call foreign function `sqlite3_open_v2`" -)] -async fn transaction_raw_with_parameterized() { - let db = TestDatabase::new_sqlite() - .await - .expect("failed to create SQLite test database"); - migrate_test_model(&db).await; - - let mut transaction = db.begin().await.unwrap(); - let params: &[&dyn cot::db::ToDbValue] = &[&"raw_param"]; - let result = transaction - .raw_with("INSERT INTO cot__test_model (name) VALUES (?)", params) - .await - .unwrap(); - assert_eq!(result.rows_affected().0, 1); - - let objects = transaction - .raw_as_with::( - "SELECT * FROM cot__test_model WHERE name = ?", - &[&"raw_param"], - ) - .await - .unwrap(); - assert_eq!(objects.len(), 1); - assert_eq!(objects[0].name, "raw_param"); - - transaction.commit().await.unwrap(); - - let objects = TestModel::objects().all(&*db).await.unwrap(); - assert_eq!(objects.len(), 1); - assert_eq!(objects[0].name, "raw_param"); - - db.cleanup() - .await - .expect("failed to clean up SQLite test database"); -} diff --git a/cot/tests/db_testing/query/like.rs b/cot/tests/db_testing/query/like.rs new file mode 100644 index 000000000..5abda3580 --- /dev/null +++ b/cot/tests/db_testing/query/like.rs @@ -0,0 +1,300 @@ +use cot::db::{Auto, Model}; +use cot::test::TestDatabase; +use cot_macros::query; + +use crate::db_testing::query::{TestModel, migrate_test_model}; + +async fn seed(test_db: &TestDatabase, names: &[&str]) { + let mut models: Vec = names + .iter() + .map(|n| TestModel { + id: Auto::auto(), + name: (*n).to_owned(), + }) + .collect(); + TestModel::bulk_insert(&**test_db, &mut models) + .await + .unwrap(); +} + +fn names_of(objects: &[TestModel]) -> Vec<&str> { + objects.iter().map(|o| o.name.as_str()).collect() +} + +#[cot_macros::dbtest] +async fn model_query_contains_case_sensitive(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed(test_db, &["foo", "Foo", "fOO", "FOO", "bar"]).await; + + let objects = query!(TestModel, $name.contains("oo")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["foo", "Foo"]); + + let objects = query!(TestModel, $name.contains("fo")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["foo"]); + + let objects = query!(TestModel, $name.contains("bar")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["bar"]); + + let objects = query!(TestModel, $name.contains("xyz")) + .all(&**test_db) + .await + .unwrap(); + assert!(objects.is_empty()); + + let objects = query!(TestModel, $name.contains("")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(objects.len(), 5); +} + +#[cot_macros::dbtest] +async fn model_query_icontains(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed(test_db, &["foo", "Foo", "fOO", "FOO", "bar"]).await; + + let mut objects = query!(TestModel, $name.icontains("OO")) + .all(&**test_db) + .await + .unwrap(); + objects.sort_by_key(|a| a.id.unwrap()); + assert_eq!(names_of(&objects), vec!["foo", "Foo", "fOO", "FOO"]); + + let objects = query!(TestModel, $name.icontains("xyz")) + .all(&**test_db) + .await + .unwrap(); + assert!(objects.is_empty()); +} + +#[cot_macros::dbtest] +async fn model_query_starts_with(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed(test_db, &["foobar", "Foobar", "barfoo", "foo"]).await; + + let objects = query!(TestModel, $name.starts_with("foo")) + .all(&**test_db) + .await + .unwrap(); + let mut got = names_of(&objects); + got.sort_unstable(); + assert_eq!(got, vec!["foo", "foobar"]); + + let objects = query!(TestModel, $name.starts_with("bar")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["barfoo"]); + + let objects = query!(TestModel, $name.starts_with("foobarbaz")) + .all(&**test_db) + .await + .unwrap(); + assert!(objects.is_empty()); +} + +#[cot_macros::dbtest] +async fn model_query_istarts_with(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed(test_db, &["foobar", "Foobar", "barfoo"]).await; + + let mut objects = query!(TestModel, $name.istarts_with("FOO")) + .all(&**test_db) + .await + .unwrap(); + objects.sort_by_key(|a| a.id.unwrap()); + assert_eq!(names_of(&objects), vec!["foobar", "Foobar"]); +} + +#[cot_macros::dbtest] +async fn model_query_ends_with(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed(test_db, &["report.pdf", "report.PDF", "archive.zip", "pdf"]).await; + + let objects = query!(TestModel, $name.ends_with(".pdf")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["report.pdf"]); + + let objects = query!(TestModel, $name.ends_with("report.pdf.pdf")) + .all(&**test_db) + .await + .unwrap(); + assert!(objects.is_empty()); +} + +#[cot_macros::dbtest] +async fn model_query_iends_with(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed(test_db, &["report.pdf", "report.PDF", "archive.zip"]).await; + + let mut objects = query!(TestModel, $name.iends_with(".PDF")) + .all(&**test_db) + .await + .unwrap(); + objects.sort_by_key(|a| a.id.unwrap()); + assert_eq!(names_of(&objects), vec!["report.pdf", "report.PDF"]); +} + +#[cot_macros::dbtest] +async fn model_query_raw_positional(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed(test_db, &["faXo", "fooo", "fo", "faXYo", "f_o"]).await; + + let mut objects = query!(TestModel, $name.raw_like("f??o")) + .all(&**test_db) + .await + .unwrap(); + objects.sort_by_key(|a| a.id.unwrap()); + let mut got = names_of(&objects); + got.sort_unstable(); + assert_eq!(got, vec!["faXo", "fooo"]); +} + +#[cot_macros::dbtest] +async fn model_query_raw_middle_wildcards(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed( + test_db, + &[ + "foo_bar_baz", + "foo bar baz extra", + "foobarbaz", + "bar_foo_baz", // wrong order, must not match + ], + ) + .await; + + let objects = query!(TestModel, $name.raw_like("*foo*bar*baz*")) + .all(&**test_db) + .await + .unwrap(); + let mut got = names_of(&objects); + got.sort_unstable(); + assert_eq!(got, vec!["foo bar baz extra", "foo_bar_baz", "foobarbaz"]); +} + +#[cot_macros::dbtest] +async fn model_query_raw_escaped_wildcard(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed(test_db, &["a*b", "aXb", "a?b"]).await; + + let objects = query!(TestModel, $name.raw_like("a\\*b")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["a*b"]); + + let mut objects = query!(TestModel, $name.raw_like("a?b")) + .all(&**test_db) + .await + .unwrap(); + objects.sort_by_key(|a| a.id.unwrap()); + let mut got = names_of(&objects); + got.sort_unstable(); + assert_eq!(got, vec!["a*b", "a?b", "aXb"]); +} + +#[cot_macros::dbtest] +async fn model_query_iraw(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed(test_db, &["README", "ReadMe", "readme", "READMEE", "REDME"]).await; + + let mut objects = query!(TestModel, $name.iraw_like("re?dme")) + .all(&**test_db) + .await + .unwrap(); + objects.sort_by_key(|a| a.id.unwrap()); + let mut got = names_of(&objects); + got.sort_unstable(); + assert_eq!(got, vec!["README", "ReadMe", "readme",]); +} + +#[cot_macros::dbtest] +async fn model_query_literal_wildcard_characters_in_data(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed(test_db, &["100% off", "under_score", "a*b", "aXb"]).await; + + let objects = query!(TestModel, $name.contains("100% off")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["100% off"]); + + let objects = query!(TestModel, $name.contains("_score")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["under_score"]); + + let objects = query!(TestModel, $name.contains("a*b")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["a*b"]); +} + +#[cot_macros::dbtest] +async fn model_query_unicode_case_sensitive(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed( + test_db, + &["café", "CAFÉ", "日本語のテスト", "🎉 party time", "naïve"], + ) + .await; + + let objects = query!(TestModel, $name.contains("café")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["café"]); + + let objects = query!(TestModel, $name.starts_with("日本")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["日本語のテスト"]); + + let objects = query!(TestModel, $name.ends_with("time")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["🎉 party time"]); + + let objects = query!(TestModel, $name.raw_like("na?ve")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["naïve"]); +} + +#[cot_macros::dbtest] +async fn model_query_contains_combined_with_boolean_ops(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed(test_db, &["apple pie", "apple tart", "banana split"]).await; + + let objects = query!(TestModel, $name.contains("apple") && $name.contains("pie")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["apple pie"]); + let mut objects = query!(TestModel, $name.starts_with("apple") || $name.ends_with("split")) + .all(&**test_db) + .await + .unwrap(); + objects.sort_by_key(|a| a.id.unwrap()); + let mut got = names_of(&objects); + got.sort_unstable(); + + assert_eq!(got, vec!["apple pie", "apple tart", "banana split"]); +} diff --git a/cot/tests/db_testing/query/order_by.rs b/cot/tests/db_testing/query/order_by.rs new file mode 100644 index 000000000..17aeadcbc --- /dev/null +++ b/cot/tests/db_testing/query/order_by.rs @@ -0,0 +1,514 @@ +use cot::db::migrations::{Field, Operation}; +use cot::db::query::expr::ExprSort; +use cot::db::{Auto, Database, DatabaseField, Identifier, Model}; +use cot::test::TestDatabase; +use cot_macros::{model, query}; + +#[derive(Debug, PartialEq, Clone)] +#[model] +struct OrderTestModel { + #[model(primary_key)] + id: Auto, + category: String, + priority: i32, + x: i32, + y: i32, + score: Option, +} + +async fn migrate_order_test_model(db: &Database) { + CREATE_ORDER_TEST_MODEL.forwards(db).await.unwrap(); +} + +const CREATE_ORDER_TEST_MODEL: Operation = Operation::create_model() + .table_name(Identifier::new("cot__order_test_model")) + .fields(&[ + Field::new(Identifier::new("id"), as DatabaseField>::TYPE) + .primary_key() + .auto(), + Field::new(Identifier::new("category"), ::TYPE), + Field::new(Identifier::new("priority"), ::TYPE), + Field::new(Identifier::new("x"), ::TYPE), + Field::new(Identifier::new("y"), ::TYPE), + Field::new( + Identifier::new("score"), + as DatabaseField>::TYPE, + ) + .null(), + ]) + .build(); + +async fn seed_order_test_model( + test_db: &TestDatabase, + rows: &[(&str, i32, i32, i32, Option)], +) { + let mut models: Vec = rows + .iter() + .map(|(category, priority, x, y, score)| OrderTestModel { + id: Auto::auto(), + category: (*category).to_owned(), + priority: *priority, + x: *x, + y: *y, + score: *score, + }) + .collect(); + OrderTestModel::bulk_insert(&**test_db, &mut models) + .await + .unwrap(); +} + +fn categories_of(objects: &[OrderTestModel]) -> Vec<&str> { + objects.iter().map(|o| o.category.as_str()).collect() +} + +#[cot_macros::dbtest] +async fn order_by_single_field_ascending(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + seed_order_test_model( + test_db, + &[ + ("banana", 1, 0, 0, None), + ("apple", 1, 0, 0, None), + ("cherry", 1, 0, 0, None), + ], + ) + .await; + + let objects = OrderTestModel::objects() + .order_by([::Fields::category.asc()]) + .all(&**test_db) + .await + .unwrap(); + + assert_eq!(categories_of(&objects), vec!["apple", "banana", "cherry"]); +} + +#[cot_macros::dbtest] +async fn order_by_single_field_descending(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + seed_order_test_model( + test_db, + &[ + ("banana", 1, 0, 0, None), + ("apple", 1, 0, 0, None), + ("cherry", 1, 0, 0, None), + ], + ) + .await; + + let objects = OrderTestModel::objects() + .order_by([::Fields::category.desc()]) + .all(&**test_db) + .await + .unwrap(); + + assert_eq!(categories_of(&objects), vec!["cherry", "banana", "apple"]); +} + +#[cot_macros::dbtest] +async fn order_by_bare_field_defaults_to_ascending(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + seed_order_test_model( + test_db, + &[("banana", 1, 0, 0, None), ("apple", 1, 0, 0, None)], + ) + .await; + + let objects = OrderTestModel::objects() + .order_by([::Fields::category]) + .all(&**test_db) + .await + .unwrap(); + + assert_eq!(categories_of(&objects), vec!["apple", "banana"]); +} + +#[cot_macros::dbtest] +async fn order_by_multiple_columns_breaks_ties(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + seed_order_test_model( + test_db, + &[ + ("fruit", 2, 0, 0, None), + ("fruit", 1, 0, 0, None), + ("veg", 1, 0, 0, None), + ("fruit", 3, 0, 0, None), + ], + ) + .await; + + // category ASC, then priority DESC within each category. + let objects = OrderTestModel::objects() + .order_by([ + ::Fields::category.asc(), + ::Fields::priority.desc(), + ]) + .all(&**test_db) + .await + .unwrap(); + + let got: Vec<_> = objects + .iter() + .map(|o| (o.category.as_str(), o.priority)) + .collect(); + assert_eq!( + got, + vec![("fruit", 3), ("fruit", 2), ("fruit", 1), ("veg", 1)] + ); +} + +#[cot_macros::dbtest] +async fn order_by_expression_ascending(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + seed_order_test_model( + test_db, + &[ + ("a", 1, 5, 5, None), // sum = 10 + ("b", 1, 1, 1, None), // sum = 2 + ("c", 1, 3, 3, None), // sum = 6 + ], + ) + .await; + + let objects = OrderTestModel::objects() + .order_by([ + (::Fields::x + ::Fields::y).asc(), + ]) + .all(&**test_db) + .await + .unwrap(); + + assert_eq!(categories_of(&objects), vec!["b", "c", "a"]); +} + +#[cot_macros::dbtest] +async fn order_by_expression_descending(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + seed_order_test_model( + test_db, + &[ + ("a", 1, 5, 5, None), + ("b", 1, 1, 1, None), + ("c", 1, 3, 3, None), + ], + ) + .await; + + let objects = OrderTestModel::objects() + .order_by([ + (::Fields::x + ::Fields::y).desc(), + ]) + .all(&**test_db) + .await + .unwrap(); + + assert_eq!(categories_of(&objects), vec!["a", "c", "b"]); +} + +#[cot_macros::dbtest] +async fn order_by_bare_expression_defaults_to_ascending(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + seed_order_test_model(test_db, &[("a", 1, 5, 5, None), ("b", 1, 1, 1, None)]).await; + + let objects = OrderTestModel::objects() + .order_by([::Fields::x + ::Fields::y]) + .all(&**test_db) + .await + .unwrap(); + + assert_eq!(categories_of(&objects), vec!["b", "a"]); +} + +#[cot_macros::dbtest] +async fn order_by_mixed_column_and_expression_terms(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + seed_order_test_model( + test_db, + &[ + ("fruit", 1, 2, 2, None), // sum = 4 + ("fruit", 1, 1, 1, None), // sum = 2 + ("veg", 1, 0, 0, None), // sum = 0 + ], + ) + .await; + + let objects = OrderTestModel::objects() + .order_by([ + ::Fields::category.asc(), + (::Fields::x + ::Fields::y).desc(), + ]) + .all(&**test_db) + .await + .unwrap(); + + let got: Vec<_> = objects + .iter() + .map(|o| (o.category.as_str(), o.x + o.y)) + .collect(); + assert_eq!(got, vec![("fruit", 4), ("fruit", 2), ("veg", 0)]); +} + +#[cot_macros::dbtest] +async fn order_by_nulls_first_with_ascending(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + seed_order_test_model( + test_db, + &[ + ("a", 1, 0, 0, Some(2)), + ("b", 1, 0, 0, None), + ("c", 1, 0, 0, Some(1)), + ], + ) + .await; + + let objects = OrderTestModel::objects() + .order_by([::Fields::score.asc().nulls_first()]) + .all(&**test_db) + .await + .unwrap(); + + let scores: Vec<_> = objects.iter().map(|o| o.score).collect(); + assert_eq!(scores, vec![None, Some(1), Some(2)]); +} + +#[cot_macros::dbtest] +async fn order_by_nulls_last_with_ascending(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + seed_order_test_model( + test_db, + &[ + ("a", 1, 0, 0, Some(2)), + ("b", 1, 0, 0, None), + ("c", 1, 0, 0, Some(1)), + ], + ) + .await; + + let objects = OrderTestModel::objects() + .order_by([::Fields::score.asc().nulls_last()]) + .all(&**test_db) + .await + .unwrap(); + + let scores: Vec<_> = objects.iter().map(|o| o.score).collect(); + assert_eq!(scores, vec![Some(1), Some(2), None]); +} + +#[cot_macros::dbtest] +async fn order_by_nulls_first_with_descending(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + seed_order_test_model( + test_db, + &[ + ("a", 1, 0, 0, Some(2)), + ("b", 1, 0, 0, None), + ("c", 1, 0, 0, Some(1)), + ], + ) + .await; + + let objects = OrderTestModel::objects() + .order_by([::Fields::score + .desc() + .nulls_first()]) + .all(&**test_db) + .await + .unwrap(); + + let scores: Vec<_> = objects.iter().map(|o| o.score).collect(); + assert_eq!(scores, vec![None, Some(2), Some(1)]); +} + +#[cot_macros::dbtest] +async fn order_by_nulls_last_with_descending(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + seed_order_test_model( + test_db, + &[ + ("a", 1, 0, 0, Some(2)), + ("b", 1, 0, 0, None), + ("c", 1, 0, 0, Some(1)), + ], + ) + .await; + + let objects = OrderTestModel::objects() + .order_by([::Fields::score.desc().nulls_last()]) + .all(&**test_db) + .await + .unwrap(); + + let scores: Vec<_> = objects.iter().map(|o| o.score).collect(); + assert_eq!(scores, vec![Some(2), Some(1), None]); +} + +#[cot_macros::dbtest] +async fn order_by_custom_ranking(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + seed_order_test_model( + test_db, + &[ + ("apple", 1, 0, 0, None), + ("banana", 1, 0, 0, None), + ("cherry", 1, 0, 0, None), + ], + ) + .await; + + // Rank explicitly as cherry, apple, banana regardless of alphabetic + // or insertion order. + let objects = OrderTestModel::objects() + .order_by([ + ::Fields::category.custom(["cherry", "apple", "banana"]) + ]) + .all(&**test_db) + .await + .unwrap(); + + assert_eq!(categories_of(&objects), vec!["cherry", "apple", "banana"]); +} + +#[cot_macros::dbtest] +async fn order_by_custom_ranking_partial_list_keeps_remaining_rows(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + seed_order_test_model( + test_db, + &[ + ("apple", 1, 0, 0, None), + ("banana", 1, 0, 0, None), + ("cherry", 1, 0, 0, None), + ], + ) + .await; + + // Only rank "banana" explicitly. the rest keep arbitrary (but present) + // positions after it. + let objects = OrderTestModel::objects() + .order_by([::Fields::category.custom(["banana"])]) + .all(&**test_db) + .await + .unwrap(); + + assert_eq!(objects.len(), 3); + let mut got = categories_of(&objects); + got.sort_unstable(); + assert_eq!(got, vec!["apple", "banana", "cherry"]); +} + +#[cot_macros::dbtest] +async fn order_by_combined_with_filter(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + seed_order_test_model( + test_db, + &[ + ("fruit", 3, 0, 0, None), + ("fruit", 1, 0, 0, None), + ("veg", 5, 0, 0, None), + ("fruit", 2, 0, 0, None), + ], + ) + .await; + + let objects = query!(OrderTestModel, $category == "fruit") + .order_by([::Fields::priority.desc()]) + .all(&**test_db) + .await + .unwrap(); + + let priorities: Vec<_> = objects.iter().map(|o| o.priority).collect(); + assert_eq!(priorities, vec![3, 2, 1]); +} + +#[cot_macros::dbtest] +async fn order_by_combined_with_limit_and_offset(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + seed_order_test_model( + test_db, + &[ + ("a", 5, 0, 0, None), + ("b", 3, 0, 0, None), + ("c", 4, 0, 0, None), + ("d", 1, 0, 0, None), + ("e", 2, 0, 0, None), + ], + ) + .await; + + let objects = OrderTestModel::objects() + .order_by([::Fields::priority.asc()]) + .limit(2) + .offset(1) + .all(&**test_db) + .await + .unwrap(); + + let priorities: Vec<_> = objects.iter().map(|o| o.priority).collect(); + assert_eq!(priorities, vec![2, 3]); +} + +#[cot_macros::dbtest] +async fn order_by_within_uncommitted_transaction(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + let db = &**test_db; + + let mut transaction = db.begin().await.unwrap(); + let mut models = vec![ + OrderTestModel { + id: Auto::auto(), + category: "c".to_owned(), + priority: 3, + x: 0, + y: 0, + score: None, + }, + OrderTestModel { + id: Auto::auto(), + category: "a".to_owned(), + priority: 1, + x: 0, + y: 0, + score: None, + }, + OrderTestModel { + id: Auto::auto(), + category: "b".to_owned(), + priority: 2, + x: 0, + y: 0, + score: None, + }, + ]; + OrderTestModel::bulk_insert(&mut transaction, &mut models) + .await + .unwrap(); + + let objects = OrderTestModel::objects() + .order_by([::Fields::category.asc()]) + .all(&mut transaction) + .await + .unwrap(); + assert_eq!(categories_of(&objects), vec!["a", "b", "c"]); + + transaction.commit().await.unwrap(); + + let objects = OrderTestModel::objects() + .order_by([::Fields::category.desc()]) + .all(db) + .await + .unwrap(); + assert_eq!(categories_of(&objects), vec!["c", "b", "a"]); +} + +#[cot_macros::dbtest] +async fn order_by_empty_table_returns_empty(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + + let objects = OrderTestModel::objects() + .order_by([::Fields::priority.asc()]) + .all(&**test_db) + .await + .unwrap(); + + assert!(objects.is_empty()); +} diff --git a/cot/tests/db_testing/query/transactions.rs b/cot/tests/db_testing/query/transactions.rs new file mode 100644 index 000000000..b3c0ff75f --- /dev/null +++ b/cot/tests/db_testing/query/transactions.rs @@ -0,0 +1,524 @@ +use cot::db::query::expr::ExprEq; +use cot::db::{Auto, Model}; +use cot::test::TestDatabase; +use cot_macros::query; + +use crate::db_testing::query::{TestModel, migrate_test_model}; + +#[cot_macros::dbtest] +async fn transaction_commit(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + let db = &**test_db; + + let mut transaction = db.begin().await.unwrap(); + let mut model = TestModel { + id: Auto::auto(), + name: "test".to_string(), + }; + model.insert(&mut transaction).await.unwrap(); + transaction.commit().await.unwrap(); + + let exists = TestModel::objects() + .filter(::Fields::name.eq("test")) + .exists(db) + .await + .unwrap(); + assert!(exists); +} + +#[cot_macros::dbtest] +async fn transaction_rollback(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + let db = &**test_db; + + let mut transaction = db.begin().await.unwrap(); + let mut model = TestModel { + id: Auto::auto(), + name: "test_rollback".to_string(), + }; + model.insert(&mut transaction).await.unwrap(); + transaction.rollback().await.unwrap(); + + let exists = TestModel::objects() + .filter(::Fields::name.eq("test_rollback")) + .exists(db) + .await + .unwrap(); + assert!(!exists); +} + +#[cot_macros::dbtest] +async fn transaction_nested(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + let db = &**test_db; + + let mut transaction = db.begin().await.unwrap(); + let mut outer_model = TestModel { + id: Auto::auto(), + name: "outer".to_string(), + }; + outer_model.insert(&mut transaction).await.unwrap(); + + let mut nested = transaction.begin().await.unwrap(); + let mut inner_model = TestModel { + id: Auto::auto(), + name: "inner".to_string(), + }; + inner_model.insert(&mut nested).await.unwrap(); + nested.rollback().await.unwrap(); + + transaction.commit().await.unwrap(); + + let outer_exists = TestModel::objects() + .filter(::Fields::name.eq("outer")) + .exists(db) + .await + .unwrap(); + assert!(outer_exists); + + let inner_exists = TestModel::objects() + .filter(::Fields::name.eq("inner")) + .exists(db) + .await + .unwrap(); + assert!(!inner_exists); +} + +#[cot_macros::dbtest] +async fn transaction_nested_commit(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + let db = &**test_db; + + let mut transaction = db.begin().await.unwrap(); + let mut outer_model = TestModel { + id: Auto::auto(), + name: "outer".to_string(), + }; + outer_model.insert(&mut transaction).await.unwrap(); + + let mut nested = transaction.begin().await.unwrap(); + let mut inner_model = TestModel { + id: Auto::auto(), + name: "inner".to_string(), + }; + inner_model.insert(&mut nested).await.unwrap(); + // Committing the savepoint releases it into the enclosing transaction. + nested.commit().await.unwrap(); + + // Both rows are visible within the still-open outer transaction. + assert_eq!( + TestModel::objects().count(&mut transaction).await.unwrap(), + 2 + ); + + transaction.commit().await.unwrap(); + + // After committing the outer transaction, both rows are persisted. + for name in ["outer", "inner"] { + assert!( + TestModel::objects() + .filter(::Fields::name.eq(name)) + .exists(db) + .await + .unwrap() + ); + } +} + +#[cot_macros::dbtest] +async fn transaction_nested_outer_rollback(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + let db = &**test_db; + + let mut transaction = db.begin().await.unwrap(); + let mut outer_model = TestModel { + id: Auto::auto(), + name: "outer".to_string(), + }; + outer_model.insert(&mut transaction).await.unwrap(); + + let mut nested = transaction.begin().await.unwrap(); + let mut inner_model = TestModel { + id: Auto::auto(), + name: "inner".to_string(), + }; + inner_model.insert(&mut nested).await.unwrap(); + // Releasing the savepoint doesn't durably persist the nested work; it only + // hands it up to the enclosing transaction. + nested.commit().await.unwrap(); + + // Rolling back the outer transaction discards everything, including the + // work from the already-committed nested transaction. + transaction.rollback().await.unwrap(); + + for name in ["outer", "inner"] { + assert!( + !TestModel::objects() + .filter(::Fields::name.eq(name)) + .exists(db) + .await + .unwrap() + ); + } +} + +#[cot_macros::dbtest] +async fn transaction_insert_or_update(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + let db = &**test_db; + + let mut transaction = db.begin().await.unwrap(); + + // insert_or_update on a new primary key takes the insert path. + let mut model = TestModel { + id: Auto::fixed(1), + name: "inserted".to_string(), + }; + model.save(&mut transaction).await.unwrap(); + assert_eq!( + TestModel::get_by_primary_key(&mut transaction, model.id) + .await + .unwrap() + .unwrap() + .name, + "inserted" + ); + + // insert_or_update on an existing primary key takes the update path. + model.name = "updated".to_string(); + model.save(&mut transaction).await.unwrap(); + assert_eq!( + TestModel::get_by_primary_key(&mut transaction, model.id) + .await + .unwrap() + .unwrap() + .name, + "updated" + ); + + transaction.commit().await.unwrap(); + + let saved = TestModel::get_by_primary_key(db, model.id) + .await + .unwrap() + .unwrap(); + assert_eq!(saved.name, "updated"); +} + +#[cot_macros::dbtest] +async fn transaction_bulk_insert(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + let db = &**test_db; + + let mut transaction = db.begin().await.unwrap(); + let mut models = vec![ + TestModel { + id: Auto::auto(), + name: "bulk1".to_string(), + }, + TestModel { + id: Auto::auto(), + name: "bulk2".to_string(), + }, + ]; + TestModel::bulk_insert(&mut transaction, &mut models) + .await + .unwrap(); + assert!(matches!(models[0].id, Auto::Fixed(_))); + assert!(matches!(models[1].id, Auto::Fixed(_))); + + let count_in_transaction = TestModel::objects().count(&mut transaction).await.unwrap(); + assert_eq!(count_in_transaction, 2); + + transaction.commit().await.unwrap(); + + let objects = TestModel::objects().all(db).await.unwrap(); + let names: Vec<_> = objects.iter().map(|m| m.name.as_str()).collect(); + assert!(names.contains(&"bulk1")); + assert!(names.contains(&"bulk2")); +} + +#[cot_macros::dbtest] +async fn transaction_bulk_insert_or_update(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + let db = &**test_db; + + let mut initial = vec![ + TestModel { + id: Auto::auto(), + name: "initial1".to_string(), + }, + TestModel { + id: Auto::auto(), + name: "initial2".to_string(), + }, + ]; + TestModel::bulk_insert(db, &mut initial).await.unwrap(); + + let mut transaction = db.begin().await.unwrap(); + let mut updates = vec![ + TestModel { + id: initial[0].id, + name: "initial1_updated".to_string(), + }, + TestModel { + id: Auto::fixed(9999), + name: "new".to_string(), + }, + ]; + TestModel::bulk_insert_or_update(&mut transaction, &mut updates) + .await + .unwrap(); + + let names_in_transaction: Vec<_> = TestModel::objects() + .all(&mut transaction) + .await + .unwrap() + .into_iter() + .map(|m| m.name) + .collect(); + assert!(names_in_transaction.contains(&"initial1_updated".to_string())); + assert!(names_in_transaction.contains(&"initial2".to_string())); + assert!(names_in_transaction.contains(&"new".to_string())); + + transaction.commit().await.unwrap(); + + let names: Vec<_> = TestModel::objects() + .all(db) + .await + .unwrap() + .into_iter() + .map(|m| m.name) + .collect(); + assert!(names.contains(&"initial1_updated".to_string())); + assert!(names.contains(&"initial2".to_string())); + assert!(names.contains(&"new".to_string())); +} + +#[cot_macros::dbtest] +async fn transaction_query(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + let db = &**test_db; + + let mut transaction = db.begin().await.unwrap(); + let mut model = TestModel { + id: Auto::auto(), + name: "queried".to_string(), + }; + model.insert(&mut transaction).await.unwrap(); + + // The insert isn't committed yet, so it's only visible through the + // transaction that created it. + let objects = query!(TestModel, $name == "queried") + .all(&mut transaction) + .await + .unwrap(); + assert_eq!(objects.len(), 1); + assert_eq!(objects[0].name, "queried"); + + transaction.rollback().await.unwrap(); + + let objects = query!(TestModel, $name == "queried").all(db).await.unwrap(); + assert!(objects.is_empty()); +} + +#[cot_macros::dbtest] +async fn transaction_exists(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + let db = &**test_db; + + let mut transaction = db.begin().await.unwrap(); + let mut model = TestModel { + id: Auto::auto(), + name: "exists_check".to_string(), + }; + model.insert(&mut transaction).await.unwrap(); + + assert!( + TestModel::objects() + .filter(::Fields::name.eq("exists_check")) + .exists(&mut transaction) + .await + .unwrap() + ); + assert!( + !TestModel::objects() + .filter(::Fields::name.eq("does_not_exist")) + .exists(&mut transaction) + .await + .unwrap() + ); + + transaction.commit().await.unwrap(); + + assert!( + TestModel::objects() + .filter(::Fields::name.eq("exists_check")) + .exists(db) + .await + .unwrap() + ); +} + +#[cot_macros::dbtest] +async fn transaction_count(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + let db = &**test_db; + + let mut transaction = db.begin().await.unwrap(); + for name in ["count1", "count2", "count3"] { + let mut model = TestModel { + id: Auto::auto(), + name: name.to_string(), + }; + model.insert(&mut transaction).await.unwrap(); + } + + assert_eq!( + TestModel::objects().count(&mut transaction).await.unwrap(), + 3 + ); + assert_eq!( + TestModel::objects() + .filter(::Fields::name.eq("count2")) + .count(&mut transaction) + .await + .unwrap(), + 1 + ); + + transaction.commit().await.unwrap(); + + assert_eq!(TestModel::objects().count(db).await.unwrap(), 3); +} + +#[cot_macros::dbtest] +async fn transaction_delete(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + let db = &**test_db; + + let mut models = vec![ + TestModel { + id: Auto::auto(), + name: "keep".to_string(), + }, + TestModel { + id: Auto::auto(), + name: "remove".to_string(), + }, + ]; + TestModel::bulk_insert(db, &mut models).await.unwrap(); + + let mut transaction = db.begin().await.unwrap(); + TestModel::objects() + .filter(::Fields::name.eq("remove")) + .delete(&mut transaction) + .await + .unwrap(); + + // The deletion isn't committed yet, but it's already visible through + // the transaction that performed it. + assert_eq!( + TestModel::objects().count(&mut transaction).await.unwrap(), + 1 + ); + + transaction.commit().await.unwrap(); + + let names: Vec<_> = TestModel::objects() + .all(db) + .await + .unwrap() + .into_iter() + .map(|m| m.name) + .collect(); + assert_eq!(names, vec!["keep".to_string()]); +} + +#[cot_macros::dbtest] +async fn transaction_raw(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + let db = &**test_db; + + let mut transaction = db.begin().await.unwrap(); + let result = transaction + .raw("INSERT INTO cot__test_model (name) VALUES ('raw')") + .await + .unwrap(); + assert_eq!(result.rows_affected().0, 1); + + // The insert is visible within the transaction that performed it. + let objects = transaction + .raw_as::("SELECT * FROM cot__test_model") + .await + .unwrap(); + assert_eq!(objects.len(), 1); + assert_eq!(objects[0].name, "raw"); + + transaction.commit().await.unwrap(); + + let objects = TestModel::objects().all(db).await.unwrap(); + assert_eq!(objects.len(), 1); + assert_eq!(objects[0].name, "raw"); +} + +#[cot_macros::dbtest] +async fn transaction_raw_rollback(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + let db = &**test_db; + + let mut transaction = db.begin().await.unwrap(); + transaction + .raw("INSERT INTO cot__test_model (name) VALUES ('raw_rollback')") + .await + .unwrap(); + transaction.rollback().await.unwrap(); + + assert_eq!(TestModel::objects().count(db).await.unwrap(), 0); +} + +// `raw_with`/`raw_as_with` need bound-parameter placeholders in the SQL text +// itself (`?` on SQLite/MySQL vs. `$1, $2, ...` on PostgreSQL), so a single +// `dbtest` function body can't exercise all three backends. These are +// therefore SQLite-only. + +#[cfg(feature = "sqlite")] +#[cot::test] +#[cfg_attr( + miri, + ignore = "unsupported operation: can't call foreign function `sqlite3_open_v2`" +)] +async fn transaction_raw_with_parameterized() { + let db = TestDatabase::new_sqlite() + .await + .expect("failed to create SQLite test database"); + migrate_test_model(&db).await; + + let mut transaction = db.begin().await.unwrap(); + let params: &[&dyn cot::db::ToDbValue] = &[&"raw_param"]; + let result = transaction + .raw_with("INSERT INTO cot__test_model (name) VALUES (?)", params) + .await + .unwrap(); + assert_eq!(result.rows_affected().0, 1); + + let objects = transaction + .raw_as_with::( + "SELECT * FROM cot__test_model WHERE name = ?", + &[&"raw_param"], + ) + .await + .unwrap(); + assert_eq!(objects.len(), 1); + assert_eq!(objects[0].name, "raw_param"); + + transaction.commit().await.unwrap(); + + let objects = TestModel::objects().all(&*db).await.unwrap(); + assert_eq!(objects.len(), 1); + assert_eq!(objects[0].name, "raw_param"); + + db.cleanup() + .await + .expect("failed to clean up SQLite test database"); +} From d25dbe093cd575416671c1fd424bfcba149738c8 Mon Sep 17 00:00:00 2001 From: Elijah Date: Sat, 5 Sep 2026 01:14:27 +0000 Subject: [PATCH 07/11] docs initial draft. needs more work --- cot/src/db/query.rs | 10 ++- cot/src/db/query/expr/order_by.rs | 3 +- docs/databases/queries.md | 115 ++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 4 deletions(-) diff --git a/cot/src/db/query.rs b/cot/src/db/query.rs index 268d072c6..dee6ee19f 100644 --- a/cot/src/db/query.rs +++ b/cot/src/db/query.rs @@ -173,16 +173,20 @@ impl Query { /// /// ``` /// use cot::db::model; - /// use cot::db::query::{Expr, Query}; + /// use cot::db::query::{ExprSort, Query}; /// /// #[model] /// struct User { /// #[model(primary_key)] /// id: i32, - /// age: i32, + /// name: String, /// } /// - /// let query = Query::::new().order_by(User::age, Order::Asc); // or Order::Desc + /// let mut query = Query::::new(); + /// query.order_by([ + /// ::Fields::id.asc(), + /// ::Fields::name.desc().nulls_first(), + /// ]); /// ``` pub fn order_by(&mut self, order_by: I) -> &mut Self where diff --git a/cot/src/db/query/expr/order_by.rs b/cot/src/db/query/expr/order_by.rs index 19562ab50..ecd47fc84 100644 --- a/cot/src/db/query/expr/order_by.rs +++ b/cot/src/db/query/expr/order_by.rs @@ -1,3 +1,4 @@ +//! Database expression for order_by use cot::db::{DbFieldValue, ToDbFieldValue}; use crate::db::Identifier; @@ -129,7 +130,7 @@ impl OrderByExpr { /// /// # Panics /// - /// Panics if this term was built with [`ExprSort::custom_order`]. A + /// Panics if this term was built with [`ExprSort::custom`]. A /// custom-order term never produces a `NULL` sort key, /// so an explicit `NULLS` placement on top of it can never have any /// effect. diff --git a/docs/databases/queries.md b/docs/databases/queries.md index c541eb3dd..8f3334f3e 100644 --- a/docs/databases/queries.md +++ b/docs/databases/queries.md @@ -420,6 +420,121 @@ async fn search_customers_with_expr(db: Database) -> cot::Result<()> { For the complete list of pattern-matching methods, their case-insensitive counterparts, and the glob pattern syntax used by `raw_like`, see the [`Expr`](enum@cot::db::query::expr::Expr) and [`ExprLike`](trait@cot::db::query::expr::ExprLike) docs. +## Ordering results + +Cot lets you control the order rows come back in with the [`order_by`](struct@cot::db::query::Query#method.order_by) method on [`Query`](struct@cot::db::query::Query). Call it with a list of ordering terms built from a model's `Fields`. + +```rust +use cot::db::Database; +use cot::db::Model; + +# #[model] #[derive(Debug)] struct Customer { #[model(primary_key)] id: Auto, #[model(unique)] email: cot::common_types::Email, full_name: LimitedString<128>, is_verified: bool } +async fn get_customers_by_name(db: Database) -> cot::Result<()> { + let customers = Customer::objects() + .order_by([::Fields::full_name.asc()]) + .all(&db) + .await?; + println!("Customers: {:?}", customers); +# Ok(()) +} +``` + +The example above sorts customers alphabetically by `full_name`. Use [`desc`](trait@cot::db::query::expr::ExprSort#method.desc) instead of `asc` to reverse the order. If you pass a bare field without calling `asc` or `desc` on it, it defaults to ascending order. + +```rust +# use cot::db::Model; +# #[model] #[derive(Debug)] struct Customer { #[model(primary_key)] id: Auto, full_name: LimitedString<128> } +# async fn foo(db: &cot::db::Database) -> cot::Result<()> { +let customers = Customer::objects() + .order_by([::Fields::full_name]) + .all(db) + .await?; +# Ok(()) +# } +``` + +`order_by` works alongside `filter`, `limit`, and `offset`, and combines cleanly with the `query!` macro too, since it all returns a `Query`. + +### Ordering by multiple fields + +Pass more than one term to `order_by` to break ties. Terms are applied in the order given, so the first term is the primary sort and later ones only kick in when earlier ones tie. + +```rust +# use cot::db::Model; +# #[model] #[derive(Debug)] struct Order { #[model(primary_key)] id: Auto, is_fulfilled: bool, quantity: i32 } +# async fn foo(db: &cot::db::Database) -> cot::Result<()> { +let orders = Order::objects() + .order_by([ + ::Fields::is_fulfilled.asc(), + ::Fields::quantity.desc(), + ]) + .all(db) + .await?; +# Ok(()) +# } +``` + +This sorts unfulfilled orders first, and within each fulfillment status, sorts by quantity descending. + +### Controlling where NULLs land + +If a field is nullable, use [`nulls_first`](struct@cot::db::query::expr::OrderByExpr#method.nulls_first) or [`nulls_last`](struct@cot::db::query::expr::OrderByExpr#method.nulls_last) to pin down where `NULL` values show up, regardless of database backend or sort direction. + +```rust +# use cot::db::Model; +# #[model] #[derive(Debug)] struct Product { #[model(primary_key)] id: Auto, price_cents: i64, stock: Option } +# async fn foo(db: &cot::db::Database) -> cot::Result<()> { +let products = Product::objects() + .order_by([::Fields::stock.asc().nulls_last()]) + .all(db) + .await?; +# Ok(()) +# } +``` + +Without `nulls_first`/`nulls_last`, where `NULL`s land is left to the database's default behavior, which differs between backends. + +### Ordering by an expression + +You're not limited to ordering by a single column. Any [`Expr`](enum@cot::db::query::expr::Expr) can be turned into an ordering term by calling `asc()` or `desc()` on it directly, which is handy for computed values like sums. + +```rust +# use cot::db::Model; +# use cot::db::query::expr::Expr; +# #[model] #[derive(Debug)] struct Order { #[model(primary_key)] id: Auto, quantity: i32, price_cents: i64 } +# async fn foo(db: &cot::db::Database) -> cot::Result<()> { +let orders = Order::objects() + .order_by([ + (::Fields::quantity + ::Fields::price_cents).desc(), + ]) + .all(db) + .await?; +# Ok(()) +# } +``` + +This sorts orders by `quantity + price_cents`, largest first. + +### Custom ranking + +Sometimes alphabetical or numeric order isn't what you want, you want a specific, hand-picked order instead. The [`custom`](trait@cot::db::query::expr::ExprSort#method.custom) method lets you rank rows by matching a field against a list of values you provide, in the order you give them. + +```rust +# use cot::db::Model; +# #[model] #[derive(Debug)] struct Product { #[model(primary_key)] id: Auto, sku: LimitedString<64> } +# async fn foo(db: &cot::db::Database) -> cot::Result<()> { +// Featured products first, in this exact order, regardless of their SKU +// or insertion order. +let products = Product::objects() + .order_by([::Fields::sku.custom(["SKU-003", "SKU-001", "SKU-002"])]) + .all(db) + .await?; +# Ok(()) +# } +``` + +Rows whose value isn't in the list still come back, they just end up somewhere after the ranked ones, in no guaranteed order. `nulls_first`/`nulls_last` can't be combined with `custom`, since a custom-ranked term never produces a `NULL` sort key in the first place. + ## Removing an object The [`delete`](struct@cot::db::query::Query#method.delete) method can be used to remove an object from the database. The example below shows how to remove a `Customer` instance with the primary key of `5`. From e88dc1c50d25616bada8ea824bf7609539e44d9d Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 8 Sep 2026 03:26:07 +0000 Subject: [PATCH 08/11] improve tests and docs, and more improvements --- cot/src/db.rs | 3 - cot/src/db/query.rs | 2 +- cot/src/db/query/expr.rs | 123 +++++++++++++-- cot/src/db/query/expr/order_by.rs | 53 ++++--- cot/tests/db_testing/query/order_by.rs | 202 ++++++++++++++++++++++++- docs/databases/queries.md | 48 +++--- 6 files changed, 369 insertions(+), 62 deletions(-) diff --git a/cot/src/db.rs b/cot/src/db.rs index fbc0298c7..8b1d7088c 100644 --- a/cot/src/db.rs +++ b/cot/src/db.rs @@ -1022,9 +1022,6 @@ pub trait FromDbValue { /// An alias for the value type internally used by the [`sea_query`] crate. pub type DbValue = sea_query::Value; -/// An alias for the values type internally used the [`sea_query`] crate. -pub type DbValues = sea_query::Values; - /// A trait for converting a Rust value to a database value. pub trait ToDbValue: Send + Sync { /// Converts the Rust value to a `sea_query` value. diff --git a/cot/src/db/query.rs b/cot/src/db/query.rs index dee6ee19f..f959df44d 100644 --- a/cot/src/db/query.rs +++ b/cot/src/db/query.rs @@ -8,7 +8,7 @@ use derive_more::with_trait::Debug; use thiserror::Error; use crate::db; -pub use crate::db::query::expr::{Expr, ExprAdd, ExprDiv, ExprMul, ExprOrd, ExprSub}; +pub use crate::db::query::expr::{Expr, ExprAdd, ExprDiv, ExprMul, ExprOrd, ExprSub, ExprSort}; use crate::db::query::expr::{OrderByExpr, SqlQueryBuilder}; use crate::db::{Auto, DatabaseBackend, ForeignKey, Model, StatementResult, ToDbFieldValue}; const ERROR_PREFIX: &str = "expression error:"; diff --git a/cot/src/db/query/expr.rs b/cot/src/db/query/expr.rs index 30758bc00..6148faa41 100644 --- a/cot/src/db/query/expr.rs +++ b/cot/src/db/query/expr.rs @@ -3,7 +3,7 @@ pub mod like; mod order_by; use std::marker::PhantomData; -use std::ops::Add; +use std::ops::{Add, Div, Mul, Sub}; use cot::db::query::{IntoField, QueryBuildingError}; use cot::db::{DbFieldValue, DbValue, FromDbValue, Identifier, LimitedString, ToDbFieldValue}; @@ -1203,36 +1203,84 @@ impl Expr { Self::RawLike(Box::new(lhs), Box::new(rhs), CaseSensitivity::Insensitive) } - /// Builds an ascending `ORDER BY` term from this expression. See the - /// note on [`Query::filter`](crate::db::query::Query::filter) about - /// `Expr` not being restricted to field references — the same applies - /// here; ordering by a boolean-producing expression is legal SQL but - /// rarely what you want. + /// Builds an ascending `ORDER BY` term from an expression, with `NULL`s + /// sorted last by default. + /// + /// # Examples + /// + /// ``` + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// use cot::db::{model, query}; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// filename: String, + /// } + /// + /// let _ = Expr::field("filename").asc(); + /// ``` #[must_use] pub fn asc(self) -> OrderByExpr { OrderByExpr::directional(OrderTarget::Expression(self), SortOrder::Asc) } - /// The descending counterpart of [`Self::asc`]. + /// Builds a descending `ORDER BY` term from an expression, with `NULL`s + /// sorted first by default. + /// + /// # Examples + /// + /// ``` + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// use cot::db::{model, query}; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// filename: String, + /// } + /// + /// let _ = Expr::field("filename").desc(); + /// ``` #[must_use] pub fn desc(self) -> OrderByExpr { OrderByExpr::directional(OrderTarget::Expression(self), SortOrder::Desc) } - /// The [`ExprSort::custom`]-equivalent for a compound expression. + /// Order an expression based on the position of the provided field values + /// + /// # Examples + /// + /// ``` + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// use cot::db::{ToDbValue, model, query}; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// filename: String, + /// } /// - /// Takes plain [`ToDbValue`] items rather than [`IntoField`]: unlike - /// [`FieldRef`], a general `Expr` isn't associated with one Rust - /// field type to convert against, so there's no `T` for `IntoField` - /// to key off of. + /// let _ = Expr::field("filename").field_value(vec![ + /// "foo".to_string(), + /// "bar".to_string(), + /// "baz".to_string(), + /// ]); + /// ``` #[must_use] - pub fn custom(self, values: I) -> OrderByExpr + pub fn field_value(self, values: I) -> OrderByExpr where I: IntoIterator, I::Item: ToDbValue, { let values = values.into_iter().map(|v| v.to_db_value()).collect(); - OrderByExpr::custom(OrderTarget::Expression(self), sea_query::Values(values)) + OrderByExpr::field_value(OrderTarget::Expression(self), sea_query::Values(values)) } /// Returns the expression as a [`sea_query::SimpleExpr`]. @@ -1368,6 +1416,30 @@ impl Add> for FieldRef { } } +impl Sub> for FieldRef { + type Output = Expr; + + fn sub(self, rhs: FieldRef) -> Self::Output { + Expr::sub(self.as_expr(), rhs.as_expr()) + } +} + +impl Mul> for FieldRef { + type Output = Expr; + + fn mul(self, rhs: FieldRef) -> Self::Output { + Expr::mul(self.as_expr(), rhs.as_expr()) + } +} + +impl Div> for FieldRef { + type Output = Expr; + + fn div(self, rhs: FieldRef) -> Self::Output { + Expr::div(self.as_expr(), rhs.as_expr()) + } +} + /// A trait for types that can be compared in database expressions. pub trait ExprEq { /// Creates an expression that checks if the field is equal to the given @@ -1697,12 +1769,14 @@ impl_num_expr!(f64); impl ExprAdd for FieldRef { fn add>(self, other: V) -> Expr { + // TODO: use Expr::concat instead Expr::add(self.as_expr(), Expr::value(other.into())) } } impl ExprAdd>> for FieldRef> { fn add>>>(self, other: V) -> Expr { + // TODO: use Expr::concat instead Expr::add(self.as_expr(), other.into().as_expr()) } } @@ -1757,4 +1831,25 @@ mod test { test_expr_constructor!(expr_sub, Sub, sub); test_expr_constructor!(expr_mul, Mul, mul); test_expr_constructor!(expr_div, Div, div); + + #[test] + fn field_ref_sub_operator_builds_sub_expr() { + let x: FieldRef = FieldRef::new(Identifier::new("x")); + let y: FieldRef = FieldRef::new(Identifier::new("y")); + assert!(matches!(x - y, Expr::Sub(_, _))); + } + + #[test] + fn field_ref_mul_operator_builds_mul_expr() { + let x: FieldRef = FieldRef::new(Identifier::new("x")); + let y: FieldRef = FieldRef::new(Identifier::new("y")); + assert!(matches!(x * y, Expr::Mul(_, _))); + } + + #[test] + fn field_ref_div_operator_builds_div_expr() { + let x: FieldRef = FieldRef::new(Identifier::new("x")); + let y: FieldRef = FieldRef::new(Identifier::new("y")); + assert!(matches!(x / y, Expr::Div(_, _))); + } } diff --git a/cot/src/db/query/expr/order_by.rs b/cot/src/db/query/expr/order_by.rs index ecd47fc84..a6bdcff9a 100644 --- a/cot/src/db/query/expr/order_by.rs +++ b/cot/src/db/query/expr/order_by.rs @@ -1,4 +1,4 @@ -//! Database expression for order_by +//! Database ORDER BY clause. use cot::db::{DbFieldValue, ToDbFieldValue}; use crate::db::Identifier; @@ -71,11 +71,15 @@ pub(crate) enum OrderTarget { #[derive(Debug, Clone, PartialEq)] enum OrderMode { - Directional { - order: SortOrder, - nulls: Option, - }, - Custom(sea_query::Values), + Directional { order: SortOrder, nulls: NullsOrder }, + FieldValue(sea_query::Values), +} + +fn default_nulls_for(order: SortOrder) -> NullsOrder { + match order { + SortOrder::Asc => NullsOrder::Last, + SortOrder::Desc => NullsOrder::First, + } } /// An `ORDER BY` term. @@ -84,7 +88,9 @@ enum OrderMode { /// /// ``` /// use cot::db::model; -/// use cot::db::query::{ExprSort, Query}; +/// use cot::db::query::Query; +/// +/// use crate::cot::db::query::expr::ExprSort; /// /// #[model] /// struct User { @@ -110,18 +116,21 @@ impl OrderByExpr { pub(crate) fn directional(target: OrderTarget, order: SortOrder) -> Self { Self { target, - mode: OrderMode::Directional { order, nulls: None }, + mode: OrderMode::Directional { + order, + nulls: default_nulls_for(order), + }, } } - pub(crate) fn custom(target: OrderTarget, values: sea_query::Values) -> Self { + pub(crate) fn field_value(target: OrderTarget, values: sea_query::Values) -> Self { assert!( !values.0.is_empty(), - "`custom` requires at least one value to rank by" + "`field_value` requires at least one value to rank by" ); Self { target, - mode: OrderMode::Custom(values), + mode: OrderMode::FieldValue(values), } } @@ -130,8 +139,8 @@ impl OrderByExpr { /// /// # Panics /// - /// Panics if this term was built with [`ExprSort::custom`]. A - /// custom-order term never produces a `NULL` sort key, + /// Panics if this term was built with [`ExprSort::field_value`]. A + /// field_value term never produces a `NULL` sort key, /// so an explicit `NULLS` placement on top of it can never have any /// effect. #[must_use] @@ -154,9 +163,9 @@ impl OrderByExpr { #[track_caller] fn set_nulls(&mut self, nulls: NullsOrder) { match &mut self.mode { - OrderMode::Directional { nulls: n, .. } => *n = Some(nulls), - OrderMode::Custom(_) => panic!( - "`nulls_first`/`nulls_last` can't be combined with `custom`: a custom-order \ + OrderMode::Directional { nulls: n, .. } => *n = nulls, + OrderMode::FieldValue(_) => panic!( + "`nulls_first`/`nulls_last` can't be combined with `field_value`: a field_value \ term never produces a NULL sort key, so an explicit NULLS placement would \ have no effect" ), @@ -169,8 +178,8 @@ impl OrderByExpr { sql_builder: &dyn SqlQueryBuilder, ) -> Result<(), QueryBuildingError> { let (sea_order, nulls): (sea_query::Order, Option) = match &self.mode { - OrderMode::Directional { order, nulls } => (order.into(), *nulls), - OrderMode::Custom(values) => (sea_query::Order::Field(values.clone()), None), + OrderMode::Directional { order, nulls } => (order.into(), Some(*nulls)), + OrderMode::FieldValue(values) => (sea_query::Order::Field(values.clone()), None), }; match &self.target { @@ -218,7 +227,7 @@ pub trait ExprSort { fn desc(&self) -> OrderByExpr; /// Sorts rows by the position of this field's value - fn custom(&self, values: I) -> OrderByExpr + fn field_value(&self, values: I) -> OrderByExpr where I: IntoIterator, I::Item: IntoField; @@ -233,7 +242,7 @@ impl ExprSort for FieldRef { OrderByExpr::directional(OrderTarget::Column(self.identifier()), SortOrder::Desc) } - fn custom(&self, values: I) -> OrderByExpr + fn field_value(&self, values: I) -> OrderByExpr where I: IntoIterator, I::Item: IntoField, @@ -243,11 +252,11 @@ impl ExprSort for FieldRef { .map(|v| match v.into_field().to_db_field_value() { DbFieldValue::Value(value) => value, DbFieldValue::Auto => { - panic!("cannot use an auto-generated value as a custom ordering key") + panic!("cannot use an auto-generated value as a field value ordering key") } }) .collect(); - OrderByExpr::custom( + OrderByExpr::field_value( OrderTarget::Column(self.identifier()), sea_query::Values(values), ) diff --git a/cot/tests/db_testing/query/order_by.rs b/cot/tests/db_testing/query/order_by.rs index 17aeadcbc..a7b34fd78 100644 --- a/cot/tests/db_testing/query/order_by.rs +++ b/cot/tests/db_testing/query/order_by.rs @@ -249,6 +249,78 @@ async fn order_by_mixed_column_and_expression_terms(test_db: &mut TestDatabase) assert_eq!(got, vec![("fruit", 4), ("fruit", 2), ("veg", 0)]); } +#[cot_macros::dbtest] +async fn order_by_expression_subtraction_ascending(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + seed_order_test_model( + test_db, + &[ + ("a", 1, 5, 2, None), // x - y = 3 + ("b", 1, 1, 4, None), // x - y = -3 + ("c", 1, 3, 3, None), // x - y = 0 + ], + ) + .await; + + let objects = OrderTestModel::objects() + .order_by([ + (::Fields::x - ::Fields::y).asc(), + ]) + .all(&**test_db) + .await + .unwrap(); + + assert_eq!(categories_of(&objects), vec!["b", "c", "a"]); +} + +#[cot_macros::dbtest] +async fn order_by_expression_multiplication_ascending(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + seed_order_test_model( + test_db, + &[ + ("a", 1, 2, 3, None), // x * y = 6 + ("b", 1, 4, 1, None), // x * y = 4 + ("c", 1, 1, 1, None), // x * y = 1 + ], + ) + .await; + + let objects = OrderTestModel::objects() + .order_by([ + (::Fields::x * ::Fields::y).asc(), + ]) + .all(&**test_db) + .await + .unwrap(); + + assert_eq!(categories_of(&objects), vec!["c", "b", "a"]); +} + +#[cot_macros::dbtest] +async fn order_by_expression_division_ascending(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + seed_order_test_model( + test_db, + &[ + ("a", 1, 10, 2, None), // x / y = 5 + ("b", 1, 9, 3, None), // x / y = 3 + ("c", 1, 8, 4, None), // x / y = 2 + ], + ) + .await; + + let objects = OrderTestModel::objects() + .order_by([ + (::Fields::x / ::Fields::y).asc(), + ]) + .all(&**test_db) + .await + .unwrap(); + + assert_eq!(categories_of(&objects), vec!["c", "b", "a"]); +} + #[cot_macros::dbtest] async fn order_by_nulls_first_with_ascending(test_db: &mut TestDatabase) { migrate_order_test_model(&*test_db).await; @@ -295,6 +367,75 @@ async fn order_by_nulls_last_with_ascending(test_db: &mut TestDatabase) { assert_eq!(scores, vec![Some(1), Some(2), None]); } +#[cot_macros::dbtest] +async fn order_by_asc_default_nulls_last(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + seed_order_test_model( + test_db, + &[ + ("a", 1, 0, 0, Some(2)), + ("b", 1, 0, 0, None), + ("c", 1, 0, 0, Some(1)), + ], + ) + .await; + + let objects = OrderTestModel::objects() + .order_by([::Fields::score.asc()]) + .all(&**test_db) + .await + .unwrap(); + + let scores: Vec<_> = objects.iter().map(|o| o.score).collect(); + assert_eq!(scores, vec![Some(1), Some(2), None]); +} + +#[cot_macros::dbtest] +async fn order_by_bare_field_defaults_to_nulls_last(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + seed_order_test_model( + test_db, + &[ + ("a", 1, 0, 0, Some(2)), + ("b", 1, 0, 0, None), + ("c", 1, 0, 0, Some(1)), + ], + ) + .await; + + let objects = OrderTestModel::objects() + .order_by([::Fields::score]) + .all(&**test_db) + .await + .unwrap(); + + let scores: Vec<_> = objects.iter().map(|o| o.score).collect(); + assert_eq!(scores, vec![Some(1), Some(2), None]); +} + +#[cot_macros::dbtest] +async fn order_by_bare_expression_defaults_to_nulls_last(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + seed_order_test_model( + test_db, + &[ + ("a", 1, 0, 0, Some(2)), + ("b", 1, 0, 0, None), + ("c", 1, 0, 0, Some(1)), + ], + ) + .await; + + let objects = OrderTestModel::objects() + .order_by([::Fields::score.as_expr()]) + .all(&**test_db) + .await + .unwrap(); + + let scores: Vec<_> = objects.iter().map(|o| o.score).collect(); + assert_eq!(scores, vec![Some(1), Some(2), None]); +} + #[cot_macros::dbtest] async fn order_by_nulls_first_with_descending(test_db: &mut TestDatabase) { migrate_order_test_model(&*test_db).await; @@ -344,7 +485,30 @@ async fn order_by_nulls_last_with_descending(test_db: &mut TestDatabase) { } #[cot_macros::dbtest] -async fn order_by_custom_ranking(test_db: &mut TestDatabase) { +async fn order_by_desc_default_nulls_first(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + seed_order_test_model( + test_db, + &[ + ("a", 1, 0, 0, Some(2)), + ("b", 1, 0, 0, None), + ("c", 1, 0, 0, Some(1)), + ], + ) + .await; + + let objects = OrderTestModel::objects() + .order_by([::Fields::score.desc()]) + .all(&**test_db) + .await + .unwrap(); + + let scores: Vec<_> = objects.iter().map(|o| o.score).collect(); + assert_eq!(scores, vec![None, Some(2), Some(1)]); +} + +#[cot_macros::dbtest] +async fn order_by_field_value_ranking(test_db: &mut TestDatabase) { migrate_order_test_model(&*test_db).await; seed_order_test_model( test_db, @@ -360,7 +524,7 @@ async fn order_by_custom_ranking(test_db: &mut TestDatabase) { // or insertion order. let objects = OrderTestModel::objects() .order_by([ - ::Fields::category.custom(["cherry", "apple", "banana"]) + ::Fields::category.field_value(["cherry", "apple", "banana"]) ]) .all(&**test_db) .await @@ -370,7 +534,9 @@ async fn order_by_custom_ranking(test_db: &mut TestDatabase) { } #[cot_macros::dbtest] -async fn order_by_custom_ranking_partial_list_keeps_remaining_rows(test_db: &mut TestDatabase) { +async fn order_by_field_value_ranking_partial_list_keeps_remaining_rows( + test_db: &mut TestDatabase, +) { migrate_order_test_model(&*test_db).await; seed_order_test_model( test_db, @@ -385,7 +551,7 @@ async fn order_by_custom_ranking_partial_list_keeps_remaining_rows(test_db: &mut // Only rank "banana" explicitly. the rest keep arbitrary (but present) // positions after it. let objects = OrderTestModel::objects() - .order_by([::Fields::category.custom(["banana"])]) + .order_by([::Fields::category.field_value(["banana"])]) .all(&**test_db) .await .unwrap(); @@ -512,3 +678,31 @@ async fn order_by_empty_table_returns_empty(test_db: &mut TestDatabase) { assert!(objects.is_empty()); } + +#[test] +#[should_panic(expected = "requires at least one value to rank by")] +fn field_value_panics_on_empty_values() { + let _ = ::Fields::category.field_value(Vec::<&str>::new()); +} + +#[test] +#[should_panic(expected = "cannot use an auto-generated value as a field value ordering key")] +fn field_value_panics_on_auto_generated_value() { + let _ = ::Fields::id.field_value([Auto::auto()]); +} + +#[test] +#[should_panic(expected = "can't be combined with `field_value`")] +fn nulls_first_panics_after_field_value() { + let _ = ::Fields::category + .field_value(["a"]) + .nulls_first(); +} + +#[test] +#[should_panic(expected = "can't be combined with `field_value`")] +fn nulls_last_panics_after_field_value() { + let _ = ::Fields::category + .field_value(["a"]) + .nulls_last(); +} diff --git a/docs/databases/queries.md b/docs/databases/queries.md index 8f3334f3e..e8a1ffaca 100644 --- a/docs/databases/queries.md +++ b/docs/databases/queries.md @@ -420,13 +420,13 @@ async fn search_customers_with_expr(db: Database) -> cot::Result<()> { For the complete list of pattern-matching methods, their case-insensitive counterparts, and the glob pattern syntax used by `raw_like`, see the [`Expr`](enum@cot::db::query::expr::Expr) and [`ExprLike`](trait@cot::db::query::expr::ExprLike) docs. -## Ordering results - -Cot lets you control the order rows come back in with the [`order_by`](struct@cot::db::query::Query#method.order_by) method on [`Query`](struct@cot::db::query::Query). Call it with a list of ordering terms built from a model's `Fields`. +### Ordering results +The [`order_by`](struct@cot::db::query::Query#method.order_by) method allows you to control the order in which rows are returned in the query results. It takes in a list of column types or expressions, each of which carries its own sort direction. ```rust use cot::db::Database; use cot::db::Model; +use cot::db::query::expr::ExprSort; # #[model] #[derive(Debug)] struct Customer { #[model(primary_key)] id: Auto, #[model(unique)] email: cot::common_types::Email, full_name: LimitedString<128>, is_verified: bool } async fn get_customers_by_name(db: Database) -> cot::Result<()> { @@ -439,13 +439,15 @@ async fn get_customers_by_name(db: Database) -> cot::Result<()> { } ``` -The example above sorts customers alphabetically by `full_name`. Use [`desc`](trait@cot::db::query::expr::ExprSort#method.desc) instead of `asc` to reverse the order. If you pass a bare field without calling `asc` or `desc` on it, it defaults to ascending order. +The example above sorts customers alphabetically by `full_name`. Use [`desc`](trait@cot::db::query::expr::ExprSort#method.desc) instead of [`asc`](trait@cot::db::query::expr::ExprSort#method.asc) to reverse the order. By default, the sort direction on a field is `asc` if unspecified. ```rust # use cot::db::Model; +# use cot::db::query::expr::ExprSort; # #[model] #[derive(Debug)] struct Customer { #[model(primary_key)] id: Auto, full_name: LimitedString<128> } # async fn foo(db: &cot::db::Database) -> cot::Result<()> { let customers = Customer::objects() + // sorts in ascending order by default .order_by([::Fields::full_name]) .all(db) .await?; @@ -453,14 +455,13 @@ let customers = Customer::objects() # } ``` -`order_by` works alongside `filter`, `limit`, and `offset`, and combines cleanly with the `query!` macro too, since it all returns a `Query`. - -### Ordering by multiple fields +#### Ordering by multiple fields -Pass more than one term to `order_by` to break ties. Terms are applied in the order given, so the first term is the primary sort and later ones only kick in when earlier ones tie. +You can pass in multiple terms to `order_by` to sort by multiple fields. Terms are applied in the order given, so the first term is the primary sort and later ones only kick in when earlier ones tie. ```rust # use cot::db::Model; +# use cot::db::query::expr::ExprSort; # #[model] #[derive(Debug)] struct Order { #[model(primary_key)] id: Auto, is_fulfilled: bool, quantity: i32 } # async fn foo(db: &cot::db::Database) -> cot::Result<()> { let orders = Order::objects() @@ -476,12 +477,15 @@ let orders = Order::objects() This sorts unfulfilled orders first, and within each fulfillment status, sorts by quantity descending. -### Controlling where NULLs land +#### Handling NULL values -If a field is nullable, use [`nulls_first`](struct@cot::db::query::expr::OrderByExpr#method.nulls_first) or [`nulls_last`](struct@cot::db::query::expr::OrderByExpr#method.nulls_last) to pin down where `NULL` values show up, regardless of database backend or sort direction. +When a column contains `NULL` values, you can use [`nulls_first`](trait@cot::db::query::expr::ExprSort#method.nulls_first) or [`nulls_last`](trait@cot::db::query::expr::ExprSort#method.nulls_last) methods to control where `NULL` values show up in the sort order. +The `nulls_first` method puts `NULL` values at the beginning of the sort order before non-null values, while `nulls_last` puts them at the end. The default behavior of null ordering is determined by the order in which the terms are sorted. If the order is `asc`, the +null ordering defaults to `nulls_last`, and if the order is `desc`, the null ordering defaults to `nulls_first`. ```rust # use cot::db::Model; +# use cot::db::query::expr::ExprSort; # #[model] #[derive(Debug)] struct Product { #[model(primary_key)] id: Auto, price_cents: i64, stock: Option } # async fn foo(db: &cot::db::Database) -> cot::Result<()> { let products = Product::objects() @@ -492,15 +496,14 @@ let products = Product::objects() # } ``` -Without `nulls_first`/`nulls_last`, where `NULL`s land is left to the database's default behavior, which differs between backends. - -### Ordering by an expression +#### Ordering by an expression You're not limited to ordering by a single column. Any [`Expr`](enum@cot::db::query::expr::Expr) can be turned into an ordering term by calling `asc()` or `desc()` on it directly, which is handy for computed values like sums. ```rust # use cot::db::Model; # use cot::db::query::expr::Expr; +# use cot::db::query::expr::ExprSort; # #[model] #[derive(Debug)] struct Order { #[model(primary_key)] id: Auto, quantity: i32, price_cents: i64 } # async fn foo(db: &cot::db::Database) -> cot::Result<()> { let orders = Order::objects() @@ -515,25 +518,34 @@ let orders = Order::objects() This sorts orders by `quantity + price_cents`, largest first. -### Custom ranking +#### Ordering by custom field values -Sometimes alphabetical or numeric order isn't what you want, you want a specific, hand-picked order instead. The [`custom`](trait@cot::db::query::expr::ExprSort#method.custom) method lets you rank rows by matching a field against a list of values you provide, in the order you give them. +Sometimes alphabetical or numeric order isn't what you want, you want a specific, hand-picked order instead. The [`field_value`](trait@cot::db::query::expr::ExprSort#method.field_value) method lets you rank rows by matching a field against a list of values you provide, in the order you give them. This is analogous to the `ORDER BY FIELD` clause in database engines like MySQL. ```rust -# use cot::db::Model; +# use cot::db::{Model, LimitedString}; +# use cot::db::query::expr::ExprSort; +# use cot::db::query::IntoField; # #[model] #[derive(Debug)] struct Product { #[model(primary_key)] id: Auto, sku: LimitedString<64> } # async fn foo(db: &cot::db::Database) -> cot::Result<()> { // Featured products first, in this exact order, regardless of their SKU // or insertion order. +let values = vec![ + LimitedString::new("SKU-001").unwrap(), + LimitedString::new("SKU-002").unwrap(), + LimitedString::new("SKU-003").unwrap() +]; let products = Product::objects() - .order_by([::Fields::sku.custom(["SKU-003", "SKU-001", "SKU-002"])]) + .order_by( + [::Fields::sku.field_value(values)] + ) .all(db) .await?; # Ok(()) # } ``` -Rows whose value isn't in the list still come back, they just end up somewhere after the ranked ones, in no guaranteed order. `nulls_first`/`nulls_last` can't be combined with `custom`, since a custom-ranked term never produces a `NULL` sort key in the first place. +Rows whose value isn't in the list still come back, they just end up somewhere after the ranked ones, in no guaranteed order. Keep in mind that `nulls_first`/`nulls_last` ordering can't be combined with `field_value` option, since a field-value-ranked term never produces a `NULL` sort key in the first place. ## Removing an object The [`delete`](struct@cot::db::query::Query#method.delete) method can be used to remove an object from the database. The example below shows how to remove a `Customer` instance with the primary key of `5`. From f32ac80fac392265e33e7cbfdf22936bcd52c0ef Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 03:34:23 +0000 Subject: [PATCH 09/11] chore(pre-commit.ci): auto fixes from pre-commit hooks --- cot/src/db/query.rs | 2 +- docs/databases/queries.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cot/src/db/query.rs b/cot/src/db/query.rs index f959df44d..855c7301f 100644 --- a/cot/src/db/query.rs +++ b/cot/src/db/query.rs @@ -8,7 +8,7 @@ use derive_more::with_trait::Debug; use thiserror::Error; use crate::db; -pub use crate::db::query::expr::{Expr, ExprAdd, ExprDiv, ExprMul, ExprOrd, ExprSub, ExprSort}; +pub use crate::db::query::expr::{Expr, ExprAdd, ExprDiv, ExprMul, ExprOrd, ExprSort, ExprSub}; use crate::db::query::expr::{OrderByExpr, SqlQueryBuilder}; use crate::db::{Auto, DatabaseBackend, ForeignKey, Model, StatementResult, ToDbFieldValue}; const ERROR_PREFIX: &str = "expression error:"; diff --git a/docs/databases/queries.md b/docs/databases/queries.md index e8a1ffaca..d2315e03d 100644 --- a/docs/databases/queries.md +++ b/docs/databases/queries.md @@ -479,8 +479,8 @@ This sorts unfulfilled orders first, and within each fulfillment status, sorts b #### Handling NULL values -When a column contains `NULL` values, you can use [`nulls_first`](trait@cot::db::query::expr::ExprSort#method.nulls_first) or [`nulls_last`](trait@cot::db::query::expr::ExprSort#method.nulls_last) methods to control where `NULL` values show up in the sort order. -The `nulls_first` method puts `NULL` values at the beginning of the sort order before non-null values, while `nulls_last` puts them at the end. The default behavior of null ordering is determined by the order in which the terms are sorted. If the order is `asc`, the +When a column contains `NULL` values, you can use [`nulls_first`](trait@cot::db::query::expr::ExprSort#method.nulls_first) or [`nulls_last`](trait@cot::db::query::expr::ExprSort#method.nulls_last) methods to control where `NULL` values show up in the sort order. +The `nulls_first` method puts `NULL` values at the beginning of the sort order before non-null values, while `nulls_last` puts them at the end. The default behavior of null ordering is determined by the order in which the terms are sorted. If the order is `asc`, the null ordering defaults to `nulls_last`, and if the order is `desc`, the null ordering defaults to `nulls_first`. ```rust @@ -531,7 +531,7 @@ Sometimes alphabetical or numeric order isn't what you want, you want a specific // Featured products first, in this exact order, regardless of their SKU // or insertion order. let values = vec![ - LimitedString::new("SKU-001").unwrap(), + LimitedString::new("SKU-001").unwrap(), LimitedString::new("SKU-002").unwrap(), LimitedString::new("SKU-003").unwrap() ]; From e6d81febab3e527ec817298ef0501cd76b858550 Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 8 Sep 2026 03:58:48 +0000 Subject: [PATCH 10/11] bikeshedding --- cot/src/common_types.rs | 4 ++++ cot/src/db/query.rs | 2 +- cot/src/db/query/expr/order_by.rs | 2 +- cot/tests/db_testing/query/order_by.rs | 26 ++++++++++++-------------- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/cot/src/common_types.rs b/cot/src/common_types.rs index 7ae8a8468..6e99acbbf 100644 --- a/cot/src/common_types.rs +++ b/cot/src/common_types.rs @@ -14,7 +14,9 @@ use cot::db::impl_mysql::MySqlValueRef; use cot::db::impl_postgres::PostgresValueRef; #[cfg(feature = "sqlite")] use cot::db::impl_sqlite::SqliteValueRef; +#[cfg(feature = "db")] use cot::db::query::expr::FieldRef; +#[cfg(feature = "db")] use cot::db::query::{Expr, ExprAdd}; use cot::form::FormFieldValidationError; use email_address::EmailAddress; @@ -466,6 +468,7 @@ impl DatabaseField for Url { #[cfg(feature = "db")] impl TextField for Url {} +#[cfg(feature = "db")] impl ExprAdd for FieldRef { fn add>(self, other: V) -> Expr { Expr::add(self.as_expr(), Expr::value(other.into())) @@ -821,6 +824,7 @@ impl Display for Email { #[cfg(feature = "db")] impl TextField for Email {} +#[cfg(feature = "db")] impl ExprAdd for FieldRef { fn add>(self, other: V) -> Expr { Expr::add(self.as_expr(), Expr::value(other.into())) diff --git a/cot/src/db/query.rs b/cot/src/db/query.rs index f959df44d..855c7301f 100644 --- a/cot/src/db/query.rs +++ b/cot/src/db/query.rs @@ -8,7 +8,7 @@ use derive_more::with_trait::Debug; use thiserror::Error; use crate::db; -pub use crate::db::query::expr::{Expr, ExprAdd, ExprDiv, ExprMul, ExprOrd, ExprSub, ExprSort}; +pub use crate::db::query::expr::{Expr, ExprAdd, ExprDiv, ExprMul, ExprOrd, ExprSort, ExprSub}; use crate::db::query::expr::{OrderByExpr, SqlQueryBuilder}; use crate::db::{Auto, DatabaseBackend, ForeignKey, Model, StatementResult, ToDbFieldValue}; const ERROR_PREFIX: &str = "expression error:"; diff --git a/cot/src/db/query/expr/order_by.rs b/cot/src/db/query/expr/order_by.rs index a6bdcff9a..709edd18c 100644 --- a/cot/src/db/query/expr/order_by.rs +++ b/cot/src/db/query/expr/order_by.rs @@ -140,7 +140,7 @@ impl OrderByExpr { /// # Panics /// /// Panics if this term was built with [`ExprSort::field_value`]. A - /// field_value term never produces a `NULL` sort key, + /// `field_value` term never produces a `NULL` sort key, /// so an explicit `NULLS` placement on top of it can never have any /// effect. #[must_use] diff --git a/cot/tests/db_testing/query/order_by.rs b/cot/tests/db_testing/query/order_by.rs index a7b34fd78..b07c373d4 100644 --- a/cot/tests/db_testing/query/order_by.rs +++ b/cot/tests/db_testing/query/order_by.rs @@ -250,7 +250,7 @@ async fn order_by_mixed_column_and_expression_terms(test_db: &mut TestDatabase) } #[cot_macros::dbtest] -async fn order_by_expression_subtraction_ascending(test_db: &mut TestDatabase) { +async fn order_by_expression_sub_asc(test_db: &mut TestDatabase) { migrate_order_test_model(&*test_db).await; seed_order_test_model( test_db, @@ -274,7 +274,7 @@ async fn order_by_expression_subtraction_ascending(test_db: &mut TestDatabase) { } #[cot_macros::dbtest] -async fn order_by_expression_multiplication_ascending(test_db: &mut TestDatabase) { +async fn order_by_expression_mul_asc(test_db: &mut TestDatabase) { migrate_order_test_model(&*test_db).await; seed_order_test_model( test_db, @@ -298,7 +298,7 @@ async fn order_by_expression_multiplication_ascending(test_db: &mut TestDatabase } #[cot_macros::dbtest] -async fn order_by_expression_division_ascending(test_db: &mut TestDatabase) { +async fn order_by_expression_div_asc(test_db: &mut TestDatabase) { migrate_order_test_model(&*test_db).await; seed_order_test_model( test_db, @@ -322,7 +322,7 @@ async fn order_by_expression_division_ascending(test_db: &mut TestDatabase) { } #[cot_macros::dbtest] -async fn order_by_nulls_first_with_ascending(test_db: &mut TestDatabase) { +async fn order_by_nulls_first_with_asc(test_db: &mut TestDatabase) { migrate_order_test_model(&*test_db).await; seed_order_test_model( test_db, @@ -345,7 +345,7 @@ async fn order_by_nulls_first_with_ascending(test_db: &mut TestDatabase) { } #[cot_macros::dbtest] -async fn order_by_nulls_last_with_ascending(test_db: &mut TestDatabase) { +async fn order_by_nulls_last_with_asc(test_db: &mut TestDatabase) { migrate_order_test_model(&*test_db).await; seed_order_test_model( test_db, @@ -391,7 +391,7 @@ async fn order_by_asc_default_nulls_last(test_db: &mut TestDatabase) { } #[cot_macros::dbtest] -async fn order_by_bare_field_defaults_to_nulls_last(test_db: &mut TestDatabase) { +async fn order_by_bare_field_default_nulls_last(test_db: &mut TestDatabase) { migrate_order_test_model(&*test_db).await; seed_order_test_model( test_db, @@ -414,7 +414,7 @@ async fn order_by_bare_field_defaults_to_nulls_last(test_db: &mut TestDatabase) } #[cot_macros::dbtest] -async fn order_by_bare_expression_defaults_to_nulls_last(test_db: &mut TestDatabase) { +async fn order_by_bare_expr_default_nulls_last(test_db: &mut TestDatabase) { migrate_order_test_model(&*test_db).await; seed_order_test_model( test_db, @@ -437,7 +437,7 @@ async fn order_by_bare_expression_defaults_to_nulls_last(test_db: &mut TestDatab } #[cot_macros::dbtest] -async fn order_by_nulls_first_with_descending(test_db: &mut TestDatabase) { +async fn order_by_nulls_first_desc(test_db: &mut TestDatabase) { migrate_order_test_model(&*test_db).await; seed_order_test_model( test_db, @@ -462,7 +462,7 @@ async fn order_by_nulls_first_with_descending(test_db: &mut TestDatabase) { } #[cot_macros::dbtest] -async fn order_by_nulls_last_with_descending(test_db: &mut TestDatabase) { +async fn order_by_nulls_last_with_desc(test_db: &mut TestDatabase) { migrate_order_test_model(&*test_db).await; seed_order_test_model( test_db, @@ -534,9 +534,7 @@ async fn order_by_field_value_ranking(test_db: &mut TestDatabase) { } #[cot_macros::dbtest] -async fn order_by_field_value_ranking_partial_list_keeps_remaining_rows( - test_db: &mut TestDatabase, -) { +async fn order_by_field_value_keeps_remaining_rows(test_db: &mut TestDatabase) { migrate_order_test_model(&*test_db).await; seed_order_test_model( test_db, @@ -587,7 +585,7 @@ async fn order_by_combined_with_filter(test_db: &mut TestDatabase) { } #[cot_macros::dbtest] -async fn order_by_combined_with_limit_and_offset(test_db: &mut TestDatabase) { +async fn order_by_combined_limit_and_offset(test_db: &mut TestDatabase) { migrate_order_test_model(&*test_db).await; seed_order_test_model( test_db, @@ -687,7 +685,7 @@ fn field_value_panics_on_empty_values() { #[test] #[should_panic(expected = "cannot use an auto-generated value as a field value ordering key")] -fn field_value_panics_on_auto_generated_value() { +fn field_value_panic_auto_generated_value() { let _ = ::Fields::id.field_value([Auto::auto()]); } From ef9aad03fdda85aec9ec4eeabacf317543656fc7 Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 8 Sep 2026 15:38:31 +0000 Subject: [PATCH 11/11] increase coverage. Also, remove EprAdd impl for email and url. They are sematically wrong. --- cot/src/common_types.rs | 18 ----------- cot/src/db/query/expr.rs | 16 ++-------- cot/src/db/query/expr/order_by.rs | 18 ----------- cot/tests/db_testing/query/order_by.rs | 42 ++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 49 deletions(-) diff --git a/cot/src/common_types.rs b/cot/src/common_types.rs index 6e99acbbf..0f7980e67 100644 --- a/cot/src/common_types.rs +++ b/cot/src/common_types.rs @@ -14,10 +14,6 @@ use cot::db::impl_mysql::MySqlValueRef; use cot::db::impl_postgres::PostgresValueRef; #[cfg(feature = "sqlite")] use cot::db::impl_sqlite::SqliteValueRef; -#[cfg(feature = "db")] -use cot::db::query::expr::FieldRef; -#[cfg(feature = "db")] -use cot::db::query::{Expr, ExprAdd}; use cot::form::FormFieldValidationError; use email_address::EmailAddress; use securer_string::SecureString; @@ -468,13 +464,6 @@ impl DatabaseField for Url { #[cfg(feature = "db")] impl TextField for Url {} -#[cfg(feature = "db")] -impl ExprAdd for FieldRef { - fn add>(self, other: V) -> Expr { - Expr::add(self.as_expr(), Expr::value(other.into())) - } -} - /// A validated email address. /// /// This is a newtype wrapper around [`EmailAddress`] that provides validation @@ -824,13 +813,6 @@ impl Display for Email { #[cfg(feature = "db")] impl TextField for Email {} -#[cfg(feature = "db")] -impl ExprAdd for FieldRef { - fn add>(self, other: V) -> Expr { - Expr::add(self.as_expr(), Expr::value(other.into())) - } -} - #[cfg(test)] mod tests { use std::convert::TryFrom; diff --git a/cot/src/db/query/expr.rs b/cot/src/db/query/expr.rs index 6148faa41..4ff01fbb9 100644 --- a/cot/src/db/query/expr.rs +++ b/cot/src/db/query/expr.rs @@ -6,7 +6,7 @@ use std::marker::PhantomData; use std::ops::{Add, Div, Mul, Sub}; use cot::db::query::{IntoField, QueryBuildingError}; -use cot::db::{DbFieldValue, DbValue, FromDbValue, Identifier, LimitedString, ToDbFieldValue}; +use cot::db::{DbFieldValue, DbValue, FromDbValue, Identifier, ToDbFieldValue}; pub use like::ExprLike; use like::{CaseSensitivity, LikeExprBuilder, LikeMode}; pub use order_by::{ExprSort, NullsOrder, OrderByExpr, SortOrder}; @@ -1767,19 +1767,9 @@ impl_num_expr!(u64); impl_num_expr!(f32); impl_num_expr!(f64); -impl ExprAdd for FieldRef { - fn add>(self, other: V) -> Expr { - // TODO: use Expr::concat instead - Expr::add(self.as_expr(), Expr::value(other.into())) - } -} +// TODO: Provide `ExprAdd for FieldRef` implementations for String and +// LimitedString if Expr::concat is supported -impl ExprAdd>> for FieldRef> { - fn add>>>(self, other: V) -> Expr { - // TODO: use Expr::concat instead - Expr::add(self.as_expr(), other.into().as_expr()) - } -} #[cfg(test)] mod test { use super::*; diff --git a/cot/src/db/query/expr/order_by.rs b/cot/src/db/query/expr/order_by.rs index 709edd18c..c7ba0826b 100644 --- a/cot/src/db/query/expr/order_by.rs +++ b/cot/src/db/query/expr/order_by.rs @@ -23,15 +23,6 @@ impl From<&SortOrder> for sea_query::Order { } } -impl From for sea_query::Order { - fn from(value: SortOrder) -> Self { - match value { - SortOrder::Asc => sea_query::Order::Asc, - SortOrder::Desc => sea_query::Order::Desc, - } - } -} - /// The order to sort null values #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum NullsOrder { @@ -41,15 +32,6 @@ pub enum NullsOrder { Last, } -impl From<&NullsOrder> for sea_query::NullOrdering { - fn from(value: &NullsOrder) -> Self { - match value { - NullsOrder::First => sea_query::NullOrdering::First, - NullsOrder::Last => sea_query::NullOrdering::Last, - } - } -} - impl From for sea_query::NullOrdering { fn from(value: NullsOrder) -> Self { match value { diff --git a/cot/tests/db_testing/query/order_by.rs b/cot/tests/db_testing/query/order_by.rs index b07c373d4..bfab806f7 100644 --- a/cot/tests/db_testing/query/order_by.rs +++ b/cot/tests/db_testing/query/order_by.rs @@ -560,6 +560,48 @@ async fn order_by_field_value_keeps_remaining_rows(test_db: &mut TestDatabase) { assert_eq!(got, vec!["apple", "banana", "cherry"]); } +#[cot_macros::dbtest] +async fn order_by_field_value_then_secondary_order(test_db: &mut TestDatabase) { + migrate_order_test_model(&*test_db).await; + + seed_order_test_model( + test_db, + &[ + ("banana", 1, 0, 0, None), + ("cherry", 2, 0, 0, None), + ("cherry", 1, 0, 0, None), + ("banana", 3, 0, 0, None), + ("apple", 2, 0, 0, None), + ], + ) + .await; + + let objects = OrderTestModel::objects() + .order_by([ + ::Fields::category.field_value(["cherry", "apple", "banana"]), + ::Fields::priority.desc(), + ]) + .all(&**test_db) + .await + .unwrap(); + + let got: Vec<_> = objects + .iter() + .map(|o| (o.category.as_str(), o.priority)) + .collect(); + + assert_eq!( + got, + vec![ + ("cherry", 2), + ("cherry", 1), + ("apple", 2), + ("banana", 3), + ("banana", 1), + ] + ); +} + #[cot_macros::dbtest] async fn order_by_combined_with_filter(test_db: &mut TestDatabase) { migrate_order_test_model(&*test_db).await;