diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml new file mode 100644 index 0000000..6dbe004 --- /dev/null +++ b/.github/workflows/examples.yml @@ -0,0 +1,117 @@ +name: Examples + +on: + pull_request: + push: + branches: + - main + - '*-dev' + +jobs: + mysql: + name: MySQL Example + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:latest + env: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: ormx + ports: + - 3307:3306 + + steps: + - uses: actions/checkout@v4 + + - uses: Swatinem/rust-cache@v2 + with: + key: mysql-example + + - name: Install sqlx-cli + run: | + cargo install sqlx-cli + sleep 5 + + - name: Setup + working-directory: example-mysql + env: + DATABASE_URL: mysql://root:password@localhost:3307/ormx?ssl-mode=disabled + run: sqlx db setup + + - name: Run + working-directory: example-mysql + env: + DATABASE_URL: mysql://root:password@localhost:3307/ormx?ssl-mode=disabled + run: cargo run + + + mariadb: + name: MariaDB Example + runs-on: ubuntu-latest + + services: + mysql: + image: mariadb:latest + env: + MYSQL_ROOT_PASSWORD: password + ports: + - 3306:3306 + + steps: + - uses: actions/checkout@v4 + + - uses: Swatinem/rust-cache@v2 + with: + key: mariadb-example + + - name: Install sqlx-cli + run: | + cargo install sqlx-cli + sleep 5 + + - name: Setup + working-directory: example-mariadb + env: + DATABASE_URL: mysql://root:password@localhost:3306/ormx?ssl-mode=disabled + run: sqlx db setup + + - name: Run + working-directory: example-mariadb + env: + DATABASE_URL: mysql://root:password@localhost:3306/ormx?ssl-mode=disabled + run: cargo run + + postgres: + name: PostgreSQL Example + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:latest + env: + POSTGRES_PASSWORD: password + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v4 + + - uses: Swatinem/rust-cache@v2 + with: + key: postgres-example + + - name: Install sqlx-cli + run: cargo install sqlx-cli + + - name: Setup + working-directory: example-postgres + env: + DATABASE_URL: postgres://postgres:password@localhost:5432/ormx + run: sqlx db setup + + - name: Run + working-directory: example-postgres + env: + DATABASE_URL: postgres://postgres:password@localhost:5432/ormx + run: cargo run diff --git a/Cargo.toml b/Cargo.toml index dd0abc7..fcb590c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,2 +1,3 @@ [workspace] -members = ["ormx-macros", "ormx", "example-postgres", "example-mysql"] \ No newline at end of file +members = ["ormx-macros", "ormx", "example-postgres", "example-mariadb", "example-mysql"] +resolver = "2" \ No newline at end of file diff --git a/README.md b/README.md index e9b499d..75117e6 100644 --- a/README.md +++ b/README.md @@ -33,15 +33,16 @@ Add ormx and sqlx to your `Cargo.toml` ```toml [dependencies.ormx] -version = "0.7" +version = "0.11" features = ["mysql"] [dependencies.sqlx] -version = "0.5" +version = "0.8" default-features = false features = ["macros", "mysql", "runtime-tokio-rustls"] ``` -Right now, ormx supports mysql/mariadb and postgres. +Right now, ormx supports mysql, mariadb and postgres. + ## what does it do? ormx provides macros for generating commonly used sql queries at compile time. ormx is meant to be used together with sqlx. Everything it generates uses `sqlx::query!` under the hood, so every generated query will be checked against your database at compile time. @@ -57,8 +58,9 @@ is not what you are looking for. if you encounter an issue or have questions, feel free to ask in [`#ormx` on the sqlx discord](https://discord.gg/mrZz4Wv8r2). The documentation currently is not what it should be, so don't be afraid to ask for help. -## [mysql example](https://github.com/NyxCode/ormx/tree/master/example-mysql/src/main.rs) ## [postgres example](https://github.com/NyxCode/ormx/tree/master/example-postgres/src/main.rs) +## [mariadb example](https://github.com/NyxCode/ormx/tree/master/example-mariadb/src/main.rs) +## [mysql example](https://github.com/NyxCode/ormx/tree/master/example-mysql/src/main.rs) ## features - `mysql` - enable support for mysql/mariadb - `postgres` - enable support for postgres diff --git a/example-mariadb/Cargo.toml b/example-mariadb/Cargo.toml new file mode 100644 index 0000000..6f56c2c --- /dev/null +++ b/example-mariadb/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "example-mariadb" +version = "0.1.0" +authors = ["moritz"] +edition = "2021" + +[dependencies] +ormx = { path = "../ormx", features = ["mariadb"] } +tokio = { version = "1", features = ["full"] } +anyhow = "1" +dotenv = "0.15" +chrono = "0.4" +env_logger = "0.11.5" +log = "0.4" + +[dependencies.sqlx] +version = "0.8" +default-features = false +features = ["macros", "mysql", "runtime-tokio-rustls", "chrono"] diff --git a/scripts/mysql-schema.sql b/example-mariadb/migrations/20240908062140_schema.sql similarity index 100% rename from scripts/mysql-schema.sql rename to example-mariadb/migrations/20240908062140_schema.sql diff --git a/example-mariadb/src/main.rs b/example-mariadb/src/main.rs new file mode 100644 index 0000000..f344fb9 --- /dev/null +++ b/example-mariadb/src/main.rs @@ -0,0 +1,91 @@ +use chrono::{NaiveDateTime, Utc}; +use log::LevelFilter; +use ormx::{Delete, Insert, Table}; +use sqlx::MySqlPool; + +// To run this example, first run `/scripts/mariadb.sh` to start mariadb in a docker container and +// write the database URL to `.env`. Then, source `.env` (`. .env`) and run `cargo run` + +mod query2; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + dotenv::dotenv().ok(); + env_logger::builder() + .filter_level(LevelFilter::Debug) + .init(); + + let db = MySqlPool::connect(&dotenv::var("DATABASE_URL")?).await?; + + log::info!("insert a new row into the database"); + let mut new = InsertUser { + first_name: "Moritz".to_owned(), + last_name: "Bischof".to_owned(), + email: "moritz.bischof1@gmail.com".to_owned(), + disabled: None, + } + .insert(&db) + .await?; + + log::info!("update a single field"); + new.set_last_login(&db, Some(Utc::now().naive_utc())) + .await?; + + log::info!("update all fields at once"); + new.email = "asdf".to_owned(); + new.update(&db).await?; + + log::info!("apply a patch to the user, updating its first and last name"); + new.patch( + &db, + UpdateName { + first_name: "NewFirstName".to_owned(), + last_name: "NewLastName".to_owned(), + disabled: Some("Reason".to_owned()), + }, + ) + .await?; + + log::info!("reload the user, in case it has been modified"); + new.reload(&db).await?; + + log::info!("use the improved query macro for searching users"); + let search_result = query2::query_users(&db, Some("NewFirstName"), None).await?; + println!("{:?}", search_result); + + log::info!("delete the user from the database"); + new.delete(&db).await?; + + Ok(()) +} + +#[derive(Debug, ormx::Table)] +#[ormx(table = "users", id = user_id, insertable, deletable)] +struct User { + // map this field to the column "id" + #[ormx(column = "id")] + // generate `User::get_by_user_id(u32) -> Result` + #[ormx(get_one = get_by_user_id(u32))] + // this column is database-generated. + #[ormx(default)] + user_id: u32, + first_name: String, + last_name: String, + // generate `User::by_email(&str) -> Result>` + #[ormx(get_optional(&str))] + email: String, + disabled: Option, + // don't include this field into `InsertUser` since it has a default value + // generate `User::set_last_login(Option) -> Result<()>` + #[ormx(default, set)] + last_login: Option, +} + +// Patches can be used to update multiple fields at once (in diesel, they're called "ChangeSets"). +#[derive(ormx::Patch)] +#[ormx(table_name = "users", table = crate::User, id = "id")] +struct UpdateName { + first_name: String, + last_name: String, + disabled: Option, +} diff --git a/example-mariadb/src/query2.rs b/example-mariadb/src/query2.rs new file mode 100644 index 0000000..fe658ae --- /dev/null +++ b/example-mariadb/src/query2.rs @@ -0,0 +1,27 @@ +use sqlx::MySqlPool; + +use crate::User; + +pub(crate) async fn query_users( + db: &MySqlPool, + filter: Option<&str>, + limit: Option, +) -> anyhow::Result> { + let result = ormx::conditional_query_as!( + User, + r#"SELECT id AS user_id, first_name, last_name, email, disabled, last_login"# + "FROM users" + Some(f) = filter => { + "WHERE first_name LIKE" ?(f) + "OR last_name LIKE" ?(f) + } + "ORDER BY first_name DESC" + Some(l) = limit => { + "LIMIT" ?(l as i64) + } + ) + .fetch_all(db) + .await?; + + Ok(result) +} diff --git a/example-mysql/Cargo.toml b/example-mysql/Cargo.toml index d7f74f3..ab8bf34 100644 --- a/example-mysql/Cargo.toml +++ b/example-mysql/Cargo.toml @@ -2,18 +2,18 @@ name = "example-mysql" version = "0.1.0" authors = ["moritz"] -edition = "2018" +edition = "2021" [dependencies] ormx = { path = "../ormx", features = ["mysql"] } -tokio = { version = "1.1", features = ["full"] } +tokio = { version = "1", features = ["full"] } anyhow = "1" dotenv = "0.15" chrono = "0.4" -simple_logger = "1" +env_logger = "0.11.5" log = "0.4" [dependencies.sqlx] -version = "0.6" +version = "0.8" default-features = false -features = ["macros", "mysql", "runtime-tokio-rustls", "chrono", "offline"] +features = ["macros", "mysql", "runtime-tokio-rustls", "chrono"] diff --git a/example-mysql/migrations/20240908062140_schema.sql b/example-mysql/migrations/20240908062140_schema.sql new file mode 100644 index 0000000..773172b --- /dev/null +++ b/example-mysql/migrations/20240908062140_schema.sql @@ -0,0 +1,9 @@ +CREATE TABLE users +( + id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + first_name VARCHAR(128) NOT NULL, + last_name VARCHAR(128) NOT NULL, + email VARCHAR(128) NOT NULL UNIQUE, + disabled VARCHAR(128), + last_login DATETIME DEFAULT NULL +) \ No newline at end of file diff --git a/example-mysql/src/main.rs b/example-mysql/src/main.rs index d8ee7f3..26ba842 100644 --- a/example-mysql/src/main.rs +++ b/example-mysql/src/main.rs @@ -1,11 +1,9 @@ -// #![feature(trace_macros)] use chrono::{NaiveDateTime, Utc}; -use ormx::{Insert, Table}; +use log::LevelFilter; +use ormx::{Delete, Insert, Table}; use sqlx::MySqlPool; -// trace_macros!(true); - -// To run this example-postgres, first run `/scripts/postgres.sh` to start postgres in a docker container and +// To run this example, first run `/scripts/mariadb.sh` to start mariadb in a docker container and // write the database URL to `.env`. Then, source `.env` (`. .env`) and run `cargo run` mod query2; @@ -13,9 +11,9 @@ mod query2; #[tokio::main] async fn main() -> anyhow::Result<()> { dotenv::dotenv().ok(); - simple_logger::SimpleLogger::new() - .with_level(log::LevelFilter::Info) - .init()?; + env_logger::builder() + .filter_level(LevelFilter::Debug) + .init(); let db = MySqlPool::connect(&dotenv::var("DATABASE_URL")?).await?; @@ -26,7 +24,7 @@ async fn main() -> anyhow::Result<()> { email: "moritz.bischof1@gmail.com".to_owned(), disabled: None, } - .insert(&db) + .insert(&mut *db.acquire().await?) .await?; log::info!("update a single field"); @@ -62,7 +60,7 @@ async fn main() -> anyhow::Result<()> { } #[derive(Debug, ormx::Table)] -#[ormx(table = "users", id = user_id, insertable)] +#[ormx(table = "users", id = user_id, insertable, deletable)] struct User { // map this field to the column "id" #[ormx(column = "id")] diff --git a/example-postgres/Cargo.toml b/example-postgres/Cargo.toml index f6b1f48..fc3dd0f 100644 --- a/example-postgres/Cargo.toml +++ b/example-postgres/Cargo.toml @@ -2,18 +2,18 @@ name = "example-postgres" version = "0.1.0" authors = ["moritz"] -edition = "2018" +edition = "2021" [dependencies] ormx = { path = "../ormx", features = ["postgres"] } -tokio = { version = "1.1", features = ["full"] } +tokio = { version = "1", features = ["full"] } anyhow = "1" dotenv = "0.15" chrono = "0.4" -simple_logger = "1" +env_logger = "0.11.5" log = "0.4" [dependencies.sqlx] -version = "0.6" +version = "0.8" default-features = false -features = ["macros", "postgres", "runtime-tokio-rustls", "chrono", "offline"] +features = ["macros", "postgres", "runtime-tokio-rustls", "chrono"] diff --git a/scripts/postgres-schema.sql b/example-postgres/migrations/20240908062042_schema.sql similarity index 58% rename from scripts/postgres-schema.sql rename to example-postgres/migrations/20240908062042_schema.sql index 192c64b..7d92af4 100644 --- a/scripts/postgres-schema.sql +++ b/example-postgres/migrations/20240908062042_schema.sql @@ -1,4 +1,6 @@ CREATE TYPE user_role AS ENUM ('user', 'admin'); +CREATE TYPE account_type AS ENUM ('legacy', 'normal'); +CREATE TYPE user_group AS ENUM ('local', 'global', 'other'); CREATE TABLE users ( @@ -7,11 +9,14 @@ CREATE TABLE users last_name VARCHAR(128) NOT NULL, email VARCHAR(128) NOT NULL UNIQUE, role user_role NOT NULL, + type account_type, + "group" user_group NOT NULL DEFAULT 'local', disabled TEXT, last_login TIMESTAMP DEFAULT NULL ); -CREATE TABLE test ( - id SERIAL PRIMARY KEY, +CREATE TABLE test +( + id SERIAL PRIMARY KEY, rows TEXT[] NOT NULL ); \ No newline at end of file diff --git a/example-postgres/src/main.rs b/example-postgres/src/main.rs index 7309b64..9dc4d7d 100644 --- a/example-postgres/src/main.rs +++ b/example-postgres/src/main.rs @@ -1,11 +1,9 @@ -// #![feature(trace_macros)] use chrono::{NaiveDateTime, Utc}; -use ormx::{Insert, Table, Delete}; +use log::LevelFilter; +use ormx::{Delete, Insert, Table}; use sqlx::PgPool; -// trace_macros!(true); - -// To run this example-postgres, first run `/scripts/postgres.sh` to start postgres in a docker container and +// To run this example, first run `/scripts/postgres.sh` to start postgres in a docker container and // write the database URL to `.env`. Then, source `.env` (`. .env`) and run `cargo run` mod query2; @@ -13,13 +11,13 @@ mod query2; #[tokio::main] async fn main() -> anyhow::Result<()> { dotenv::dotenv().ok(); - simple_logger::SimpleLogger::new() - .with_level(log::LevelFilter::Info) - .init()?; + env_logger::builder() + .filter_level(LevelFilter::Debug) + .init(); let db = PgPool::connect(&dotenv::var("DATABASE_URL")?).await?; - log::info!("insert a new row into the database"); + log::info!("insert a few new rows into the database"); let mut new = InsertUser { user_id: 1, first_name: "Moritz".to_owned(), @@ -27,6 +25,18 @@ async fn main() -> anyhow::Result<()> { email: "moritz.bischof1@gmail.com".to_owned(), disabled: None, role: Role::User, + ty: Some(AccountType::Normal), + } + .insert(&db) + .await?; + InsertUser { + user_id: 2, + first_name: "Dylan".to_owned(), + last_name: "Thomas".to_owned(), + email: "dylan.thomas@gmail.com".to_owned(), + disabled: Some("email not verified".to_owned()), + role: Role::Admin, + ty: None, } .insert(&db) .await?; @@ -56,7 +66,11 @@ async fn main() -> anyhow::Result<()> { log::info!("use the improved query macro for searching users"); let search_result = query2::query_users(&db, Some("NewFirstName"), None).await?; - println!("{:?}", search_result); + log::info!("search result: {:?}", search_result); + + log::info!("load all users in the order specified by the 'order_by' attribute"); + let all = User::all_paginated(&db, 0, 100).await?; + log::info!("all users: {all:?}"); log::info!("delete the user from the database"); new.delete(&db).await?; @@ -65,7 +79,7 @@ async fn main() -> anyhow::Result<()> { } #[derive(Debug, ormx::Table)] -#[ormx(table = "users", id = user_id, insertable, deletable)] +#[ormx(table = "users", id = user_id, insertable, deletable, order_by = "email ASC")] struct User { // map this field to the column "id" #[ormx(column = "id")] @@ -78,6 +92,10 @@ struct User { email: String, #[ormx(custom_type)] role: Role, + #[ormx(column = "type", custom_type)] + ty: Option, + #[ormx(custom_type, default, set)] + group: UserGroup, disabled: Option, // don't include this field into `InsertUser` since it has a default value // generate `User::set_last_login(Option) -> Result<()>` @@ -104,6 +122,23 @@ enum Role { Admin, } +#[derive(Debug, Copy, Clone, sqlx::Type)] +#[sqlx(type_name = "account_type")] +#[sqlx(rename_all = "lowercase")] +enum AccountType { + Legacy, + Normal, +} + +#[derive(Debug, Copy, Clone, sqlx::Type)] +#[sqlx(type_name = "user_group")] +#[sqlx(rename_all = "lowercase")] +enum UserGroup { + Local, + Global, + Other, +} + #[derive(Debug, ormx::Table)] #[ormx(table = "test", id = id, insertable)] struct Test { diff --git a/example-postgres/src/query2.rs b/example-postgres/src/query2.rs index 66092ed..bf40290 100644 --- a/example-postgres/src/query2.rs +++ b/example-postgres/src/query2.rs @@ -9,7 +9,9 @@ pub(crate) async fn query_users( ) -> anyhow::Result> { let result = ormx::conditional_query_as!( User, - r#"SELECT id AS user_id, first_name, last_name, email, disabled, role AS "role: _", last_login"# + r#"SELECT + id AS user_id, first_name, last_name, email, disabled, + role AS "role: _", "group" AS "group: _", type as "ty: _", last_login"# "FROM users" Some(f) = filter => { "WHERE first_name LIKE" ?(f) diff --git a/ormx-macros/Cargo.toml b/ormx-macros/Cargo.toml index ac08ffa..a5c97e8 100644 --- a/ormx-macros/Cargo.toml +++ b/ormx-macros/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "ormx-macros" -version = "0.10.4" +version = "0.11.0" authors = ["moritz"] -edition = "2018" +edition = "2021" description = "lightweight procedural macros bringing orm-like features to sqlx" homepage = "https://github.com/NyxCode/ormx" license = "MIT" @@ -13,12 +13,13 @@ proc-macro = true [features] sqlite = [] mysql = [] +mariadb = [] postgres = [] [dependencies] -itertools = "0.10" +itertools = "0.13.0" proc-macro2 = "1" quote = "1" -syn = { version = "1", features = ["full"] } +syn = { version = "2", features = ["full"] } once_cell = "1" proc-macro-error = "1" diff --git a/ormx-macros/src/attrs.rs b/ormx-macros/src/attrs.rs index fc99434..f34626e 100644 --- a/ormx-macros/src/attrs.rs +++ b/ormx-macros/src/attrs.rs @@ -12,7 +12,9 @@ pub enum TableAttr { // insertable [= []* ]? Insertable(Option), // deletable - Deletable(()) + Deletable(()), + // order_by = + OrderBy(String), } pub struct Insertable { @@ -38,7 +40,7 @@ pub enum TableFieldAttr { // by_ref ByRef(()), // insert_attribute = - InsertAttr(AnyAttribute) + InsertAttr(AnyAttribute), } #[derive(Clone)] @@ -92,7 +94,7 @@ impl Parse for Insertable { pub fn parse_attrs(attrs: &[Attribute]) -> Result> { let attrs = attrs .iter() - .filter(|a| a.path.is_ident("ormx")) + .filter(|a| a.path().is_ident("ormx")) .map(|a| a.parse_args_with(Punctuated::::parse_terminated)) .collect::>>()? .into_iter() @@ -144,7 +146,8 @@ impl_parse!(TableAttr { "table" => Table(= String), "id" => Id(= Ident), "insertable" => Insertable((= Insertable)?), - "deletable" => Deletable() + "deletable" => Deletable(), + "order_by" => OrderBy(= String) }); impl_parse!(TableFieldAttr { @@ -172,8 +175,8 @@ impl_parse!(PatchFieldAttr { }); pub struct AnyAttribute(pub Vec); -impl syn::parse::Parse for AnyAttribute { +impl Parse for AnyAttribute { fn parse(input: ParseStream) -> Result { input.call(Attribute::parse_outer).map(Self) } -} \ No newline at end of file +} diff --git a/ormx-macros/src/backend/common/mod.rs b/ormx-macros/src/backend/common/mod.rs index 2663562..f6627bc 100644 --- a/ormx-macros/src/backend/common/mod.rs +++ b/ormx-macros/src/backend/common/mod.rs @@ -23,7 +23,7 @@ pub(crate) fn getters(table: &Table) -> TokenStream { let sql = format!( "SELECT {} FROM {} WHERE {} = {}", column_list, - table.table, + table.name(), field.column(), B::Bindings::default().next().unwrap() ); @@ -103,7 +103,7 @@ pub fn setters(table: &Table) -> TokenStream { let mut bindings = B::Bindings::default(); let sql = format!( "UPDATE {} SET {} = {} WHERE {} = {}", - table.table, + table.name(), field.column(), bindings.next().unwrap(), table.id.column(), @@ -171,7 +171,6 @@ pub(crate) fn impl_patch(patch: &Patch) -> TokenStream { bindings.next().unwrap() ); - let box_future = crate::utils::box_future(); quote! { impl ormx::Patch for #patch_ident { type Table = #table_path; @@ -180,17 +179,15 @@ pub(crate) fn impl_patch(patch: &Patch) -> TokenStream { #( entity.#field_idents = self.#field_idents; )* } - fn patch_row<'a, 'c: 'a>( + async fn patch_row<'a, 'c: 'a>( &'a self, db: impl sqlx::Executor<'c, Database = ormx::Db> + 'a, id: ::Id, - ) -> #box_future<'a, sqlx::Result<()>> { - Box::pin(async move { - sqlx::query!(#sql, #( self.#query_args, )* id) - .execute(db) - .await?; - Ok(()) - }) + ) -> sqlx::Result<()> { + sqlx::query!(#sql, #( self.#query_args, )* id) + .execute(db) + .await?; + Ok(()) } } } diff --git a/ormx-macros/src/backend/common/table.rs b/ormx-macros/src/backend/common/table.rs index 60afdf4..e64abac 100644 --- a/ormx-macros/src/backend/common/table.rs +++ b/ormx-macros/src/backend/common/table.rs @@ -47,31 +47,27 @@ pub fn impl_delete(table: &Table) -> TokenStream { } fn get(table: &Table, column_list: &str) -> TokenStream { - let box_future = crate::utils::box_future(); let get_sql = format!( "SELECT {} FROM {} WHERE {} = {}", column_list, - table.table, + table.name(), table.id.column(), B::Bindings::default().next().unwrap() ); quote! { - fn get<'a, 'c: 'a>( + async fn get<'a, 'c: 'a>( db: impl sqlx::Executor<'c, Database = ormx::Db> + 'a, id: Self::Id, - ) -> #box_future<'a, sqlx::Result> { - Box::pin(async move { - sqlx::query_as!(Self, #get_sql, id) - .fetch_one(db) - .await - }) + ) -> sqlx::Result { + sqlx::query_as!(Self, #get_sql, id) + .fetch_one(db) + .await } } } fn update(table: &Table) -> TokenStream { - let box_future = crate::utils::box_future(); let mut bindings = B::Bindings::default(); let mut assignments = vec![]; for field in table.fields_except_id() { @@ -82,7 +78,7 @@ fn update(table: &Table) -> TokenStream { let update_sql = format!( "UPDATE {} SET {} WHERE {} = {}", - table.table, + table.name(), assignments, table.id.column(), bindings.next().unwrap() @@ -91,28 +87,34 @@ fn update(table: &Table) -> TokenStream { let other_arguments = table.fields_except_id().map(TableField::fmt_as_argument); quote! { - fn update<'a, 'c: 'a>( + async fn update<'a, 'c: 'a>( &'a self, db: impl sqlx::Executor<'c, Database = ormx::Db> + 'a, - ) -> #box_future<'a, sqlx::Result<()>> { - Box::pin(async move { - sqlx::query!(#update_sql, #( #other_arguments, )* self.#id_argument) - .execute(db) - .await?; - Ok(()) - }) + ) -> sqlx::Result<()> { + sqlx::query!(#update_sql, #( #other_arguments, )* self.#id_argument) + .execute(db) + .await?; + Ok(()) } } } fn stream_all(table: &Table, column_list: &str) -> TokenStream { - let box_stream = crate::utils::box_stream(); - let all_sql = format!("SELECT {} FROM {}", column_list, table.table); + let return_type = crate::utils::stream!(sqlx::Result); + let order_by = match &table.order_by { + None => &format!("{} DESC", table.id.column()), + Some(by) => by, + }; + let all_sql = format!( + "SELECT {} FROM {} ORDER BY {order_by}", + column_list, + table.name() + ); quote! { fn stream_all<'a, 'c: 'a>( db: impl sqlx::Executor<'c, Database = ormx::Db> + 'a, - ) -> #box_stream<'a, sqlx::Result> { + ) -> #return_type + 'a { sqlx::query_as!(Self, #all_sql) .fetch(db) } @@ -120,12 +122,16 @@ fn stream_all(table: &Table, column_list: &str) -> TokenStream { } fn stream_all_paginated(table: &Table, column_list: &str) -> TokenStream { - let box_stream = crate::utils::box_stream(); + let return_type = crate::utils::stream!(sqlx::Result); let mut bindings = B::Bindings::default(); + let order_by = match &table.order_by { + None => &format!("{} DESC", table.id.column()), + Some(by) => by, + }; let all_sql = format!( - "SELECT {} FROM {} LIMIT {} OFFSET {}", + "SELECT {} FROM {} ORDER BY {order_by} LIMIT {} OFFSET {} ", column_list, - table.table, + table.name(), bindings.next().unwrap(), bindings.next().unwrap() ); @@ -135,7 +141,7 @@ fn stream_all_paginated(table: &Table, column_list: &str) -> Toke db: impl sqlx::Executor<'c, Database = ormx::Db> + 'a, offset: i64, limit: i64, - ) -> #box_stream<'a, sqlx::Result> { + ) -> #return_type + 'a { sqlx::query_as!(Self, #all_sql, limit, offset) .fetch(db) } @@ -143,38 +149,30 @@ fn stream_all_paginated(table: &Table, column_list: &str) -> Toke } fn delete(table: &Table) -> TokenStream { - let box_future = crate::utils::box_future(); let id_ty = &table.id.ty; let delete_sql = format!( "DELETE FROM {} WHERE {} = {}", - table.table, + table.name(), table.id.column(), B::Bindings::default().next().unwrap() ); - #[cfg(feature = "mysql")] - let result_import = quote!(sqlx::mysql::MySqlQueryResult); - #[cfg(feature = "postgres")] - let result_import = quote!(sqlx::postgres::PgQueryResult); - #[cfg(feature = "sqlite")] - let result_import = quote!(sqlx::sqlite::SqliteQueryResult); + let query_result = B::query_result(); quote! { - fn delete_row<'a, 'c: 'a>( + async fn delete_row<'a, 'c: 'a>( db: impl sqlx::Executor<'c, Database = ormx::Db> + 'a, id: #id_ty - ) -> #box_future<'a, sqlx::Result<()>> { - use #result_import; - - Box::pin(async move { - let result = sqlx::query!(#delete_sql, id) - .execute(db) - .await?; - if result.rows_affected() == 0 { - Err(sqlx::Error::RowNotFound) - } else { - Ok(()) - } - }) + ) -> sqlx::Result<()> { + use #query_result; + + let result = sqlx::query!(#delete_sql, id) + .execute(db) + .await?; + if result.rows_affected() == 0 { + Err(sqlx::Error::RowNotFound) + } else { + Ok(()) + } } } } diff --git a/ormx-macros/src/backend/mariadb/insert.rs b/ormx-macros/src/backend/mariadb/insert.rs new file mode 100644 index 0000000..bd54a0d --- /dev/null +++ b/ormx-macros/src/backend/mariadb/insert.rs @@ -0,0 +1,84 @@ +use itertools::Itertools; +use proc_macro2::{Span, TokenStream}; +use quote::quote; +use syn::Ident; + +use crate::{ + backend::mariadb::{MariaBackend, MariaBindings}, + table::{Table, TableField}, +}; + +pub fn impl_insert(table: &Table) -> TokenStream { + let insert_ident = match &table.insertable { + Some(i) => &i.ident, + None => return quote!(), + }; + + let insert_fields: Vec<&TableField> = table.insertable_fields().collect(); + let default_fields: Vec<&TableField> = table.default_fields().collect(); + + let table_ident = &table.ident; + let insert_field_idents = insert_fields.iter().map(|field| &field.field); + let default_field_idents = default_fields.iter().map(|field| &field.field); + let default_field_ordinals = (0usize..).take(default_fields.len()); + + let insert_sql = insert_sql(table, &insert_fields); + + let insert_field_exprs = insert_fields.iter().map(|f| f.fmt_as_argument()); + + let fetch_fn = if default_fields.is_empty() { + Ident::new("execute", Span::call_site()) + } else { + Ident::new("fetch_one", Span::call_site()) + }; + + quote! { + impl ormx::Insert for #insert_ident { + type Table = #table_ident; + + async fn insert<'a, 'c: 'a>( + self, + db: impl sqlx::Executor<'c, Database = ormx::Db> + 'a, + ) -> sqlx::Result { + use sqlx::Row; + + let _generated = sqlx::query!(#insert_sql, #( #insert_field_exprs, )*) + .#fetch_fn(db) + .await?; + + Ok(Self::Table { + #( #insert_field_idents: self.#insert_field_idents, )* + #( #default_field_idents: _generated.get(#default_field_ordinals), )* + }) + } + } + } +} + +fn insert_sql(table: &Table, insert_fields: &[&TableField]) -> String { + let columns = insert_fields.iter().map(|field| field.column()).join(", "); + let fields = MariaBindings::default() + .take(insert_fields.len()) + .join(", "); + let returning_fields = table + .default_fields() + .map(TableField::fmt_for_select) + .join(", "); + + if returning_fields.is_empty() { + format!( + "INSERT INTO {} ({}) VALUES ({})", + table.name(), + columns, + fields + ) + } else { + format!( + "INSERT INTO {} ({}) VALUES ({}) RETURNING {}", + table.name(), + columns, + fields, + returning_fields + ) + } +} diff --git a/ormx-macros/src/backend/mariadb/mod.rs b/ormx-macros/src/backend/mariadb/mod.rs new file mode 100644 index 0000000..938fa84 --- /dev/null +++ b/ormx-macros/src/backend/mariadb/mod.rs @@ -0,0 +1,37 @@ +use std::borrow::Cow; + +use proc_macro2::TokenStream; +use quote::quote; + +use crate::{backend::Backend, table::Table}; + +mod insert; + +#[derive(Clone)] +pub struct MariaBackend; + +impl Backend for MariaBackend { + const QUOTE: char = '`'; + const IS_MYSQL: bool = true; + + type Bindings = MariaBindings; + + fn query_result() -> TokenStream { + quote!(sqlx::mysql::MySqlQueryResult) + } + + fn impl_insert(table: &Table) -> TokenStream { + insert::impl_insert(table) + } +} + +#[derive(Default)] +pub struct MariaBindings; + +impl Iterator for MariaBindings { + type Item = Cow<'static, str>; + + fn next(&mut self) -> Option { + Some(Cow::Borrowed("?")) + } +} diff --git a/ormx-macros/src/backend/mod.rs b/ormx-macros/src/backend/mod.rs index 37b4592..3d5a476 100644 --- a/ormx-macros/src/backend/mod.rs +++ b/ormx-macros/src/backend/mod.rs @@ -5,11 +5,15 @@ use proc_macro2::TokenStream; use crate::{patch::Patch, table::Table}; mod common; +#[cfg(feature = "mariadb")] +mod mariadb; #[cfg(feature = "mysql")] mod mysql; #[cfg(feature = "postgres")] mod postgres; +#[cfg(feature = "mariadb")] +pub type Implementation = mariadb::MariaBackend; #[cfg(feature = "mysql")] pub type Implementation = mysql::MySqlBackend; #[cfg(feature = "postgres")] @@ -19,11 +23,13 @@ compile_error!("sqlite is currently not supported"); pub trait Backend: Sized + Clone { const QUOTE: char; - /// TODO: benchmark HashSet vs linear search - const RESERVED_IDENTS: &'static [&'static str]; + const IS_MYSQL: bool = false; type Bindings: Iterator> + Default; + /// Returns the type used to return query results, e.g `sqlx::postgres::PgQueryResult` + fn query_result() -> TokenStream; + /// Generate an `impl ` block, containing getter methods fn impl_getters(table: &Table) -> TokenStream { common::getters::(table) diff --git a/ormx-macros/src/backend/mysql/insert.rs b/ormx-macros/src/backend/mysql/insert.rs index e45cc79..772ae2b 100644 --- a/ormx-macros/src/backend/mysql/insert.rs +++ b/ormx-macros/src/backend/mysql/insert.rs @@ -15,29 +15,28 @@ pub fn impl_insert(table: &Table) -> TokenStream { }; let table_ident = &table.ident; - let box_future = quote!(ormx::exports::futures::future::BoxFuture); - let insert = insert(&table); - let query_id = query_id(&table); - let query_default = query_default(&table); - let construct_row = construct_row(&table); + let insert = insert(table); + let query_id = query_id(table); + let query_default = query_default(table); + let construct_row = construct_row(table); quote! { impl ormx::Insert for #insert_ident { type Table = #table_ident; - fn insert<'a, 'c: 'a>( + async fn insert<'a, 'c: 'a>( self, - db: impl sqlx::Executor<'c, Database = ormx::Db> + 'a, - ) -> #box_future<'a, sqlx::Result> { - Box::pin(async move { - let mut tx = db.begin().await?; - #insert - #query_id - #query_default - tx.commit().await?; - Ok(#construct_row) - }) + db: &'c mut sqlx::MySqlConnection, + ) -> sqlx::Result { + use sqlx::Connection; + + let mut tx = db.begin().await?; + #insert + #query_id + #query_default + tx.commit().await?; + Ok(#construct_row) } } } @@ -81,13 +80,13 @@ fn query_default(table: &Table) -> TokenStream { let query_default_sql = format!( "SELECT {} FROM {} WHERE {} = ?", default_fields.map(TableField::fmt_for_select).join(", "), - table.table, + table.name(), table.id.column() ); quote! { let _generated = sqlx::query!(#query_default_sql, _id) - .fetch_one(&mut tx) + .fetch_one(&mut *tx) .await?; } } @@ -99,14 +98,14 @@ fn insert(table: &Table) -> TokenStream { let insert_sql = format!( "INSERT INTO {} ({}) VALUES ({})", - table.table, + table.name(), insert_fields.iter().map(|field| field.column()).join(", "), MySqlBindings.take(insert_fields.len()).join(", ") ); quote! { sqlx::query!(#insert_sql, #( self.#insert_field_idents, )*) - .execute(&mut tx) + .execute(&mut *tx) .await?; } } @@ -121,7 +120,7 @@ fn query_id(table: &Table) -> TokenStream { match table.id.default { true => quote! { let _id = sqlx::query!("SELECT LAST_INSERT_ID() AS id") - .fetch_one(&mut tx) + .fetch_one(&mut *tx) .await? .id; }, diff --git a/ormx-macros/src/backend/mysql/mod.rs b/ormx-macros/src/backend/mysql/mod.rs index 8ec8dde..9d73fe8 100644 --- a/ormx-macros/src/backend/mysql/mod.rs +++ b/ormx-macros/src/backend/mysql/mod.rs @@ -1,6 +1,7 @@ use std::borrow::Cow; use proc_macro2::TokenStream; +use quote::quote; use crate::{backend::Backend, table::Table}; @@ -11,9 +12,14 @@ pub struct MySqlBackend; impl Backend for MySqlBackend { const QUOTE: char = '`'; - const RESERVED_IDENTS: &'static [&'static str] = &[]; + const IS_MYSQL: bool = true; + type Bindings = MySqlBindings; + fn query_result() -> TokenStream { + quote!(sqlx::mysql::MySqlQueryResult) + } + fn impl_insert(table: &Table) -> TokenStream { insert::impl_insert(table) } diff --git a/ormx-macros/src/backend/postgres/insert.rs b/ormx-macros/src/backend/postgres/insert.rs index 875aedd..2cfa846 100644 --- a/ormx-macros/src/backend/postgres/insert.rs +++ b/ormx-macros/src/backend/postgres/insert.rs @@ -8,27 +8,6 @@ use crate::{ table::{Table, TableField}, }; -fn insert_sql(table: &Table, insert_fields: &[&TableField]) -> String { - let columns = insert_fields.iter().map(|field| field.column()).join(", "); - let fields = PgBindings::default().take(insert_fields.len()).join(", "); - let returning_fields = table - .default_fields() - .map(TableField::fmt_for_select) - .join(", "); - - if returning_fields.is_empty() { - format!( - "INSERT INTO {} ({}) VALUES ({})", - table.table, columns, fields - ) - } else { - format!( - "INSERT INTO {} ({}) VALUES ({}) RETURNING {}", - table.table, columns, fields, returning_fields - ) - } -} - pub fn impl_insert(table: &Table) -> TokenStream { let insert_ident = match &table.insertable { Some(i) => &i.ident, @@ -39,48 +18,60 @@ pub fn impl_insert(table: &Table) -> TokenStream { let default_fields: Vec<&TableField> = table.default_fields().collect(); let table_ident = &table.ident; - let insert_field_idents = insert_fields - .iter() - .map(|field| &field.field) - .collect::>(); - let default_field_idents = default_fields - .iter() - .map(|field| &field.field) - .collect::>(); - + let insert_field_idents = insert_fields.iter().map(|field| &field.field); + let default_field_idents = default_fields.iter().map(|field| &field.field); let insert_sql = insert_sql(table, &insert_fields); + let insert_field_exprs = insert_fields.iter().map(|f| f.fmt_as_argument()); - let insert_field_exprs = insert_fields - .iter() - .map(|f| f.fmt_as_argument()) - .collect::>(); - - let fetch_funtion = if default_fields.is_empty() { + let fetch_fn = if default_fields.is_empty() { Ident::new("execute", Span::call_site()) } else { Ident::new("fetch_one", Span::call_site()) }; - let box_future = crate::utils::box_future(); quote! { impl ormx::Insert for #insert_ident { type Table = #table_ident; - fn insert<'a, 'c: 'a>( + async fn insert<'a, 'c: 'a>( self, db: impl sqlx::Executor<'c, Database = ormx::Db> + 'a, - ) -> #box_future<'a, sqlx::Result> { - Box::pin(async move { - let _generated = sqlx::query!(#insert_sql, #( #insert_field_exprs, )*) - .#fetch_funtion(db) - .await?; + ) -> sqlx::Result { + let _generated = sqlx::query!(#insert_sql, #( #insert_field_exprs, )*) + .#fetch_fn(db) + .await?; - Ok(Self::Table { - #( #insert_field_idents: self.#insert_field_idents, )* - #( #default_field_idents: _generated.#default_field_idents, )* - }) + Ok(Self::Table { + #( #insert_field_idents: self.#insert_field_idents, )* + #( #default_field_idents: _generated.#default_field_idents, )* }) } } } } + +fn insert_sql(table: &Table, insert_fields: &[&TableField]) -> String { + let columns = insert_fields.iter().map(|field| field.column()).join(", "); + let fields = PgBindings::default().take(insert_fields.len()).join(", "); + let returning_fields = table + .default_fields() + .map(TableField::fmt_for_select) + .join(", "); + + if returning_fields.is_empty() { + format!( + "INSERT INTO {} ({}) VALUES ({})", + table.name(), + columns, + fields + ) + } else { + format!( + "INSERT INTO {} ({}) VALUES ({}) RETURNING {}", + table.name(), + columns, + fields, + returning_fields + ) + } +} diff --git a/ormx-macros/src/backend/postgres/mod.rs b/ormx-macros/src/backend/postgres/mod.rs index 2f6d556..af2c6f8 100644 --- a/ormx-macros/src/backend/postgres/mod.rs +++ b/ormx-macros/src/backend/postgres/mod.rs @@ -1,6 +1,7 @@ use std::borrow::Cow; use proc_macro2::TokenStream; +use quote::quote; use crate::{backend::Backend, table::Table}; @@ -11,23 +12,12 @@ pub struct PgBackend; impl Backend for PgBackend { const QUOTE: char = '"'; - #[rustfmt::skip] - const RESERVED_IDENTS: &'static [&'static str] = &[ - "ALL", "ANALYSE", "ANALYZE", "AND", "ANY", "ARRAY", "AS", "ASC", "ASYMMETRIC", - "AUTHORIZATION", "BETWEEN", "BINARY", "BOTH", "CASE", "CAST", "CHECK", "COLLATE", "COLUMN", - "CONSTRAINT", "CREATE", "CROSS", "CURRENT_DATE", "CURRENT_ROLE", "CURRENT_TIME", - "CURRENT_TIMESTAMP", "CURRENT_USER", "DEFAULT", "DEFERRABLE", "DESC", "DISTINCT", "DO", - "ELSE", "END", "EXCEPT", "FALSE", "FETCH", "FOR", "FOREIGN", "FROM", "FREEZE", "FULL", - "GRANT", "GROUP", "HAVING", "ILIKE", "IN", "INITIALLY", "INNER", "INTERSECT", "INTO", "IS", - "ISNULL", "JOIN", "LATERAL", "LEADING", "LEFT", "LIMIT", "LIKE", "LOCALTIME", - "LOCALTIMESTAMP", "NATURAL", "NOT", "NOTNULL", "NULL", "OFFSET", "ON", "ONLY", "OR", - "ORDER", "OUTER", "OVERLAPS", "PLACING", "PRIMARY", "REFERENCES", "RETURNING", "RIGHT", - "SELECT", "SESSION_USER", "SIMILAR", "SOME", "SYMMETRIC", "TABLE", "TABLESAMPLE", "THEN", - "TO", "TRAILING", "TRUE", "UNION", "UNIQUE", "USER", "USING", "VARIADIC", "VERBOSE", "WHEN", - "WHERE", "WINDOW", "WITH" - ]; type Bindings = PgBindings; + fn query_result() -> TokenStream { + quote!(sqlx::postgres::PgQueryResult) + } + fn impl_insert(table: &Table) -> TokenStream { insert::impl_insert(table) } diff --git a/ormx-macros/src/lib.rs b/ormx-macros/src/lib.rs index 19852b4..511f86b 100644 --- a/ormx-macros/src/lib.rs +++ b/ormx-macros/src/lib.rs @@ -1,4 +1,9 @@ -#![cfg(any(feature = "mysql", feature = "postgres", feature = "sqlite"))] +#![cfg(any( + feature = "mysql", + feature = "postgres", + feature = "sqlite", + feature = "mariadb" +))] mod attrs; mod backend; diff --git a/ormx-macros/src/patch/parse.rs b/ormx-macros/src/patch/parse.rs index 6a9c85e..5fce176 100644 --- a/ormx-macros/src/patch/parse.rs +++ b/ormx-macros/src/patch/parse.rs @@ -9,7 +9,7 @@ use crate::{ utils::{missing_attr, set_once}, }; -impl TryFrom<&syn::DeriveInput> for Patch { +impl TryFrom<&DeriveInput> for Patch { type Error = Error; fn try_from(value: &DeriveInput) -> Result { @@ -45,7 +45,7 @@ impl TryFrom<&syn::DeriveInput> for Patch { } } -impl TryFrom<&syn::Field> for PatchField { +impl TryFrom<&Field> for PatchField { type Error = Error; fn try_from(value: &Field) -> Result { diff --git a/ormx-macros/src/table/mod.rs b/ormx-macros/src/table/mod.rs index 36d7864..0dc82bf 100644 --- a/ormx-macros/src/table/mod.rs +++ b/ormx-macros/src/table/mod.rs @@ -1,9 +1,9 @@ -use std::{borrow::Cow, convert::TryFrom, marker::PhantomData}; +use std::{convert::TryFrom, marker::PhantomData}; use itertools::Itertools; use proc_macro2::{Ident, Span, TokenStream}; -use quote::quote; -use syn::{DeriveInput, Result, Type, Visibility, Attribute}; +use quote::{quote, ToTokens}; +use syn::{Attribute, DeriveInput, Result, Type, Visibility}; use crate::{ attrs::{Getter, Insertable}, @@ -15,20 +15,20 @@ mod parse; pub struct Table { pub ident: Ident, pub vis: Visibility, - pub table: String, + table: String, pub id: TableField, pub fields: Vec>, pub insertable: Option, - pub deletable: bool + pub deletable: bool, + pub order_by: Option, } #[derive(Clone)] pub struct TableField { pub field: Ident, pub ty: Type, - pub column_name: String, + column_name: String, pub custom_type: bool, - pub reserved_ident: bool, pub default: bool, pub get_one: Option, pub get_optional: Option, @@ -59,22 +59,28 @@ impl Table { .map(|field| field.fmt_for_select()) .join(", ") } + + pub fn name(&self) -> String { + let q = B::QUOTE; + format!("{q}{}{q}", self.table) + } } impl TableField { pub fn fmt_for_select(&self) -> String { + let q = B::QUOTE; + if self.custom_type { format!( - "{} AS {}{}: _{}", - self.column(), - B::QUOTE, - self.field, - B::QUOTE + "{q}{}{q} AS {q}{}!: {}{q}", + self.column_name, + self.field.to_string(), + self.ty.to_token_stream() ) } else if self.field == self.column_name { - self.column().into() + self.column() } else { - format!("{} AS {}", self.column(), self.field) + format!("{q}{}{q} AS {q}{}{q}", self.column_name, self.field) } } @@ -95,12 +101,9 @@ impl TableField { out } - pub fn column(&self) -> Cow { - if self.reserved_ident { - format!("{}{}{}", B::QUOTE, self.column_name, B::QUOTE).into() - } else { - Cow::Borrowed(&self.column_name) - } + pub fn column(&self) -> String { + let q = B::QUOTE; + format!("{q}{}{q}", self.column_name) } } diff --git a/ormx-macros/src/table/parse.rs b/ormx-macros/src/table/parse.rs index 4842b28..540c78f 100644 --- a/ormx-macros/src/table/parse.rs +++ b/ormx-macros/src/table/parse.rs @@ -1,7 +1,7 @@ use std::{convert::TryFrom, marker::PhantomData}; use proc_macro2::Span; -use syn::{Data, DeriveInput, Error, Ident, Result}; +use syn::{ext::IdentExt, Data, DeriveInput, Error, Ident, Result}; use super::{Table, TableField}; use crate::{ @@ -20,14 +20,6 @@ impl TryFrom<&syn::Field> for TableField { fn try_from(value: &syn::Field) -> Result { let ident = value.ident.clone().unwrap(); - let reserved_ident = B::RESERVED_IDENTS.contains(&&*ident.to_string().to_uppercase()); - if reserved_ident { - proc_macro_error::emit_warning!( - ident.span(), - "This is a reserved keyword, you might want to consider choosing a different name." - ); - } - none!( column, custom_type, @@ -48,7 +40,8 @@ impl TryFrom<&syn::Field> for TableField { TableFieldAttr::GetOptional(g) => set_once(&mut get_optional, g)?, TableFieldAttr::GetMany(g) => set_once(&mut get_many, g)?, TableFieldAttr::Set(s) => { - let default = || Ident::new(&format!("set_{}", ident), Span::call_site()); + let default = + || Ident::new(&format!("set_{}", ident.unraw()), Span::call_site()); set_once(&mut set, s.unwrap_or_else(default))? } TableFieldAttr::Default(..) => set_once(&mut default, true)?, @@ -57,11 +50,10 @@ impl TryFrom<&syn::Field> for TableField { } } Ok(TableField { - column_name: column.unwrap_or_else(|| ident.to_string()), + column_name: column.unwrap_or_else(|| ident.unraw().to_string()), field: ident, ty: value.ty.clone(), custom_type: custom_type.unwrap_or(false), - reserved_ident, default: default.unwrap_or(false), get_one, get_optional, @@ -74,7 +66,7 @@ impl TryFrom<&syn::Field> for TableField { } } -impl TryFrom<&syn::DeriveInput> for Table { +impl TryFrom<&DeriveInput> for Table { type Error = Error; fn try_from(value: &DeriveInput) -> Result { @@ -89,7 +81,7 @@ impl TryFrom<&syn::DeriveInput> for Table { .map(TableField::try_from) .collect::>>()?; - none!(table, id, insertable, deletable); + none!(table, id, insertable, deletable, order_by); for attr in parse_attrs::(&value.attrs)? { match attr { TableAttr::Table(x) => set_once(&mut table, x)?, @@ -102,6 +94,7 @@ impl TryFrom<&syn::DeriveInput> for Table { set_once(&mut insertable, x.unwrap_or_else(default))?; } TableAttr::Deletable(_) => set_once(&mut deletable, true)?, + TableAttr::OrderBy(by) => set_once(&mut order_by, by)?, } } @@ -131,7 +124,8 @@ impl TryFrom<&syn::DeriveInput> for Table { id, insertable, fields, - deletable: deletable.unwrap_or(false) + deletable: deletable.unwrap_or(false), + order_by, }) } } diff --git a/ormx-macros/src/utils.rs b/ormx-macros/src/utils.rs index 6106885..fafbf9a 100644 --- a/ormx-macros/src/utils.rs +++ b/ormx-macros/src/utils.rs @@ -1,14 +1,12 @@ -use proc_macro2::{Span, TokenStream}; -use quote::quote; +use proc_macro2::Span; use syn::{Error, Result}; -pub fn box_future() -> TokenStream { - quote!(ormx::exports::futures::future::BoxFuture) -} - -pub fn box_stream() -> TokenStream { - quote!(ormx::exports::futures::stream::BoxStream) +macro_rules! stream { + ($($t:tt)*) => { + quote!(impl ormx::exports::Stream + Send + Unpin) + }; } +pub(crate) use stream; pub fn set_once(opt: &mut Option, v: T) -> Result<()> { match opt.replace(v) { diff --git a/ormx/Cargo.toml b/ormx/Cargo.toml index 5f6a93a..9473c4b 100644 --- a/ormx/Cargo.toml +++ b/ormx/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "ormx" -version = "0.10.0" +version = "0.11.0" authors = ["Moritz Bischof"] -edition = "2018" +edition = "2021" license = "MIT" description = "lightweight procedural macros bringing orm-like features to sqlx" homepage = "https://github.com/NyxCode/ormx" @@ -14,17 +14,18 @@ targets = ["x86_64-unknown-linux-gnu"] [features] mysql = ["sqlx/mysql", "ormx-macros/mysql"] +mariadb = ["sqlx/mysql", "ormx-macros/mariadb"] sqlite = ["sqlx/sqlite", "ormx-macros/sqlite"] postgres = ["sqlx/postgres", "ormx-macros/postgres"] _docs-rs-build = ["sqlx/runtime-tokio-rustls", "postgres"] [dependencies] -ormx-macros = { path = "../ormx-macros", version = "0.10" } +ormx-macros = { path = "../ormx-macros", version = "=0.11.0" } futures = "0.3" [dependencies.sqlx] -version = "0.6" +version = "0.8" default-features = false features = ["macros"] diff --git a/ormx/src/lib.rs b/ormx/src/lib.rs index 350a4fd..19471cb 100644 --- a/ormx/src/lib.rs +++ b/ormx/src/lib.rs @@ -1,4 +1,9 @@ -#![cfg(any(feature = "mysql", feature = "postgres", feature = "sqlite"))] +#![cfg(any( + feature = "mysql", + feature = "postgres", + feature = "sqlite", + feature = "mariadb" +))] //! Lightweight derive macros for bringing orm-like features to sqlx. //! //! # Example: Table @@ -30,21 +35,23 @@ //! # Documentation //! See the docs of [derive(Table)](derive.Table.html) and [Patch](trait.Patch.html). -use futures::{future::BoxFuture, stream::BoxStream}; +use std::future::Future; + +use futures::{Stream, TryStreamExt}; pub use ormx_macros::*; -use sqlx::{Database, Executor, Result}; +use sqlx::{Executor, Result}; #[doc(hidden)] pub mod exports { - pub use futures; + pub use futures::Stream; pub use crate::query2::map::*; } -#[cfg(any(feature = "mysql", feature = "postgres"))] +#[cfg(any(feature = "mysql", feature = "postgres", feature = "mariadb"))] mod query2; -#[cfg(feature = "mysql")] +#[cfg(any(feature = "mysql", feature = "mariadb"))] pub type Db = sqlx::MySql; #[cfg(feature = "postgres")] pub type Db = sqlx::Postgres; @@ -63,10 +70,11 @@ where fn id(&self) -> Self::Id; /// Insert a row into the database. - fn insert( - db: &mut ::Connection, + fn insert<'a, 'c: 'a>( + #[cfg(not(feature = "mysql"))] db: impl Executor<'c, Database = Db> + 'a, + #[cfg(feature = "mysql")] db: &'c mut sqlx::MySqlConnection, row: impl Insert
, - ) -> BoxFuture> { + ) -> impl Future> + Send + 'a { row.insert(db) } @@ -74,69 +82,76 @@ where fn get<'a, 'c: 'a>( db: impl Executor<'c, Database = Db> + 'a, id: Self::Id, - ) -> BoxFuture<'a, Result>; + ) -> impl Future> + Send + 'a; /// Stream all rows from this table. + /// By default, results are ordered in descending order according to their ID column. + /// This can be configured using `#[ormx(order_by = "some_column ASC")]`. fn stream_all<'a, 'c: 'a>( db: impl Executor<'c, Database = Db> + 'a, - ) -> BoxStream<'a, Result>; + ) -> impl Stream> + Send + 'a; + /// Streams at most `limit` rows from this table, skipping the first `offset` rows. + /// By default, results are ordered in descending order according to their ID column. + /// This can be configured using `#[ormx(order_by = "some_column ASC")]`. fn stream_all_paginated<'a, 'c: 'a>( db: impl Executor<'c, Database = Db> + 'a, offset: i64, limit: i64, - ) -> BoxStream<'a, Result>; + ) -> impl Stream> + Send + 'a; /// Load all rows from this table. + /// By default, results are ordered in descending order according to their ID column. + /// This can be configured using `#[ormx(order_by = "some_column ASC")]`. fn all<'a, 'c: 'a>( db: impl Executor<'c, Database = Db> + 'a, - ) -> BoxFuture<'a, Result>> { - use futures::TryStreamExt; - - Box::pin(Self::stream_all(db).try_collect()) + ) -> impl Future>> + Send + 'a { + Self::stream_all(db).try_collect() } + /// Load at most `limit` rows from this table, skipping the first `offset`. + /// By default, results are ordered in descending order according to their ID column. + /// This can be configured using `#[ormx(order_by = "some_column ASC")]`. fn all_paginated<'a, 'c: 'a>( db: impl Executor<'c, Database = Db> + 'a, offset: i64, limit: i64, - ) -> BoxFuture<'a, Result>> { - use futures::TryStreamExt; - - Box::pin(Self::stream_all_paginated(db, offset, limit).try_collect()) + ) -> impl Future>> + Send + 'a { + Self::stream_all_paginated(db, offset, limit).try_collect() } + /// Applies a patch to this row. fn patch<'a, 'c: 'a, P>( &'a mut self, db: impl Executor<'c, Database = Db> + 'a, patch: P, - ) -> BoxFuture<'a, Result<()>> + ) -> impl Future> + Send + 'a where P: Patch
, { - Box::pin(async move { + async move { let patch: P = patch; - patch.patch_row(db, self.id()).await?; + patch.patch_row(db, self.id()).send().await?; patch.apply_to(self); Ok(()) - }) + } } /// Updates all fields of this row, regardless if they have been changed or not. fn update<'a, 'c: 'a>( &'a self, db: impl Executor<'c, Database = Db> + 'a, - ) -> BoxFuture<'a, Result<()>>; + ) -> impl Future> + Send + 'a; - // Refresh this row, querying all columns from the database. + /// Refresh this row, querying all columns from the database. fn reload<'a, 'c: 'a>( &'a mut self, db: impl Executor<'c, Database = Db> + 'a, - ) -> BoxFuture<'a, Result<()>> { - Box::pin(async move { - *self = Self::get(db, self.id()).await?; + ) -> impl Future> + Send + 'a { + async move { + *self = Self::get(db, self.id()).send().await?; Ok(()) - }) + } } } @@ -148,13 +163,13 @@ where fn delete_row<'a, 'c: 'a>( db: impl Executor<'c, Database = Db> + 'a, id: Self::Id, - ) -> BoxFuture<'a, Result<()>>; + ) -> impl Future> + Send + 'a; /// Deletes this row from the database fn delete<'a, 'c: 'a>( self, db: impl Executor<'c, Database = Db> + 'a, - ) -> BoxFuture<'a, Result<()>> { + ) -> impl Future> + Send + 'a { Self::delete_row(db, self.id()) } @@ -162,7 +177,7 @@ where fn delete_ref<'a, 'c: 'a>( &self, db: impl Executor<'c, Database = Db> + 'a, - ) -> BoxFuture<'a, Result<()>> { + ) -> impl Future> + Send + 'a { Self::delete_row(db, self.id()) } } @@ -175,7 +190,7 @@ where type Table: Table; /// Applies the data of this patch to the given entity. - /// This does not persist the change in the database. + /// This does not persist the change in the database. fn apply_to(self, entity: &mut Self::Table); /// Applies this patch to a row in the database. @@ -183,7 +198,7 @@ where &'a self, db: impl Executor<'c, Database = Db> + 'a, id: ::Id, - ) -> BoxFuture<'a, Result<()>>; + ) -> impl Future> + Send + 'a; } /// A type which can be inserted as a row into the database. @@ -196,6 +211,19 @@ where /// Insert a row into the database, returning the inserted row. fn insert<'a, 'c: 'a>( self, - db: impl Executor<'c, Database = Db> + 'a, - ) -> BoxFuture<'a, Result>; + #[cfg(not(feature = "mysql"))] db: impl Executor<'c, Database = Db> + 'a, + #[cfg(feature = "mysql")] db: &'c mut sqlx::MySqlConnection, + ) -> impl Future> + Send + 'a; } + +// Ridiculous workaround for [#100013](https://github.com/rust-lang/rust/issues/100013#issuecomment-2210995259). +trait SendFuture: Future { + fn send(self) -> impl Future + Send + where + Self: Sized + Send, + { + self + } +} + +impl SendFuture for T {} diff --git a/ormx/src/query2/map.rs b/ormx/src/query2/map.rs index 99ab0cd..203ab51 100644 --- a/ormx/src/query2/map.rs +++ b/ormx/src/query2/map.rs @@ -1,4 +1,4 @@ -use futures::stream::BoxStream; +use futures::Stream; use sqlx::{Database, Executor, IntoArguments, Error, query::Map}; #[doc(hidden)] @@ -45,7 +45,7 @@ macro_rules! make_conditional_map_ty { $fa: 'q + Send + IntoArguments<'q, DB>, )* { - pub fn fetch<'e, 'c: 'e, E>(self, executor: E) -> BoxStream<'e, sqlx::Result> + pub fn fetch<'e, 'c: 'e, E>(self, executor: E) -> impl Stream> + Send + Unpin + 'e where 'q: 'e, E: 'e + Executor<'c, Database = DB>, diff --git a/ormx/src/query2/mod.rs b/ormx/src/query2/mod.rs index 0b0cf65..63c20ab 100644 --- a/ormx/src/query2/mod.rs +++ b/ormx/src/query2/mod.rs @@ -1,7 +1,7 @@ #[doc(hidden)] #[rustfmt::skip] pub mod map; -#[cfg(feature = "mysql")] +#[cfg(any(feature = "mysql", feature = "mariadb"))] mod mysql; #[cfg(feature = "postgres")] mod postgres; diff --git a/scripts/mariadb.sh b/scripts/mariadb.sh index 223df8b..c01cb61 100755 --- a/scripts/mariadb.sh +++ b/scripts/mariadb.sh @@ -1,10 +1,7 @@ -CONTAINER_ID=$( - docker run -it --rm --name ormx-test-mariadb-db \ - -e MYSQL_DATABASE=ormx \ - -e MYSQL_ROOT_PASSWORD=admin \ - -v $(pwd)/scripts/mysql-schema.sql:/docker-entrypoint-initdb.d/schema.sql \ - -d mariadb -) -CONTAINER_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $CONTAINER_ID) -echo "DATABASE_URL=mysql://root:admin@$CONTAINER_IP/ormx" > .env -docker attach $CONTAINER_ID +echo "DATABASE_URL=mysql://root:admin@127.0.0.1/ormx" > .env + +docker run -it --rm --name ormx-test-mariadb-db \ + -e MYSQL_DATABASE=ormx \ + -e MYSQL_ROOT_PASSWORD=admin \ + -p 3306:3306 \ + mariadb:latest \ No newline at end of file diff --git a/scripts/mysql.sh b/scripts/mysql.sh new file mode 100755 index 0000000..16a1a48 --- /dev/null +++ b/scripts/mysql.sh @@ -0,0 +1,7 @@ +echo "DATABASE_URL=mysql://root:admin@127.0.0.1/ormx" > .env + +docker run -it --rm --name ormx-test-mysql-db \ + -e MYSQL_DATABASE=ormx \ + -e MYSQL_ROOT_PASSWORD=admin \ + -p 3306:3306 \ + mysql:latest \ No newline at end of file diff --git a/scripts/postgres.sh b/scripts/postgres.sh index a3d3358..f4f728f 100755 --- a/scripts/postgres.sh +++ b/scripts/postgres.sh @@ -1,11 +1,9 @@ -CONTAINER_ID=$( - docker run -it --rm --name ormx-test-postgres-db \ - -e POSTGRES_DB=ormx \ - -e POSTGRES_USER=postgres \ - -e POSTGRES_PASSWORD=admin \ - -v $(pwd)/scripts/postgres-schema.sql:/docker-entrypoint-initdb.d/schema.sql \ - -d postgres -) -CONTAINER_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $CONTAINER_ID) -echo "DATABASE_URL=postgres://postgres:admin@$CONTAINER_IP/ormx" > .env -docker attach $CONTAINER_ID \ No newline at end of file +echo "DATABASE_URL=postgres://postgres:admin@127.0.0.1/ormx" > .env + +docker run -it --rm --name ormx-test-postgres-db \ + -e POSTGRES_DB=ormx \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=admin \ + -p 5432:5432 \ + postgres:latest \ + -c log_statement=all \ No newline at end of file