diff --git a/cot/src/db.rs b/cot/src/db.rs index 3672b8672..8b1d7088c 100644 --- a/cot/src/db.rs +++ b/cot/src/db.rs @@ -2576,6 +2576,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, 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/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.rs b/cot/src/db/query.rs index 3bc4a50ad..855c7301f 100644 --- a/cot/src/db/query.rs +++ b/cot/src/db/query.rs @@ -8,8 +8,8 @@ use derive_more::with_trait::Debug; use thiserror::Error; use crate::db; -use crate::db::query::expr::SqlQueryBuilder; -pub use crate::db::query::expr::{Expr, ExprAdd, ExprDiv, ExprMul, ExprOrd, ExprSub}; +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:"; @@ -47,6 +47,7 @@ pub enum QueryBuildingError { pub struct Query { filter: Option, limit: Option, + order_by: Vec, offset: Option, phantom_data: PhantomData T>, } @@ -56,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) @@ -69,6 +71,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, } @@ -112,6 +115,7 @@ impl Query { Self { filter: None, limit: None, + order_by: Vec::new(), offset: None, phantom_data: PhantomData, } @@ -163,6 +167,36 @@ impl Query { self } + /// Set an order for records from the query. + /// + /// # 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(), + /// ]); + /// ``` + 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 + } + /// Set the offset for the query. /// /// # Example @@ -249,6 +283,17 @@ impl Query { } } + 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, sql_builder)?; + } + Ok(()) + } + pub(super) fn add_offset_to_statement(&self, statement: &mut sea_query::SelectStatement) { if let Some(offset) = self.offset { statement.offset(offset); diff --git a/cot/src/db/query/expr.rs b/cot/src/db/query/expr.rs index 8946ccd95..4ff01fbb9 100644 --- a/cot/src/db/query/expr.rs +++ b/cot/src/db/query/expr.rs @@ -1,14 +1,20 @@ //! Database expressions. pub mod like; +mod order_by; use std::marker::PhantomData; +use std::ops::{Add, Div, Mul, Sub}; 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}; +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, @@ -1197,6 +1203,86 @@ impl Expr { Self::RawLike(Box::new(lhs), Box::new(rhs), CaseSensitivity::Insensitive) } + /// 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) + } + + /// 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) + } + + /// 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, + /// } + /// + /// let _ = Expr::field("filename").field_value(vec![ + /// "foo".to_string(), + /// "bar".to_string(), + /// "baz".to_string(), + /// ]); + /// ``` + #[must_use] + 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::field_value(OrderTarget::Expression(self), sea_query::Values(values)) + } + /// Returns the expression as a [`sea_query::SimpleExpr`]. /// /// # Example @@ -1316,6 +1402,42 @@ impl FieldRef { pub fn as_expr(&self) -> Expr { Expr::Field(self.identifier) } + + pub(crate) fn identifier(&self) -> Identifier { + self.identifier + } +} + +impl Add> for FieldRef { + type Output = Expr; + + fn add(self, rhs: FieldRef) -> Self::Output { + Expr::add(self.as_expr(), rhs.as_expr()) + } +} + +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. @@ -1645,6 +1767,9 @@ impl_num_expr!(u64); impl_num_expr!(f32); impl_num_expr!(f64); +// TODO: Provide `ExprAdd for FieldRef` implementations for String and +// LimitedString if Expr::concat is supported + #[cfg(test)] mod test { use super::*; @@ -1696,4 +1821,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 new file mode 100644 index 000000000..c7ba0826b --- /dev/null +++ b/cot/src/db/query/expr/order_by.rs @@ -0,0 +1,246 @@ +//! Database ORDER BY clause. +use cot::db::{DbFieldValue, ToDbFieldValue}; + +use crate::db::Identifier; +use crate::db::query::expr::{FieldRef, SqlQueryBuilder}; +use crate::db::query::{Expr, IntoField, QueryBuildingError}; + +/// Ordering Options +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum SortOrder { + /// Sort in Ascending order. + Asc, + /// Sort in Descending Order. + Desc, +} + +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, + } + } +} + +/// 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, + } + } +} + +/// 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: 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. +/// +/// # Example +/// +/// ``` +/// use cot::db::model; +/// use cot::db::query::Query; +/// +/// use crate::cot::db::query::expr::ExprSort; +/// +/// #[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 { + target: OrderTarget, + mode: OrderMode, +} + +impl OrderByExpr { + pub(crate) fn directional(target: OrderTarget, order: SortOrder) -> Self { + Self { + target, + mode: OrderMode::Directional { + order, + nulls: default_nulls_for(order), + }, + } + } + + pub(crate) fn field_value(target: OrderTarget, values: sea_query::Values) -> Self { + assert!( + !values.0.is_empty(), + "`field_value` requires at least one value to rank by" + ); + Self { + target, + mode: OrderMode::FieldValue(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::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] + 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); + self + } + + #[track_caller] + fn set_nulls(&mut self, nulls: NullsOrder) { + match &mut self.mode { + 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" + ), + } + } + + 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(), Some(*nulls)), + OrderMode::FieldValue(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 field_value(&self, values: I) -> OrderByExpr + where + I: IntoIterator, + I::Item: IntoField; +} + +impl ExprSort for FieldRef { + fn asc(&self) -> OrderByExpr { + OrderByExpr::directional(OrderTarget::Column(self.identifier()), SortOrder::Asc) + } + + fn desc(&self) -> OrderByExpr { + OrderByExpr::directional(OrderTarget::Column(self.identifier()), SortOrder::Desc) + } + + fn field_value(&self, values: I) -> OrderByExpr + where + I: IntoIterator, + I::Item: IntoField, + { + let values = values + .into_iter() + .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 field value ordering key") + } + }) + .collect(); + OrderByExpr::field_value( + OrderTarget::Column(self.identifier()), + sea_query::Values(values), + ) + } +} 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..bfab806f7 --- /dev/null +++ b/cot/tests/db_testing/query/order_by.rs @@ -0,0 +1,748 @@ +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_expression_sub_asc(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_mul_asc(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_div_asc(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_asc(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_asc(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_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_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]) + .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_expr_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.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_desc(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_desc(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_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, + &[ + ("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.field_value(["cherry", "apple", "banana"]) + ]) + .all(&**test_db) + .await + .unwrap(); + + assert_eq!(categories_of(&objects), vec!["cherry", "apple", "banana"]); +} + +#[cot_macros::dbtest] +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, + &[ + ("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.field_value(["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_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; + 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_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()); +} + +#[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_panic_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/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"); +} diff --git a/docs/databases/queries.md b/docs/databases/queries.md index c541eb3dd..d2315e03d 100644 --- a/docs/databases/queries.md +++ b/docs/databases/queries.md @@ -420,6 +420,133 @@ 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 + +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<()> { + 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`](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?; +# Ok(()) +# } +``` + +#### Ordering by multiple fields + +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() + .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. + +#### 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 +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() + .order_by([::Fields::stock.asc().nulls_last()]) + .all(db) + .await?; +# Ok(()) +# } +``` + +#### 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() + .order_by([ + (::Fields::quantity + ::Fields::price_cents).desc(), + ]) + .all(db) + .await?; +# Ok(()) +# } +``` + +This sorts orders by `quantity + price_cents`, largest first. + +#### Ordering by custom field values + +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, 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.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. 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`.