diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index d4c11de9..bd25127b 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -55,10 +55,11 @@ on: push: branches: - master - - 0.2.x + - 0.*.x + - pr/**/ci concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + group: ${{ github.workflow }}-${{ github.head_ref || github.ref || github.run_id }} cancel-in-progress: true env: @@ -393,6 +394,10 @@ jobs: toolchain: stable override: true + - name: check rustfmt + run: | + cargo fmt --manifest-path ${{ matrix.path }} --all -- --check + - uses: actions-rs/cargo@v1 with: command: build @@ -405,11 +410,14 @@ jobs: args: > --manifest-path ${{ matrix.path }} - - name: check rustfmt - run: | - rustup override set nightly - rustup component add rustfmt - cargo +nightly fmt --manifest-path ${{ matrix.path }} --all -- --check + - name: Run mock test if it is core crate + uses: actions-rs/cargo@v1 + if: ${{ contains(matrix.path, 'core/Cargo.toml') }} + with: + command: test + args: > + --manifest-path ${{ matrix.path }} + --features mock issues-matrix: name: Issues Matrix diff --git a/CHANGELOG.md b/CHANGELOG.md index a09d389f..69c17f7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,58 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## 0.10.0 - Pending +### New Features +* Better error types (carrying SQLx Error) https://github.com/SeaQL/sea-orm/pull/1002 +* [sea-orm-cli] Generate entity files as a library or module https://github.com/SeaQL/sea-orm/pull/953 +* [sea-orm-cli] Generate a new migration template with name prefix of unix timestamp https://github.com/SeaQL/sea-orm/pull/947 +* [sea-orm-cli] Generate migration in modules https://github.com/SeaQL/sea-orm/pull/933 +* [sea-orm-cli] Generate `DeriveRelation` on empty `Relation` enum https://github.com/SeaQL/sea-orm/pull/1019 +* [sea-orm-cli] Generate entity derive `Eq` if possible https://github.com/SeaQL/sea-orm/pull/988 +* Run migration on any PostgreSQL schema https://github.com/SeaQL/sea-orm/pull/1056 + +### Enhancements + +* Support `distinct` & `distinct_on` expression https://github.com/SeaQL/sea-orm/pull/902 +* `fn column()` also handle enum type https://github.com/SeaQL/sea-orm/pull/973 +* Added `acquire_timeout` on `ConnectOptions` https://github.com/SeaQL/sea-orm/pull/897 +* `migrate fresh` command will drop all PostgreSQL types https://github.com/SeaQL/sea-orm/pull/864, https://github.com/SeaQL/sea-orm/pull/991 +* Better compile error for entity without primary key https://github.com/SeaQL/sea-orm/pull/1020 +* Added blanket implementations of `IntoActiveValue` for `Option` values https://github.com/SeaQL/sea-orm/pull/833 +* Added `into_model` & `into_json` to `Cursor` https://github.com/SeaQL/sea-orm/pull/1112 +* Added `set_schema_search_path` method to `ConnectOptions` for setting schema search path of PostgreSQL connection https://github.com/SeaQL/sea-orm/pull/1056 + +### Bug fixes + +* Trim spaces when paginating raw SQL https://github.com/SeaQL/sea-orm/pull/1094 + ### Breaking changes * Replaced `usize` with `u64` in `PaginatorTrait` https://github.com/SeaQL/sea-orm/pull/789 +* Type signature of `DbErr` changed as a result of https://github.com/SeaQL/sea-orm/pull/1002 + +### House keeping + +* Documentation grammar fixes https://github.com/SeaQL/sea-orm/pull/1050 +* Replace `dotenv` with `dotenvy` in examples https://github.com/SeaQL/sea-orm/pull/1085 +* Exclude test_cfg module from SeaORM https://github.com/SeaQL/sea-orm/pull/1077 + +### Integration + +* Support `rocket_okapi` https://github.com/SeaQL/sea-orm/pull/1071 + +## 0.9.3 - 2022-09-30 ### Enhancements * `fn column()` also handle enum type https://github.com/SeaQL/sea-orm/pull/973 +* Generate migration in modules https://github.com/SeaQL/sea-orm/pull/933 +* Generate `DeriveRelation` on empty `Relation` enum https://github.com/SeaQL/sea-orm/pull/1019 +* Documentation grammar fixes https://github.com/SeaQL/sea-orm/pull/1050 + +### Bug fixes + +* Implement `IntoActiveValue` for `time` types https://github.com/SeaQL/sea-orm/pull/1041 +* Fixed module import for `FromJsonQueryResult` derive macro https://github.com/SeaQL/sea-orm/pull/1081 ## 0.9.2 - 2022-08-20 @@ -93,7 +138,7 @@ In this minor release, we removed `time` v0.1 from the dependency graph * `SelectTwoMany::one()` has been dropped https://github.com/SeaQL/sea-orm/pull/813, you can get `(Entity, Vec)` by first querying a single model from Entity, then use [`ModelTrait::find_related`] on the model. * #### Feature flag revamp - We now adopt the [weak dependency](https://blog.rust-lang.org/2022/04/07/Rust-1.60.0.html#new-syntax-for-cargo-features) syntax in Cargo. That means the flags `["sqlx-json", "sqlx-chrono", "sqlx-decimal", "sqlx-uuid", "sqlx-time"]` are not needed and now removed. Instead, `with-time` will enable `sqlx?/time` only if `sqlx` is already enabled. As a consequence, now the features `with-json`, `with-chrono`, `with-rust_decimal`, `with-uuid`, `with-time` will not be enabled as a side-effects of enabling `sqlx`. + We now adopt the [weak dependency](https://blog.rust-lang.org/2022/04/07/Rust-1.60.0.html#new-syntax-for-cargo-features) syntax in Cargo. That means the flags `["sqlx-json", "sqlx-chrono", "sqlx-decimal", "sqlx-uuid", "sqlx-time"]` are not needed and now removed. Instead, `with-time` will enable `sqlx?/time` only if `sqlx` is already enabled. As a consequence, now the features `with-json`, `with-chrono`, `with-rust_decimal`, `with-uuid`, `with-time` will not be enabled as a side-effect of enabling `sqlx`. ## sea-orm-migration 0.8.3 @@ -186,11 +231,11 @@ In this minor release, we removed `time` v0.1 from the dependency graph ### Fixed Issues * orm-cli generated incorrect type for #[sea_orm(primary_key)]. Should be u64. Was i64. https://github.com/SeaQL/sea-orm/issues/295 -* how to update dynamicly from json value https://github.com/SeaQL/sea-orm/issues/346 +* how to update dynamically from json value https://github.com/SeaQL/sea-orm/issues/346 * Make `DatabaseConnection` `Clone` with the default features enabled https://github.com/SeaQL/sea-orm/issues/438 -* Updating mutiple fields in a Model by passing a reference https://github.com/SeaQL/sea-orm/issues/460 +* Updating multiple fields in a Model by passing a reference https://github.com/SeaQL/sea-orm/issues/460 * SeaORM CLI not adding serde derives to Enums https://github.com/SeaQL/sea-orm/issues/461 -* sea-orm-cli generates wrong datatype for nullable blob https://github.com/SeaQL/sea-orm/issues/490 +* sea-orm-cli generates wrong data type for nullable blob https://github.com/SeaQL/sea-orm/issues/490 * Support the time crate in addition (instead of?) chrono https://github.com/SeaQL/sea-orm/issues/499 * PaginatorTrait for SelectorRaw https://github.com/SeaQL/sea-orm/issues/500 * sea_orm::DatabaseConnection should implement `Clone` by default https://github.com/SeaQL/sea-orm/issues/517 @@ -198,7 +243,7 @@ In this minor release, we removed `time` v0.1 from the dependency graph * Datetime fields are not serialized by `.into_json()` on queries https://github.com/SeaQL/sea-orm/issues/530 * Update / Delete by id https://github.com/SeaQL/sea-orm/issues/552 * `#[sea_orm(indexed)]` only works for MySQL https://github.com/SeaQL/sea-orm/issues/554 -* `sea-orm-cli generate --with-serde` does not work on Postegresql custom type https://github.com/SeaQL/sea-orm/issues/581 +* `sea-orm-cli generate --with-serde` does not work on Postgresql custom type https://github.com/SeaQL/sea-orm/issues/581 * `sea-orm-cli generate --expanded-format` panic when postgres table contains enum type https://github.com/SeaQL/sea-orm/issues/614 * UUID fields are not serialized by `.into_json()` on queries https://github.com/SeaQL/sea-orm/issues/619 @@ -241,7 +286,7 @@ In this minor release, we removed `time` v0.1 from the dependency graph ## 0.5.0 - 2022-01-01 ### Fixed Issues -* Why insert, update, etc return a ActiveModel instead of Model? https://github.com/SeaQL/sea-orm/issues/289 +* Why insert, update, etc return an ActiveModel instead of Model? https://github.com/SeaQL/sea-orm/issues/289 * Rework `ActiveValue` https://github.com/SeaQL/sea-orm/issues/321 * Some missing ActiveEnum utilities https://github.com/SeaQL/sea-orm/issues/338 @@ -257,7 +302,7 @@ In this minor release, we removed `time` v0.1 from the dependency graph * Add docker create script for contributors to setup databases locally by @billy1624 in https://github.com/SeaQL/sea-orm/pull/378 * Log with tracing-subscriber by @billy1624 in https://github.com/SeaQL/sea-orm/pull/399 * Codegen SQLite by @billy1624 in https://github.com/SeaQL/sea-orm/pull/386 -* PR without clippy warmings in file changed tab by @billy1624 in https://github.com/SeaQL/sea-orm/pull/401 +* PR without clippy warnings in file changed tab by @billy1624 in https://github.com/SeaQL/sea-orm/pull/401 * Rename `sea-strum` lib back to `strum` by @billy1624 in https://github.com/SeaQL/sea-orm/pull/361 ### Breaking Changes @@ -275,7 +320,7 @@ In this minor release, we removed `time` v0.1 from the dependency graph ### Fixed Issues * Delete::many() doesn't work when schema_name is defined https://github.com/SeaQL/sea-orm/issues/362 * find_with_related panic https://github.com/SeaQL/sea-orm/issues/374 -* How to define rust type of TIMESTAMP? https://github.com/SeaQL/sea-orm/issues/344 +* How to define the rust type of TIMESTAMP? https://github.com/SeaQL/sea-orm/issues/344 * Add Table on the generated Column enum https://github.com/SeaQL/sea-orm/issues/356 ### Merged PRs @@ -317,7 +362,7 @@ In this minor release, we removed `time` v0.1 from the dependency graph * Codegen fix clippy warnings by @billy1624 in https://github.com/SeaQL/sea-orm/pull/303 * Add axum example by @YoshieraHuang in https://github.com/SeaQL/sea-orm/pull/297 * Enumeration by @billy1624 in https://github.com/SeaQL/sea-orm/pull/258 -* Add `PaginatorTrait` and `CountTrait` for more constrains by @YoshieraHuang in https://github.com/SeaQL/sea-orm/pull/306 +* Add `PaginatorTrait` and `CountTrait` for more constraints by @YoshieraHuang in https://github.com/SeaQL/sea-orm/pull/306 * Continue `PaginatorTrait` by @billy1624 in https://github.com/SeaQL/sea-orm/pull/307 * Refactor `Schema` by @billy1624 in https://github.com/SeaQL/sea-orm/pull/309 * Detailed connection errors by @billy1624 in https://github.com/SeaQL/sea-orm/pull/312 @@ -328,7 +373,7 @@ In this minor release, we removed `time` v0.1 from the dependency graph * Returning by @billy1624 in https://github.com/SeaQL/sea-orm/pull/292 ### Breaking Changes -* Refactor `paginate()` & `count()` utilities into `PaginatorTrait`. You can use the paginator as usual but you might need to import `PaginatorTrait` manually when upgrading from previous version. +* Refactor `paginate()` & `count()` utilities into `PaginatorTrait`. You can use the paginator as usual but you might need to import `PaginatorTrait` manually when upgrading from the previous version. ```rust use futures::TryStreamExt; use sea_orm::{entity::*, query::*, tests_cfg::cake}; @@ -342,7 +387,7 @@ In this minor release, we removed `time` v0.1 from the dependency graph // Do something on cakes: Vec } ``` -* The helper struct `Schema` converting `EntityTrait` into different `sea-query` statement now has to be initialized with `DbBackend`. +* The helper struct `Schema` converting `EntityTrait` into different `sea-query` statements now has to be initialized with `DbBackend`. ```rust use sea_orm::{tests_cfg::*, DbBackend, Schema}; use sea_orm::sea_query::TableCreateStatement; @@ -414,7 +459,7 @@ In this minor release, we removed `time` v0.1 from the dependency graph (We are changing our Changelog format from now on) ### Fixed Issues -* Align case trasforms across derive macros https://github.com/SeaQL/sea-orm/issues/262 +* Align case transforms across derive macros https://github.com/SeaQL/sea-orm/issues/262 * Added `is_null` and `is_not_null` to `ColumnTrait` https://github.com/SeaQL/sea-orm/issues/267 (The following is generated by GitHub) @@ -512,7 +557,7 @@ https://www.sea-ql.org/SeaORM/blog/2021-10-01-whats-new-in-0.2.4 - [[#191]] [sea-orm-cli] Unique key handling - [[#182]] `find_linked` join with alias - [[#202]] Accept both `postgres://` and `postgresql://` -- [[#208]] Support feteching T, (T, U), (T, U, P) etc +- [[#208]] Support fetching T, (T, U), (T, U, P) etc - [[#209]] Rename column name & column enum variant - [[#207]] Support `chrono::NaiveDate` & `chrono::NaiveTime` - Support `Condition::not` (from sea-query) @@ -528,7 +573,7 @@ https://www.sea-ql.org/SeaORM/blog/2021-10-01-whats-new-in-0.2.4 ## 0.2.3 - 2021-09-22 - [[#152]] DatabaseConnection impl `Clone` -- [[#175]] Impl `TryGetableMany` for diffrent types of generics +- [[#175]] Impl `TryGetableMany` for different types of generics - Codegen `TimestampWithTimeZone` fixup [#152]: https://github.com/SeaQL/sea-orm/issues/152 diff --git a/COMMUNITY.md b/COMMUNITY.md index 347d1e51..e12fe1a1 100644 --- a/COMMUNITY.md +++ b/COMMUNITY.md @@ -7,58 +7,60 @@ If you have built an app using SeaORM and want to showcase it, feel free to open ### Startups - [Caido](https://caido.io/) | A lightweight web security auditing toolkit -- [Svix](https://www.svix.com/) ([repository](https://github.com/svix/svix-webhooks)) ![GitHub stars](https://img.shields.io/github/stars/svix/svix-webhooks.svg?style=social) | The enterprise ready webhooks service -- [Sensei](https://l2.technology/sensei) ([repository](https://github.com/L2-Technology/sensei)) ![GitHub stars](https://img.shields.io/github/stars/L2-Technology/sensei.svg?style=social) | A Bitcoin lightning node implementation -- [Spyglass](https://docs.spyglass.fyi/) ([repository](https://github.com/a5huynh/spyglass)) ![GitHub stars](https://img.shields.io/github/stars/a5huynh/spyglass.svg?style=social) | 🔭 A personal search engine that indexes what you want w/ a simple set of rules. +- [Svix](https://www.svix.com/) ([repository](https://github.com/svix/svix-webhooks)) ![GitHub stars](https://img.shields.io/github/stars/svix/svix-webhooks.svg?style=social) | The enterprise ready webhooks service | DB: Postgres +- [Sensei](https://l2.technology/sensei) ([repository](https://github.com/L2-Technology/sensei)) ![GitHub stars](https://img.shields.io/github/stars/L2-Technology/sensei.svg?style=social) | A Bitcoin lightning node implementation | DB: MySQL, Postgres, SQLite +- [Spyglass](https://docs.spyglass.fyi/) ([repository](https://github.com/a5huynh/spyglass)) ![GitHub stars](https://img.shields.io/github/stars/a5huynh/spyglass.svg?style=social) | 🔭 A personal search engine that indexes what you want w/ a simple set of rules. | DB: SQLite - [My Data My Consent](https://mydatamyconsent.com/) | Online data sharing for people and businesses simplified -- [CodeCTRL](https://codectrl.authentura.com) ([repository](https://github.com/Authentura/codectrl)) | A self-hostable code logging platform +- [CodeCTRL](https://codectrl.authentura.com) ([repository](https://github.com/Authentura/codectrl)) | A self-hostable code logging platform | DB: SQLite ### Frameworks -- [awto](https://github.com/awto-rs/awto) | Awtomate your 🦀 microservices with awto -- [tardis](https://github.com/ideal-world/tardis) | Elegant, Clean Rust development framework🛸 -- [Rust Async-GraphQL Example: Caster API](https://github.com/bkonkle/rust-example-caster-api) | A demo GraphQL API using Tokio, Warp, async-graphql, and SeaOrm -- [Quasar](https://github.com/Technik97/Quasar) | Rust REST API using Actix-Web, SeaOrm and Postgres with Svelte Typescript Frontend -- [actix-react-starter-template](https://github.com/aslamplr/actix-react-starter-template) | Actix web + SeaORM + React + Redux + Redux Saga project starter template +- [awto](https://github.com/awto-rs/awto) | Awtomate your 🦀 microservices with awto | DB: Postgres +- [tardis](https://github.com/ideal-world/tardis) | Elegant, Clean Rust development framework🛸 | DB: MySQL, Postgres, SQLite +- [Rust Async-GraphQL Example: Caster API](https://github.com/bkonkle/rust-example-caster-api) | A demo GraphQL API using Tokio, Warp, async-graphql, and SeaOrm | DB: Postgres +- [Quasar](https://github.com/Technik97/Quasar) | Rust REST API using Actix-Web, SeaOrm and Postgres with Svelte Typescript Frontend | DB: Postgres +- [actix-react-starter-template](https://github.com/aslamplr/actix-react-starter-template) | Actix web + SeaORM + React + Redux + Redux Saga project starter template | DB: Postgres - [ZAPP](https://zapp.epics.dev) ([repository](https://github.com/EpicsDAO/zapp)) | ZAPP is a serverless framework made by Rust. Quickly build a scalable GraphQL API web server. ### Open Source Projects -- [Wikijump](https://github.com/scpwiki/wikijump) ([repository](https://github.com/scpwiki/wikijump/tree/develop/deepwell)) | API service for Wikijump, a fork of Wikidot -- [aeroFans](https://github.com/naryand/aerofans) | Full stack forum-like social media platform in Rust and WebAssembly -- [thrpg](https://github.com/thrpg/thrpg) | Touhou Project's secondary creative games -- [Adta](https://github.com/aaronleopold/adta) | Adta is **A**nother **D**amn **T**odo **A**pp, fun little side project -- [Axum-Book-Management](https://github.com/lz1998/axum-book-management) | CRUD system of book-management with ORM and JWT for educational purposes -- [mediarepo](https://mediarepo.trivernis.dev) ([repository](https://github.com/Trivernis/mediarepo)) | A tag-based media management application -- [THUBurrow](https://thuburrow.com) ([repository](https://github.com/BobAnkh/THUBurrow)) | A campus forum built by Next.js and Rocket -- [Backpack](https://github.com/JSH32/Backpack) | Open source self hosted file sharing platform on crack +- [Wikijump](https://github.com/scpwiki/wikijump) ([repository](https://github.com/scpwiki/wikijump/tree/develop/deepwell)) | API service for Wikijump, a fork of Wikidot | DB: Postgres +- [aeroFans](https://github.com/naryand/aerofans) | Full stack forum-like social media platform in Rust and WebAssembly | DB: Postgres +- [thrpg](https://github.com/thrpg/thrpg) | Touhou Project's secondary creative games | DB: Postgres +- [Adta](https://github.com/aaronleopold/adta) | Adta is **A**nother **D**amn **T**odo **A**pp, fun little side project | DB: MySQL, Postgres, SQLite +- [Axum-Book-Management](https://github.com/lz1998/axum-book-management) | CRUD system of book-management with ORM and JWT for educational purposes | DB: MySQL +- [mediarepo](https://mediarepo.trivernis.dev) ([repository](https://github.com/Trivernis/mediarepo)) | A tag-based media management application | DB: SQLite +- [THUBurrow](https://thuburrow.com) ([repository](https://github.com/BobAnkh/THUBurrow)) | A campus forum built by Next.js and Rocket | DB: Postgres +- [Backpack](https://github.com/JSH32/Backpack) | Open source self hosted file sharing platform on crack | DB: MySQL, Postgres, SQLite - [Stump](https://github.com/aaronleopold/stump) ![GitHub stars](https://img.shields.io/github/stars/aaronleopold/stump.svg?style=social) | A free and open source comics server with OPDS support -- [mugen](https://github.com/koopa1338/mugen-dms) | DMS written in 🦀 -- [JinShu](https://github.com/gengteng/jinshu) | A cross-platform **I**nstant **M**essaging system written in 🦀 -- [rust-juniper-playground](https://github.com/Yama-Tomo/rust-juniper-playground) | juniper with SeaORM example -- [Oura Postgres Sink](https://github.com/dcSpark/oura-postgres-sink) | Sync a postgres database with the cardano blockchain using [Oura](https://github.com/txpipe/oura) -- [pansy](https://github.com/niuhuan/pansy) | An illustrations app using SeaORM, SQLite, flutter. runs on the desktop and mobile terminals -- [Orca](https://github.com/workfoxes/orca) | An No-code Test Automation platfrom using actix, SeaORM, react. runs on the desktop and cloud -- [symbols](https://github.com/nappa85/symbols) | A proc-macro utility to populates enum variants with primary keys values -- [Warpgate](https://github.com/warp-tech/warpgate) ![GitHub stars](https://img.shields.io/github/stars/warp-tech/warpgate.svg?style=social) | Smart SSH bastion that works with any SSH client -- [suzuya](https://github.com/SH11235/suzuya) | A merchandise management application using SeaORM, Actix-Web, Tera -- [snmp-sim-rust](https://github.com/sonalake/snmp-sim-rust) | SNMP Simulator -- [template_flow](https://github.com/hilary888/template_flow) | An experiment exploring replacing placeholders in pre-prepared templates with their actual values -- [poem_admin](https://github.com/lingdu1234/poem_admin) | An admin panel built with poem, **Sea-orm** and Vue 3. -- [VeryRezsi](https://github.com/szattila98/veryrezsi) | VeryRezsi is a subscription and expense calculator web-application. Powered by SvelteKit on client side, and Rust on server side. -- [todo-rs](https://github.com/anshulxyz/todo-rs/) | A TUI ToDo-app written in Rust using Cursive library and SeaORM for SQLite -- [KrakenPics](https://github.com/kraken-pics/backend) | A public file host written in rust using seaorm & actix_web -- [service_auth](https://github.com/shorii/service_auth) | A simple JWT authentication web-application -- [rj45less-server](https://github.com/pmnxis/rj45less-server) | A simple unique number allocator for custom router -- [SophyCore](https://github.com/FarDragi/SophyCore) | Main system that centralizes all rules, to be used by both the discord bot and the future site +- [mugen](https://github.com/koopa1338/mugen-dms) | DMS written in 🦀 | DB: Postgres +- [JinShu](https://github.com/gengteng/jinshu) | A cross-platform **I**nstant **M**essaging system written in 🦀 | DB: MySQL, Postgres +- [rust-juniper-playground](https://github.com/Yama-Tomo/rust-juniper-playground) | juniper with SeaORM example | DB: MySQL +- [Oura Postgres Sink](https://github.com/dcSpark/oura-postgres-sink) | Sync a postgres database with the cardano blockchain using [Oura](https://github.com/txpipe/oura) | DB: Postgres +- [pansy](https://github.com/niuhuan/pansy) | An illustration app using SeaORM, SQLite, flutter. runs on the desktop and mobile terminals | DB: SQLite +- [Orca](https://github.com/workfoxes/orca) | An No-code Test Automation platform using actix, SeaORM, react. runs on the desktop and cloud | DB: Postgres +- [symbols](https://github.com/nappa85/symbols) | A proc-macro utility to populates enum variants with primary keys values | DB: MySQL +- [Warpgate](https://github.com/warp-tech/warpgate) ![GitHub stars](https://img.shields.io/github/stars/warp-tech/warpgate.svg?style=social) | Smart SSH bastion that works with any SSH client | DB: SQLite +- [suzuya](https://github.com/SH11235/suzuya) | A merchandise management application using SeaORM, Actix-Web, Tera | DB: Postgres +- [snmp-sim-rust](https://github.com/sonalake/snmp-sim-rust) | SNMP Simulator | DB: SQLite +- [template_flow](https://github.com/hilary888/template_flow) | An experiment exploring replacing placeholders in pre-prepared templates with their actual values | DB: Postgres +- [poem_admin](https://github.com/lingdu1234/poem_admin) | An admin panel built with poems, **Sea-orm** and Vue 3. | DB: MySQL, Postgres, SQLite +- [VeryRezsi](https://github.com/szattila98/veryrezsi) | VeryRezsi is a subscription and expense calculator web-application. Powered by SvelteKit on client side, and Rust on server side. | DB: MySQL +- [todo-rs](https://github.com/anshulxyz/todo-rs/) | A TUI ToDo-app written in Rust using Cursive library and SeaORM for SQLite | DB: SQLite +- [KrakenPics](https://github.com/kraken-pics/backend) | A public file host written in rust using seaorm & actix_web | DB: MySQL +- [service_auth](https://github.com/shorii/service_auth) | A simple JWT authentication web-application | DB: Postgres +- [rj45less-server](https://github.com/pmnxis/rj45less-server) | A simple unique number allocator for custom router | DB: SQLite +- [SophyCore](https://github.com/FarDragi/SophyCore) | Main system that centralizes all rules, to be used by both the discord bot and the future site | DB: Postgres - [lldap](https://github.com/nitnelave/lldap) ![GitHub stars](https://img.shields.io/github/stars/nitnelave/lldap.svg?style=social) | Light LDAP implementation for authentication -- [nitro_repo](https://github.com/wyatt-herkamp/nitro_repo) | An OpenSource, lightweight, and fast artifact manager. -- [MoonRamp](https://github.com/MoonRamp/MoonRamp) | A free and open source crypto payment gateway -- [url_shortener](https://github.com/michidk/url_shortener) | A simple self-hosted URL shortener written in Rust +- [nitro_repo](https://github.com/wyatt-herkamp/nitro_repo) | An OpenSource, lightweight, and fast artifact manager. | DB: MySQL, SQLite +- [MoonRamp](https://github.com/MoonRamp/MoonRamp) | A free and open source crypto payment gateway | DB: MySQL, Postgres, SQLite +- [url_shortener](https://github.com/michidk/url_shortener) | A simple self-hosted URL shortener written in Rust | DB: MySQL, Postgres, SQLite +- [RGB Lib](https://github.com/RGB-Tools/rgb-lib) | A library to manage wallets for RGB assets | DB: MySQL, Postgres, SQLite +- [RCloud](https://github.com/p0rtL6/RCloud) | A self-hosted lightweight cloud drive alternative ## Learning Resources -If you have article, tutorial, podcast or video reated to SeaORM and want to share it with the community, feel free to submit a PR and add it to the list below! +If you have an article, tutorial, podcast or video related to SeaORM and want to share it with the community, feel free to submit a PR and add it to the list below! ### Tutorials diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 76a51be9..e2907156 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,11 +10,11 @@ This project is governed by the [SeaQL Code of Conduct](https://github.com/SeaQL ## I have a question -If you got a question to ask, please do not open an issue for it. It's quicker to ask us on [SeaQL Discord Server](https://discord.com/invite/uCPdDXzbdv) or open a [GitHub Discussion](https://docs.github.com/en/discussions/quickstart#creating-a-new-discussion) on the corresponding repository. +If you have a question to ask, please do not open an issue for it. It's quicker to ask us on [SeaQL Discord Server](https://discord.com/invite/uCPdDXzbdv) or open a [GitHub Discussion](https://docs.github.com/en/discussions/quickstart#creating-a-new-discussion) on the corresponding repository. ## I need a feature -Feature request from anyone is definitely welcomed! Actually, since 0.2, many features are proposed and/or contributed by non-core members, e.g. [#105](https://github.com/SeaQL/sea-orm/issues/105), [#142](https://github.com/SeaQL/sea-orm/issues/142), [#252](https://github.com/SeaQL/sea-orm/issues/252), with various degrees of involvement. We will implement feature proposals if it benefits everyone, but of course code contributions will more likely be accepted. +Feature requests from anyone is definitely welcomed! Actually, since 0.2, many features are proposed and/or contributed by non-core members, e.g. [#105](https://github.com/SeaQL/sea-orm/issues/105), [#142](https://github.com/SeaQL/sea-orm/issues/142), [#252](https://github.com/SeaQL/sea-orm/issues/252), with various degrees of involvement. We will implement feature proposals if it benefits everyone, but of course code contributions will more likely be accepted. ## I want to support @@ -22,7 +22,7 @@ Awesome! The best way to support us is to recommend it to your classmates/collea ## I want to join -We are always looking for long-term contributors. If you want to commit longer-term to SeaQL's open source effort, definitely talk with us! There may be various form of "grant" to compensate your devotion. Although at this stage we are not resourceful enough to offer a stable stream of income to contributors. +We are always looking for long-term contributors. If you want to commit longer-term to SeaQL's open source effort, definitely talk with us! There may be various forms of "grant" to compensate for your devotion. Although at this stage we are not resourceful enough to offer a stable stream of income to contributors. ## I want to sponsor @@ -51,7 +51,7 @@ Without involving a live database, you can run unit tests on your machine with t ### Integration Test -Next, if you want to run integration tests on a live database. We recommand using Docker to spawn your database instance, you can refer to [this](build-tools/docker-compose.yml) docker compose file for reference. +Next, if you want to run integration tests on a live database. We recommend using Docker to spawn your database instance, you can refer to [this](build-tools/docker-compose.yml) docker compose file for reference. Running integration tests on a live database: - SQLite diff --git a/Cargo.toml b/Cargo.toml index 1a0795de..af17dac6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ uuid = { version = "^1", features = ["serde", "v4"], optional = true } ouroboros = "0.15" url = "^2.2" once_cell = "1.8" +thiserror = "^1" [patch.crates-io] sea-query = { git = "https://github.com/SeaQL/sea-query", branch = "sqlite-bind-decimals" } @@ -58,7 +59,7 @@ actix-rt = { version = "2.2.0" } maplit = { version = "^1" } rust_decimal_macros = { version = "^1" } tracing-subscriber = { version = "0.3", features = ["env-filter"] } -sea-orm = { path = ".", features = ["mock", "debug-print"] } +sea-orm = { path = ".", features = ["mock", "debug-print", "tests-cfg"] } pretty_assertions = { version = "^0.7" } time = { version = "^0.3", features = ["macros"] } @@ -116,3 +117,4 @@ runtime-tokio-rustls = [ "sea-query-binder/runtime-tokio-rustls", "runtime-tokio", ] +tests-cfg = [] diff --git a/DESIGN.md b/DESIGN.md index 291efc02..4cfc2255 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -16,7 +16,7 @@ Avoid macros with DSL, use derive macros where appropriate. Be friendly with IDE ## Test Time -After some bitterness we realized it is not possible to capture everything compile time. But we don't +After some bitterness we realized it is not possible to capture everything at compile time. But we don't want to encounter problems at run time either. The solution is to perform checking at 'test time' to uncover problems. These checks will be removed at production so there will be no run time penalty. @@ -96,4 +96,4 @@ where let a: ActiveModel = a.into_active_model(); ... } -``` \ No newline at end of file +``` diff --git a/README.md b/README.md index d912b5fe..17764bca 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Join our Discord server to chat with others in the SeaQL community! + [Getting Started](https://www.sea-ql.org/SeaORM/docs/index) + [Step-by-step Tutorials](https://www.sea-ql.org/sea-orm-tutorial/) ++ [Cookbook](https://www.sea-ql.org/sea-orm-cookbook/) + [Usage Example](https://github.com/SeaQL/sea-orm/tree/master/examples/basic) Integration examples @@ -188,7 +189,7 @@ fruit::Entity::delete_many() ## Learn More 1. [Design](https://github.com/SeaQL/sea-orm/tree/master/DESIGN.md) -1. [Architecture](https://github.com/SeaQL/sea-orm/tree/master/ARCHITECTURE.md) +1. [Architecture](https://www.sea-ql.org/SeaORM/docs/internal-design/architecture/) 1. [Release Model](https://www.sea-ql.org/SeaORM/blog/2021-08-30-release-model) 1. [Change Log](https://github.com/SeaQL/sea-orm/tree/master/CHANGELOG.md) @@ -227,7 +228,7 @@ Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. -SeaORM is a community driven project. We welcome you to participate, contribute and together build for Rust's future. +SeaORM is a community driven project. We welcome you to participate, contribute and together help build Rust's future. A big shout out to our contributors: diff --git a/examples/actix3_example/Cargo.toml b/examples/actix3_example/Cargo.toml index d08d1dcd..a449de4c 100644 --- a/examples/actix3_example/Cargo.toml +++ b/examples/actix3_example/Cargo.toml @@ -6,30 +6,7 @@ edition = "2021" publish = false [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] -actix-http = "2" -actix-web = "3" -actix-flash = "0.2" -actix-files = "0.5" -futures = { version = "^0.3" } -futures-util = { version = "^0.3" } -tera = "1.8.0" -dotenv = "0.15" -listenfd = "0.3.3" -serde = "1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -entity = { path = "entity" } -migration = { path = "migration" } - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "debug-print", - "runtime-async-std-native-tls", - "sqlx-mysql", - # "sqlx-postgres", - # "sqlx-sqlite", -] +actix3-example-api = { path = "api" } diff --git a/examples/actix3_example/README.md b/examples/actix3_example/README.md index 2cd8c358..47263826 100644 --- a/examples/actix3_example/README.md +++ b/examples/actix3_example/README.md @@ -6,7 +6,7 @@ 1. Modify the `DATABASE_URL` var in `.env` to point to your chosen database -1. Turn on the appropriate database feature for your chosen db in `Cargo.toml` (the `"sqlx-mysql",` line) +1. Turn on the appropriate database feature for your chosen db in `core/Cargo.toml` (the `"sqlx-mysql",` line) 1. Execute `cargo run` to start the server @@ -18,3 +18,10 @@ Run server with auto-reloading: cargo install systemfd systemfd --no-pid -s http::8000 -- cargo watch -x run ``` + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/actix3_example/api/Cargo.toml b/examples/actix3_example/api/Cargo.toml new file mode 100644 index 00000000..16160b49 --- /dev/null +++ b/examples/actix3_example/api/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "actix3-example-api" +version = "0.1.0" +authors = ["Sam Samai "] +edition = "2021" +publish = false + +[dependencies] +actix3-example-core = { path = "../core" } +actix-http = "2" +actix-web = "3" +actix-flash = "0.2" +actix-files = "0.5" +futures = { version = "^0.3" } +futures-util = { version = "^0.3" } +tera = "1.8.0" +dotenvy = "0.15" +listenfd = "0.3.3" +serde = "1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +entity = { path = "../entity" } +migration = { path = "../migration" } diff --git a/examples/actix3_example/api/src/lib.rs b/examples/actix3_example/api/src/lib.rs new file mode 100644 index 00000000..e35d88d2 --- /dev/null +++ b/examples/actix3_example/api/src/lib.rs @@ -0,0 +1,219 @@ +use actix3_example_core::{ + sea_orm::{Database, DatabaseConnection}, + Mutation, Query, +}; +use actix_files as fs; +use actix_web::{ + error, get, middleware, post, web, App, Error, HttpRequest, HttpResponse, HttpServer, Result, +}; + +use entity::post; +use listenfd::ListenFd; +use migration::{Migrator, MigratorTrait}; +use serde::{Deserialize, Serialize}; +use std::env; +use tera::Tera; + +const DEFAULT_POSTS_PER_PAGE: u64 = 5; + +#[derive(Debug, Clone)] +struct AppState { + templates: tera::Tera, + conn: DatabaseConnection, +} +#[derive(Debug, Deserialize)] +pub struct Params { + page: Option, + posts_per_page: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +struct FlashData { + kind: String, + message: String, +} + +#[get("/")] +async fn list( + req: HttpRequest, + data: web::Data, + opt_flash: Option>, +) -> Result { + let template = &data.templates; + let conn = &data.conn; + + // get params + let params = web::Query::::from_query(req.query_string()).unwrap(); + + let page = params.page.unwrap_or(1); + let posts_per_page = params.posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); + + let (posts, num_pages) = Query::find_posts_in_page(conn, page, posts_per_page) + .await + .expect("Cannot find posts in page"); + + let mut ctx = tera::Context::new(); + ctx.insert("posts", &posts); + ctx.insert("page", &page); + ctx.insert("posts_per_page", &posts_per_page); + ctx.insert("num_pages", &num_pages); + + if let Some(flash) = opt_flash { + let flash_inner = flash.into_inner(); + ctx.insert("flash", &flash_inner); + } + + let body = template + .render("index.html.tera", &ctx) + .map_err(|_| error::ErrorInternalServerError("Template error"))?; + Ok(HttpResponse::Ok().content_type("text/html").body(body)) +} + +#[get("/new")] +async fn new(data: web::Data) -> Result { + let template = &data.templates; + let ctx = tera::Context::new(); + let body = template + .render("new.html.tera", &ctx) + .map_err(|_| error::ErrorInternalServerError("Template error"))?; + Ok(HttpResponse::Ok().content_type("text/html").body(body)) +} + +#[post("/")] +async fn create( + data: web::Data, + post_form: web::Form, +) -> actix_flash::Response { + let conn = &data.conn; + + let form = post_form.into_inner(); + + Mutation::create_post(conn, form) + .await + .expect("could not insert post"); + + let flash = FlashData { + kind: "success".to_owned(), + message: "Post successfully added.".to_owned(), + }; + + actix_flash::Response::with_redirect(flash, "/") +} + +#[get("/{id}")] +async fn edit(data: web::Data, id: web::Path) -> Result { + let conn = &data.conn; + let template = &data.templates; + let id = id.into_inner(); + + let post: post::Model = Query::find_post_by_id(conn, id) + .await + .expect("could not find post") + .unwrap_or_else(|| panic!("could not find post with id {}", id)); + + let mut ctx = tera::Context::new(); + ctx.insert("post", &post); + + let body = template + .render("edit.html.tera", &ctx) + .map_err(|_| error::ErrorInternalServerError("Template error"))?; + Ok(HttpResponse::Ok().content_type("text/html").body(body)) +} + +#[post("/{id}")] +async fn update( + data: web::Data, + id: web::Path, + post_form: web::Form, +) -> actix_flash::Response { + let conn = &data.conn; + let form = post_form.into_inner(); + let id = id.into_inner(); + + Mutation::update_post_by_id(conn, id, form) + .await + .expect("could not edit post"); + + let flash = FlashData { + kind: "success".to_owned(), + message: "Post successfully updated.".to_owned(), + }; + + actix_flash::Response::with_redirect(flash, "/") +} + +#[post("/delete/{id}")] +async fn delete( + data: web::Data, + id: web::Path, +) -> actix_flash::Response { + let conn = &data.conn; + let id = id.into_inner(); + + Mutation::delete_post(conn, id) + .await + .expect("could not delete post"); + + let flash = FlashData { + kind: "success".to_owned(), + message: "Post successfully deleted.".to_owned(), + }; + + actix_flash::Response::with_redirect(flash, "/") +} + +#[actix_web::main] +async fn start() -> std::io::Result<()> { + std::env::set_var("RUST_LOG", "debug"); + tracing_subscriber::fmt::init(); + + // get env vars + dotenvy::dotenv().ok(); + let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); + let host = env::var("HOST").expect("HOST is not set in .env file"); + let port = env::var("PORT").expect("PORT is not set in .env file"); + let server_url = format!("{}:{}", host, port); + + // create post table if not exists + let conn = Database::connect(&db_url).await.unwrap(); + Migrator::up(&conn, None).await.unwrap(); + let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap(); + let state = AppState { templates, conn }; + + let mut listenfd = ListenFd::from_env(); + let mut server = HttpServer::new(move || { + App::new() + .data(state.clone()) + .wrap(middleware::Logger::default()) // enable logger + .wrap(actix_flash::Flash::default()) + .configure(init) + .service(fs::Files::new("/static", "./api/static").show_files_listing()) + }); + + server = match listenfd.take_tcp_listener(0)? { + Some(listener) => server.listen(listener)?, + None => server.bind(&server_url)?, + }; + + println!("Starting server at {}", server_url); + server.run().await?; + + Ok(()) +} + +fn init(cfg: &mut web::ServiceConfig) { + cfg.service(list); + cfg.service(new); + cfg.service(create); + cfg.service(edit); + cfg.service(update); + cfg.service(delete); +} + +pub fn main() { + let result = start(); + + if let Some(err) = result.err() { + println!("Error: {}", err) + } +} diff --git a/examples/actix3_example/static/css/normalize.css b/examples/actix3_example/api/static/css/normalize.css similarity index 100% rename from examples/actix3_example/static/css/normalize.css rename to examples/actix3_example/api/static/css/normalize.css diff --git a/examples/actix3_example/static/css/skeleton.css b/examples/actix3_example/api/static/css/skeleton.css similarity index 100% rename from examples/actix3_example/static/css/skeleton.css rename to examples/actix3_example/api/static/css/skeleton.css diff --git a/examples/actix3_example/static/css/style.css b/examples/actix3_example/api/static/css/style.css similarity index 100% rename from examples/actix3_example/static/css/style.css rename to examples/actix3_example/api/static/css/style.css diff --git a/examples/actix3_example/static/images/favicon.png b/examples/actix3_example/api/static/images/favicon.png similarity index 100% rename from examples/actix3_example/static/images/favicon.png rename to examples/actix3_example/api/static/images/favicon.png diff --git a/examples/actix3_example/templates/edit.html.tera b/examples/actix3_example/api/templates/edit.html.tera similarity index 100% rename from examples/actix3_example/templates/edit.html.tera rename to examples/actix3_example/api/templates/edit.html.tera diff --git a/examples/actix3_example/templates/error/404.html.tera b/examples/actix3_example/api/templates/error/404.html.tera similarity index 100% rename from examples/actix3_example/templates/error/404.html.tera rename to examples/actix3_example/api/templates/error/404.html.tera diff --git a/examples/actix3_example/templates/index.html.tera b/examples/actix3_example/api/templates/index.html.tera similarity index 100% rename from examples/actix3_example/templates/index.html.tera rename to examples/actix3_example/api/templates/index.html.tera diff --git a/examples/actix3_example/templates/layout.html.tera b/examples/actix3_example/api/templates/layout.html.tera similarity index 100% rename from examples/actix3_example/templates/layout.html.tera rename to examples/actix3_example/api/templates/layout.html.tera diff --git a/examples/actix3_example/templates/new.html.tera b/examples/actix3_example/api/templates/new.html.tera similarity index 100% rename from examples/actix3_example/templates/new.html.tera rename to examples/actix3_example/api/templates/new.html.tera diff --git a/examples/actix3_example/core/Cargo.toml b/examples/actix3_example/core/Cargo.toml new file mode 100644 index 00000000..c0548ff4 --- /dev/null +++ b/examples/actix3_example/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "actix3-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "debug-print", + "runtime-async-std-native-tls", + "sqlx-mysql", + # "sqlx-postgres", + # "sqlx-sqlite", +] + +[dev-dependencies] +tokio = { version = "1.20.0", features = ["macros", "rt"] } + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/actix3_example/core/src/lib.rs b/examples/actix3_example/core/src/lib.rs new file mode 100644 index 00000000..4a80f239 --- /dev/null +++ b/examples/actix3_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/actix3_example/core/src/mutation.rs b/examples/actix3_example/core/src/mutation.rs new file mode 100644 index 00000000..dd6891d4 --- /dev/null +++ b/examples/actix3_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/actix3_example/core/src/query.rs b/examples/actix3_example/core/src/query.rs new file mode 100644 index 00000000..e8d2668f --- /dev/null +++ b/examples/actix3_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/actix3_example/core/tests/mock.rs b/examples/actix3_example/core/tests/mock.rs new file mode 100644 index 00000000..190cb290 --- /dev/null +++ b/examples/actix3_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use actix3_example_core::{Mutation, Query}; +use entity::post; +use prepare::prepare_mock_db; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/actix3_example/core/tests/prepare.rs b/examples/actix3_example/core/tests/prepare.rs new file mode 100644 index 00000000..45180493 --- /dev/null +++ b/examples/actix3_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/actix3_example/src/main.rs b/examples/actix3_example/src/main.rs index f6ac5d6b..5a0e63b8 100644 --- a/examples/actix3_example/src/main.rs +++ b/examples/actix3_example/src/main.rs @@ -1,227 +1,3 @@ -use actix_files as fs; -use actix_web::{ - error, get, middleware, post, web, App, Error, HttpRequest, HttpResponse, HttpServer, Result, -}; - -use entity::post; -use entity::post::Entity as Post; -use listenfd::ListenFd; -use migration::{Migrator, MigratorTrait}; -use sea_orm::DatabaseConnection; -use sea_orm::{entity::*, query::*}; -use serde::{Deserialize, Serialize}; -use std::env; -use tera::Tera; - -const DEFAULT_POSTS_PER_PAGE: u64 = 5; - -#[derive(Debug, Clone)] -struct AppState { - templates: tera::Tera, - conn: DatabaseConnection, -} -#[derive(Debug, Deserialize)] -pub struct Params { - page: Option, - posts_per_page: Option, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -struct FlashData { - kind: String, - message: String, -} - -#[get("/")] -async fn list( - req: HttpRequest, - data: web::Data, - opt_flash: Option>, -) -> Result { - let template = &data.templates; - let conn = &data.conn; - - // get params - let params = web::Query::::from_query(req.query_string()).unwrap(); - - let page = params.page.unwrap_or(1); - let posts_per_page = params.posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); - let paginator = Post::find() - .order_by_asc(post::Column::Id) - .paginate(conn, posts_per_page); - let num_pages = paginator.num_pages().await.ok().unwrap(); - - let posts = paginator - .fetch_page(page - 1) - .await - .expect("could not retrieve posts"); - let mut ctx = tera::Context::new(); - ctx.insert("posts", &posts); - ctx.insert("page", &page); - ctx.insert("posts_per_page", &posts_per_page); - ctx.insert("num_pages", &num_pages); - - if let Some(flash) = opt_flash { - let flash_inner = flash.into_inner(); - ctx.insert("flash", &flash_inner); - } - - let body = template - .render("index.html.tera", &ctx) - .map_err(|_| error::ErrorInternalServerError("Template error"))?; - Ok(HttpResponse::Ok().content_type("text/html").body(body)) -} - -#[get("/new")] -async fn new(data: web::Data) -> Result { - let template = &data.templates; - let ctx = tera::Context::new(); - let body = template - .render("new.html.tera", &ctx) - .map_err(|_| error::ErrorInternalServerError("Template error"))?; - Ok(HttpResponse::Ok().content_type("text/html").body(body)) -} - -#[post("/")] -async fn create( - data: web::Data, - post_form: web::Form, -) -> actix_flash::Response { - let conn = &data.conn; - - let form = post_form.into_inner(); - - post::ActiveModel { - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - ..Default::default() - } - .save(conn) - .await - .expect("could not insert post"); - - let flash = FlashData { - kind: "success".to_owned(), - message: "Post successfully added.".to_owned(), - }; - - actix_flash::Response::with_redirect(flash, "/") -} - -#[get("/{id}")] -async fn edit(data: web::Data, id: web::Path) -> Result { - let conn = &data.conn; - let template = &data.templates; - - let post: post::Model = Post::find_by_id(id.into_inner()) - .one(conn) - .await - .expect("could not find post") - .unwrap(); - - let mut ctx = tera::Context::new(); - ctx.insert("post", &post); - - let body = template - .render("edit.html.tera", &ctx) - .map_err(|_| error::ErrorInternalServerError("Template error"))?; - Ok(HttpResponse::Ok().content_type("text/html").body(body)) -} - -#[post("/{id}")] -async fn update( - data: web::Data, - id: web::Path, - post_form: web::Form, -) -> actix_flash::Response { - let conn = &data.conn; - let form = post_form.into_inner(); - - post::ActiveModel { - id: Set(id.into_inner()), - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - } - .save(conn) - .await - .expect("could not edit post"); - - let flash = FlashData { - kind: "success".to_owned(), - message: "Post successfully updated.".to_owned(), - }; - - actix_flash::Response::with_redirect(flash, "/") -} - -#[post("/delete/{id}")] -async fn delete( - data: web::Data, - id: web::Path, -) -> actix_flash::Response { - let conn = &data.conn; - - let post: post::ActiveModel = Post::find_by_id(id.into_inner()) - .one(conn) - .await - .unwrap() - .unwrap() - .into(); - - post.delete(conn).await.unwrap(); - - let flash = FlashData { - kind: "success".to_owned(), - message: "Post successfully deleted.".to_owned(), - }; - - actix_flash::Response::with_redirect(flash, "/") -} - -#[actix_web::main] -async fn main() -> std::io::Result<()> { - std::env::set_var("RUST_LOG", "debug"); - tracing_subscriber::fmt::init(); - - // get env vars - dotenv::dotenv().ok(); - let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); - let host = env::var("HOST").expect("HOST is not set in .env file"); - let port = env::var("PORT").expect("PORT is not set in .env file"); - let server_url = format!("{}:{}", host, port); - - // create post table if not exists - let conn = sea_orm::Database::connect(&db_url).await.unwrap(); - Migrator::up(&conn, None).await.unwrap(); - let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap(); - let state = AppState { templates, conn }; - - let mut listenfd = ListenFd::from_env(); - let mut server = HttpServer::new(move || { - App::new() - .data(state.clone()) - .wrap(middleware::Logger::default()) // enable logger - .wrap(actix_flash::Flash::default()) - .configure(init) - .service(fs::Files::new("/static", "./static").show_files_listing()) - }); - - server = match listenfd.take_tcp_listener(0)? { - Some(listener) => server.listen(listener)?, - None => server.bind(&server_url)?, - }; - - println!("Starting server at {}", server_url); - server.run().await?; - - Ok(()) -} - -pub fn init(cfg: &mut web::ServiceConfig) { - cfg.service(list); - cfg.service(new); - cfg.service(create); - cfg.service(edit); - cfg.service(update); - cfg.service(delete); +fn main() { + actix3_example_api::main(); } diff --git a/examples/actix_example/Cargo.toml b/examples/actix_example/Cargo.toml index 8be0a188..7dc877c2 100644 --- a/examples/actix_example/Cargo.toml +++ b/examples/actix_example/Cargo.toml @@ -6,30 +6,7 @@ edition = "2021" publish = false [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] -actix-files = "0.6" -actix-http = "3" -actix-rt = "2.7" -actix-service = "2" -actix-web = "4" - -tera = "1.15.0" -dotenv = "0.15" -listenfd = "0.5" -serde = "1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -entity = { path = "entity" } -migration = { path = "migration" } - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "debug-print", - "runtime-actix-native-tls", - "sqlx-mysql", - # "sqlx-postgres", - # "sqlx-sqlite", -] +actix-example-api = { path = "api" } diff --git a/examples/actix_example/README.md b/examples/actix_example/README.md index a2acf414..d844b96a 100644 --- a/examples/actix_example/README.md +++ b/examples/actix_example/README.md @@ -4,7 +4,7 @@ 1. Modify the `DATABASE_URL` var in `.env` to point to your chosen database -1. Turn on the appropriate database feature for your chosen db in `Cargo.toml` (the `"sqlx-mysql",` line) +1. Turn on the appropriate database feature for your chosen db in `core/Cargo.toml` (the `"sqlx-mysql",` line) 1. Execute `cargo run` to start the server @@ -16,3 +16,10 @@ Run server with auto-reloading: cargo install systemfd cargo-watch systemfd --no-pid -s http::8000 -- cargo watch -x run ``` + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/actix_example/api/Cargo.toml b/examples/actix_example/api/Cargo.toml new file mode 100644 index 00000000..4a6727e9 --- /dev/null +++ b/examples/actix_example/api/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "actix-example-api" +version = "0.1.0" +authors = ["Sam Samai "] +edition = "2021" +publish = false + +[dependencies] +actix-example-core = { path = "../core" } +actix-files = "0.6" +actix-http = "3" +actix-rt = "2.7" +actix-service = "2" +actix-web = "4" +tera = "1.15.0" +dotenvy = "0.15" +listenfd = "0.5" +serde = "1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +entity = { path = "../entity" } +migration = { path = "../migration" } diff --git a/examples/actix_example/api/src/lib.rs b/examples/actix_example/api/src/lib.rs new file mode 100644 index 00000000..64a909a9 --- /dev/null +++ b/examples/actix_example/api/src/lib.rs @@ -0,0 +1,215 @@ +use actix_example_core::{ + sea_orm::{Database, DatabaseConnection}, + Mutation, Query, +}; +use actix_files::Files as Fs; +use actix_web::{ + error, get, middleware, post, web, App, Error, HttpRequest, HttpResponse, HttpServer, Result, +}; + +use entity::post; +use listenfd::ListenFd; +use migration::{Migrator, MigratorTrait}; +use serde::{Deserialize, Serialize}; +use std::env; +use tera::Tera; + +const DEFAULT_POSTS_PER_PAGE: u64 = 5; + +#[derive(Debug, Clone)] +struct AppState { + templates: tera::Tera, + conn: DatabaseConnection, +} + +#[derive(Debug, Deserialize)] +pub struct Params { + page: Option, + posts_per_page: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +struct FlashData { + kind: String, + message: String, +} + +#[get("/")] +async fn list(req: HttpRequest, data: web::Data) -> Result { + let template = &data.templates; + let conn = &data.conn; + + // get params + let params = web::Query::::from_query(req.query_string()).unwrap(); + + let page = params.page.unwrap_or(1); + let posts_per_page = params.posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); + + let (posts, num_pages) = Query::find_posts_in_page(conn, page, posts_per_page) + .await + .expect("Cannot find posts in page"); + + let mut ctx = tera::Context::new(); + ctx.insert("posts", &posts); + ctx.insert("page", &page); + ctx.insert("posts_per_page", &posts_per_page); + ctx.insert("num_pages", &num_pages); + + let body = template + .render("index.html.tera", &ctx) + .map_err(|_| error::ErrorInternalServerError("Template error"))?; + Ok(HttpResponse::Ok().content_type("text/html").body(body)) +} + +#[get("/new")] +async fn new(data: web::Data) -> Result { + let template = &data.templates; + let ctx = tera::Context::new(); + let body = template + .render("new.html.tera", &ctx) + .map_err(|_| error::ErrorInternalServerError("Template error"))?; + Ok(HttpResponse::Ok().content_type("text/html").body(body)) +} + +#[post("/")] +async fn create( + data: web::Data, + post_form: web::Form, +) -> Result { + let conn = &data.conn; + + let form = post_form.into_inner(); + + Mutation::create_post(conn, form) + .await + .expect("could not insert post"); + + Ok(HttpResponse::Found() + .append_header(("location", "/")) + .finish()) +} + +#[get("/{id}")] +async fn edit(data: web::Data, id: web::Path) -> Result { + let conn = &data.conn; + let template = &data.templates; + let id = id.into_inner(); + + let post: post::Model = Query::find_post_by_id(conn, id) + .await + .expect("could not find post") + .unwrap_or_else(|| panic!("could not find post with id {}", id)); + + let mut ctx = tera::Context::new(); + ctx.insert("post", &post); + + let body = template + .render("edit.html.tera", &ctx) + .map_err(|_| error::ErrorInternalServerError("Template error"))?; + Ok(HttpResponse::Ok().content_type("text/html").body(body)) +} + +#[post("/{id}")] +async fn update( + data: web::Data, + id: web::Path, + post_form: web::Form, +) -> Result { + let conn = &data.conn; + let form = post_form.into_inner(); + let id = id.into_inner(); + + Mutation::update_post_by_id(conn, id, form) + .await + .expect("could not edit post"); + + Ok(HttpResponse::Found() + .append_header(("location", "/")) + .finish()) +} + +#[post("/delete/{id}")] +async fn delete(data: web::Data, id: web::Path) -> Result { + let conn = &data.conn; + let id = id.into_inner(); + + Mutation::delete_post(conn, id) + .await + .expect("could not delete post"); + + Ok(HttpResponse::Found() + .append_header(("location", "/")) + .finish()) +} + +async fn not_found(data: web::Data, request: HttpRequest) -> Result { + let mut ctx = tera::Context::new(); + ctx.insert("uri", request.uri().path()); + + let template = &data.templates; + let body = template + .render("error/404.html.tera", &ctx) + .map_err(|_| error::ErrorInternalServerError("Template error"))?; + + Ok(HttpResponse::Ok().content_type("text/html").body(body)) +} + +#[actix_web::main] +async fn start() -> std::io::Result<()> { + std::env::set_var("RUST_LOG", "debug"); + tracing_subscriber::fmt::init(); + + // get env vars + dotenvy::dotenv().ok(); + let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); + let host = env::var("HOST").expect("HOST is not set in .env file"); + let port = env::var("PORT").expect("PORT is not set in .env file"); + let server_url = format!("{}:{}", host, port); + + // establish connection to database and apply migrations + // -> create post table if not exists + let conn = Database::connect(&db_url).await.unwrap(); + Migrator::up(&conn, None).await.unwrap(); + + // load tera templates and build app state + let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap(); + let state = AppState { templates, conn }; + + // create server and try to serve over socket if possible + let mut listenfd = ListenFd::from_env(); + let mut server = HttpServer::new(move || { + App::new() + .service(Fs::new("/static", "./api/static")) + .app_data(web::Data::new(state.clone())) + .wrap(middleware::Logger::default()) // enable logger + .default_service(web::route().to(not_found)) + .configure(init) + }); + + server = match listenfd.take_tcp_listener(0)? { + Some(listener) => server.listen(listener)?, + None => server.bind(&server_url)?, + }; + + println!("Starting server at {}", server_url); + server.run().await?; + + Ok(()) +} + +fn init(cfg: &mut web::ServiceConfig) { + cfg.service(list); + cfg.service(new); + cfg.service(create); + cfg.service(edit); + cfg.service(update); + cfg.service(delete); +} + +pub fn main() { + let result = start(); + + if let Some(err) = result.err() { + println!("Error: {}", err); + } +} diff --git a/examples/actix_example/static/css/normalize.css b/examples/actix_example/api/static/css/normalize.css similarity index 100% rename from examples/actix_example/static/css/normalize.css rename to examples/actix_example/api/static/css/normalize.css diff --git a/examples/actix_example/static/css/skeleton.css b/examples/actix_example/api/static/css/skeleton.css similarity index 100% rename from examples/actix_example/static/css/skeleton.css rename to examples/actix_example/api/static/css/skeleton.css diff --git a/examples/actix_example/static/css/style.css b/examples/actix_example/api/static/css/style.css similarity index 100% rename from examples/actix_example/static/css/style.css rename to examples/actix_example/api/static/css/style.css diff --git a/examples/actix_example/static/images/favicon.png b/examples/actix_example/api/static/images/favicon.png similarity index 100% rename from examples/actix_example/static/images/favicon.png rename to examples/actix_example/api/static/images/favicon.png diff --git a/examples/actix_example/templates/edit.html.tera b/examples/actix_example/api/templates/edit.html.tera similarity index 100% rename from examples/actix_example/templates/edit.html.tera rename to examples/actix_example/api/templates/edit.html.tera diff --git a/examples/actix_example/templates/error/404.html.tera b/examples/actix_example/api/templates/error/404.html.tera similarity index 100% rename from examples/actix_example/templates/error/404.html.tera rename to examples/actix_example/api/templates/error/404.html.tera diff --git a/examples/actix_example/templates/index.html.tera b/examples/actix_example/api/templates/index.html.tera similarity index 100% rename from examples/actix_example/templates/index.html.tera rename to examples/actix_example/api/templates/index.html.tera diff --git a/examples/actix_example/templates/layout.html.tera b/examples/actix_example/api/templates/layout.html.tera similarity index 100% rename from examples/actix_example/templates/layout.html.tera rename to examples/actix_example/api/templates/layout.html.tera diff --git a/examples/actix_example/templates/new.html.tera b/examples/actix_example/api/templates/new.html.tera similarity index 100% rename from examples/actix_example/templates/new.html.tera rename to examples/actix_example/api/templates/new.html.tera diff --git a/examples/actix_example/core/Cargo.toml b/examples/actix_example/core/Cargo.toml new file mode 100644 index 00000000..2044b6f2 --- /dev/null +++ b/examples/actix_example/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "actix-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "debug-print", + "runtime-async-std-native-tls", + "sqlx-mysql", + # "sqlx-postgres", + # "sqlx-sqlite", +] + +[dev-dependencies] +tokio = { version = "1.20.0", features = ["macros", "rt"] } + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/actix_example/core/src/lib.rs b/examples/actix_example/core/src/lib.rs new file mode 100644 index 00000000..4a80f239 --- /dev/null +++ b/examples/actix_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/actix_example/core/src/mutation.rs b/examples/actix_example/core/src/mutation.rs new file mode 100644 index 00000000..dd6891d4 --- /dev/null +++ b/examples/actix_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/actix_example/core/src/query.rs b/examples/actix_example/core/src/query.rs new file mode 100644 index 00000000..e8d2668f --- /dev/null +++ b/examples/actix_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/actix_example/core/tests/mock.rs b/examples/actix_example/core/tests/mock.rs new file mode 100644 index 00000000..76531b67 --- /dev/null +++ b/examples/actix_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use actix_example_core::{Mutation, Query}; +use entity::post; +use prepare::prepare_mock_db; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/actix_example/core/tests/prepare.rs b/examples/actix_example/core/tests/prepare.rs new file mode 100644 index 00000000..45180493 --- /dev/null +++ b/examples/actix_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/actix_example/src/main.rs b/examples/actix_example/src/main.rs index fa55c507..a9cdecdc 100644 --- a/examples/actix_example/src/main.rs +++ b/examples/actix_example/src/main.rs @@ -1,223 +1,3 @@ -use actix_files::Files as Fs; -use actix_web::{ - error, get, middleware, post, web, App, Error, HttpRequest, HttpResponse, HttpServer, Result, -}; - -use entity::post; -use entity::post::Entity as Post; -use listenfd::ListenFd; -use migration::{Migrator, MigratorTrait}; -use sea_orm::DatabaseConnection; -use sea_orm::{entity::*, query::*}; -use serde::{Deserialize, Serialize}; -use std::env; -use tera::Tera; - -const DEFAULT_POSTS_PER_PAGE: u64 = 5; - -#[derive(Debug, Clone)] -struct AppState { - templates: tera::Tera, - conn: DatabaseConnection, -} - -#[derive(Debug, Deserialize)] -pub struct Params { - page: Option, - posts_per_page: Option, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -struct FlashData { - kind: String, - message: String, -} - -#[get("/")] -async fn list(req: HttpRequest, data: web::Data) -> Result { - let template = &data.templates; - let conn = &data.conn; - - // get params - let params = web::Query::::from_query(req.query_string()).unwrap(); - - let page = params.page.unwrap_or(1); - let posts_per_page = params.posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); - let paginator = Post::find() - .order_by_asc(post::Column::Id) - .paginate(conn, posts_per_page); - let num_pages = paginator.num_pages().await.ok().unwrap(); - - let posts = paginator - .fetch_page(page - 1) - .await - .expect("could not retrieve posts"); - let mut ctx = tera::Context::new(); - ctx.insert("posts", &posts); - ctx.insert("page", &page); - ctx.insert("posts_per_page", &posts_per_page); - ctx.insert("num_pages", &num_pages); - - let body = template - .render("index.html.tera", &ctx) - .map_err(|_| error::ErrorInternalServerError("Template error"))?; - Ok(HttpResponse::Ok().content_type("text/html").body(body)) -} - -#[get("/new")] -async fn new(data: web::Data) -> Result { - let template = &data.templates; - let ctx = tera::Context::new(); - let body = template - .render("new.html.tera", &ctx) - .map_err(|_| error::ErrorInternalServerError("Template error"))?; - Ok(HttpResponse::Ok().content_type("text/html").body(body)) -} - -#[post("/")] -async fn create( - data: web::Data, - post_form: web::Form, -) -> Result { - let conn = &data.conn; - - let form = post_form.into_inner(); - - post::ActiveModel { - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - ..Default::default() - } - .save(conn) - .await - .expect("could not insert post"); - - Ok(HttpResponse::Found() - .append_header(("location", "/")) - .finish()) -} - -#[get("/{id}")] -async fn edit(data: web::Data, id: web::Path) -> Result { - let conn = &data.conn; - let template = &data.templates; - - let post: post::Model = Post::find_by_id(id.into_inner()) - .one(conn) - .await - .expect("could not find post") - .unwrap(); - - let mut ctx = tera::Context::new(); - ctx.insert("post", &post); - - let body = template - .render("edit.html.tera", &ctx) - .map_err(|_| error::ErrorInternalServerError("Template error"))?; - Ok(HttpResponse::Ok().content_type("text/html").body(body)) -} - -#[post("/{id}")] -async fn update( - data: web::Data, - id: web::Path, - post_form: web::Form, -) -> Result { - let conn = &data.conn; - let form = post_form.into_inner(); - - post::ActiveModel { - id: Set(id.into_inner()), - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - } - .save(conn) - .await - .expect("could not edit post"); - - Ok(HttpResponse::Found() - .append_header(("location", "/")) - .finish()) -} - -#[post("/delete/{id}")] -async fn delete(data: web::Data, id: web::Path) -> Result { - let conn = &data.conn; - - let post: post::ActiveModel = Post::find_by_id(id.into_inner()) - .one(conn) - .await - .unwrap() - .unwrap() - .into(); - - post.delete(conn).await.unwrap(); - - Ok(HttpResponse::Found() - .append_header(("location", "/")) - .finish()) -} - -async fn not_found(data: web::Data, request: HttpRequest) -> Result { - let mut ctx = tera::Context::new(); - ctx.insert("uri", request.uri().path()); - - let template = &data.templates; - let body = template - .render("error/404.html.tera", &ctx) - .map_err(|_| error::ErrorInternalServerError("Template error"))?; - - Ok(HttpResponse::Ok().content_type("text/html").body(body)) -} - -#[actix_web::main] -async fn main() -> std::io::Result<()> { - std::env::set_var("RUST_LOG", "debug"); - tracing_subscriber::fmt::init(); - - // get env vars - dotenv::dotenv().ok(); - let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); - let host = env::var("HOST").expect("HOST is not set in .env file"); - let port = env::var("PORT").expect("PORT is not set in .env file"); - let server_url = format!("{}:{}", host, port); - - // establish connection to database and apply migrations - // -> create post table if not exists - let conn = sea_orm::Database::connect(&db_url).await.unwrap(); - Migrator::up(&conn, None).await.unwrap(); - - // load tera templates and build app state - let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap(); - let state = AppState { templates, conn }; - - // create server and try to serve over socket if possible - let mut listenfd = ListenFd::from_env(); - let mut server = HttpServer::new(move || { - App::new() - .service(Fs::new("/static", "./static")) - .app_data(web::Data::new(state.clone())) - .wrap(middleware::Logger::default()) // enable logger - .default_service(web::route().to(not_found)) - .configure(init) - }); - - server = match listenfd.take_tcp_listener(0)? { - Some(listener) => server.listen(listener)?, - None => server.bind(&server_url)?, - }; - - println!("Starting server at {}", server_url); - server.run().await?; - - Ok(()) -} - -pub fn init(cfg: &mut web::ServiceConfig) { - cfg.service(list); - cfg.service(new); - cfg.service(create); - cfg.service(edit); - cfg.service(update); - cfg.service(delete); +fn main() { + actix_example_api::main(); } diff --git a/examples/axum_example/Cargo.toml b/examples/axum_example/Cargo.toml index 43478ad0..0387b657 100644 --- a/examples/axum_example/Cargo.toml +++ b/examples/axum_example/Cargo.toml @@ -5,32 +5,8 @@ authors = ["Yoshiera Huang "] edition = "2021" publish = false -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] -tokio = { version = "1.18.1", features = ["full"] } -axum = "0.5.4" -tower = "0.4.12" -tower-http = { version = "0.3.3", features = ["fs"] } -tower-cookies = "0.6.0" -anyhow = "1.0.57" -dotenv = "0.15.0" -serde = "1.0.137" -serde_json = "1.0.81" -tera = "1.15.0" -tracing-subscriber = { version = "0.3.11", features = ["env-filter"] } -entity = { path = "entity" } -migration = { path = "migration" } - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "debug-print", - "runtime-tokio-native-tls", - "sqlx-postgres", - # "sqlx-mysql", - # "sqlx-sqlite", -] +axum-example-api = { path = "api" } diff --git a/examples/axum_example/README.md b/examples/axum_example/README.md index a3c62422..fe120e71 100644 --- a/examples/axum_example/README.md +++ b/examples/axum_example/README.md @@ -4,8 +4,15 @@ 1. Modify the `DATABASE_URL` var in `.env` to point to your chosen database -1. Turn on the appropriate database feature for your chosen db in `Cargo.toml` (the `"sqlx-postgres",` line) +1. Turn on the appropriate database feature for your chosen db in `core/Cargo.toml` (the `"sqlx-postgres",` line) 1. Execute `cargo run` to start the server 1. Visit [localhost:8000](http://localhost:8000) in browser + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/axum_example/api/Cargo.toml b/examples/axum_example/api/Cargo.toml new file mode 100644 index 00000000..caa1cb8c --- /dev/null +++ b/examples/axum_example/api/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "axum-example-api" +version = "0.1.0" +authors = ["Yoshiera Huang "] +edition = "2021" +publish = false + +[dependencies] +axum-example-core = { path = "../core" } +tokio = { version = "1.18.1", features = ["full"] } +axum = "0.5.4" +tower = "0.4.12" +tower-http = { version = "0.3.3", features = ["fs"] } +tower-cookies = "0.6.0" +anyhow = "1.0.57" +dotenvy = "0.15.0" +serde = "1.0.137" +serde_json = "1.0.81" +tera = "1.15.0" +tracing-subscriber = { version = "0.3.11", features = ["env-filter"] } +entity = { path = "../entity" } +migration = { path = "../migration" } diff --git a/examples/axum_example/src/flash.rs b/examples/axum_example/api/src/flash.rs similarity index 100% rename from examples/axum_example/src/flash.rs rename to examples/axum_example/api/src/flash.rs diff --git a/examples/axum_example/api/src/lib.rs b/examples/axum_example/api/src/lib.rs new file mode 100644 index 00000000..edeee661 --- /dev/null +++ b/examples/axum_example/api/src/lib.rs @@ -0,0 +1,210 @@ +mod flash; + +use axum::{ + extract::{Extension, Form, Path, Query}, + http::StatusCode, + response::Html, + routing::{get, get_service, post}, + Router, Server, +}; +use axum_example_core::{ + sea_orm::{Database, DatabaseConnection}, + Mutation as MutationCore, Query as QueryCore, +}; +use entity::post; +use flash::{get_flash_cookie, post_response, PostResponse}; +use migration::{Migrator, MigratorTrait}; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use std::{env, net::SocketAddr}; +use tera::Tera; +use tower::ServiceBuilder; +use tower_cookies::{CookieManagerLayer, Cookies}; +use tower_http::services::ServeDir; + +#[tokio::main] +async fn start() -> anyhow::Result<()> { + env::set_var("RUST_LOG", "debug"); + tracing_subscriber::fmt::init(); + + dotenvy::dotenv().ok(); + let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); + let host = env::var("HOST").expect("HOST is not set in .env file"); + let port = env::var("PORT").expect("PORT is not set in .env file"); + let server_url = format!("{}:{}", host, port); + + let conn = Database::connect(db_url) + .await + .expect("Database connection failed"); + Migrator::up(&conn, None).await.unwrap(); + let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")) + .expect("Tera initialization failed"); + // let state = AppState { templates, conn }; + + let app = Router::new() + .route("/", get(list_posts).post(create_post)) + .route("/:id", get(edit_post).post(update_post)) + .route("/new", get(new_post)) + .route("/delete/:id", post(delete_post)) + .nest( + "/static", + get_service(ServeDir::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/static" + ))) + .handle_error(|error: std::io::Error| async move { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Unhandled internal error: {}", error), + ) + }), + ) + .layer( + ServiceBuilder::new() + .layer(CookieManagerLayer::new()) + .layer(Extension(conn)) + .layer(Extension(templates)), + ); + + let addr = SocketAddr::from_str(&server_url).unwrap(); + Server::bind(&addr).serve(app.into_make_service()).await?; + + Ok(()) +} + +#[derive(Deserialize)] +struct Params { + page: Option, + posts_per_page: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +struct FlashData { + kind: String, + message: String, +} + +async fn list_posts( + Extension(ref templates): Extension, + Extension(ref conn): Extension, + Query(params): Query, + cookies: Cookies, +) -> Result, (StatusCode, &'static str)> { + let page = params.page.unwrap_or(1); + let posts_per_page = params.posts_per_page.unwrap_or(5); + + let (posts, num_pages) = QueryCore::find_posts_in_page(conn, page, posts_per_page) + .await + .expect("Cannot find posts in page"); + + let mut ctx = tera::Context::new(); + ctx.insert("posts", &posts); + ctx.insert("page", &page); + ctx.insert("posts_per_page", &posts_per_page); + ctx.insert("num_pages", &num_pages); + + if let Some(value) = get_flash_cookie::(&cookies) { + ctx.insert("flash", &value); + } + + let body = templates + .render("index.html.tera", &ctx) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Template error"))?; + + Ok(Html(body)) +} + +async fn new_post( + Extension(ref templates): Extension, +) -> Result, (StatusCode, &'static str)> { + let ctx = tera::Context::new(); + let body = templates + .render("new.html.tera", &ctx) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Template error"))?; + + Ok(Html(body)) +} + +async fn create_post( + Extension(ref conn): Extension, + form: Form, + mut cookies: Cookies, +) -> Result { + let form = form.0; + + MutationCore::create_post(conn, form) + .await + .expect("could not insert post"); + + let data = FlashData { + kind: "success".to_owned(), + message: "Post succcessfully added".to_owned(), + }; + + Ok(post_response(&mut cookies, data)) +} + +async fn edit_post( + Extension(ref templates): Extension, + Extension(ref conn): Extension, + Path(id): Path, +) -> Result, (StatusCode, &'static str)> { + let post: post::Model = QueryCore::find_post_by_id(conn, id) + .await + .expect("could not find post") + .unwrap_or_else(|| panic!("could not find post with id {}", id)); + + let mut ctx = tera::Context::new(); + ctx.insert("post", &post); + + let body = templates + .render("edit.html.tera", &ctx) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Template error"))?; + + Ok(Html(body)) +} + +async fn update_post( + Extension(ref conn): Extension, + Path(id): Path, + form: Form, + mut cookies: Cookies, +) -> Result { + let form = form.0; + + MutationCore::update_post_by_id(conn, id, form) + .await + .expect("could not edit post"); + + let data = FlashData { + kind: "success".to_owned(), + message: "Post succcessfully updated".to_owned(), + }; + + Ok(post_response(&mut cookies, data)) +} + +async fn delete_post( + Extension(ref conn): Extension, + Path(id): Path, + mut cookies: Cookies, +) -> Result { + MutationCore::delete_post(conn, id) + .await + .expect("could not delete post"); + + let data = FlashData { + kind: "success".to_owned(), + message: "Post succcessfully deleted".to_owned(), + }; + + Ok(post_response(&mut cookies, data)) +} + +pub fn main() { + let result = start(); + + if let Some(err) = result.err() { + println!("Error: {}", err); + } +} diff --git a/examples/axum_example/static/css/normalize.css b/examples/axum_example/api/static/css/normalize.css similarity index 100% rename from examples/axum_example/static/css/normalize.css rename to examples/axum_example/api/static/css/normalize.css diff --git a/examples/axum_example/static/css/skeleton.css b/examples/axum_example/api/static/css/skeleton.css similarity index 100% rename from examples/axum_example/static/css/skeleton.css rename to examples/axum_example/api/static/css/skeleton.css diff --git a/examples/axum_example/static/css/style.css b/examples/axum_example/api/static/css/style.css similarity index 100% rename from examples/axum_example/static/css/style.css rename to examples/axum_example/api/static/css/style.css diff --git a/examples/axum_example/static/images/favicon.png b/examples/axum_example/api/static/images/favicon.png similarity index 100% rename from examples/axum_example/static/images/favicon.png rename to examples/axum_example/api/static/images/favicon.png diff --git a/examples/axum_example/templates/edit.html.tera b/examples/axum_example/api/templates/edit.html.tera similarity index 100% rename from examples/axum_example/templates/edit.html.tera rename to examples/axum_example/api/templates/edit.html.tera diff --git a/examples/axum_example/templates/error/404.html.tera b/examples/axum_example/api/templates/error/404.html.tera similarity index 100% rename from examples/axum_example/templates/error/404.html.tera rename to examples/axum_example/api/templates/error/404.html.tera diff --git a/examples/axum_example/templates/index.html.tera b/examples/axum_example/api/templates/index.html.tera similarity index 100% rename from examples/axum_example/templates/index.html.tera rename to examples/axum_example/api/templates/index.html.tera diff --git a/examples/axum_example/templates/layout.html.tera b/examples/axum_example/api/templates/layout.html.tera similarity index 100% rename from examples/axum_example/templates/layout.html.tera rename to examples/axum_example/api/templates/layout.html.tera diff --git a/examples/axum_example/templates/new.html.tera b/examples/axum_example/api/templates/new.html.tera similarity index 100% rename from examples/axum_example/templates/new.html.tera rename to examples/axum_example/api/templates/new.html.tera diff --git a/examples/axum_example/core/Cargo.toml b/examples/axum_example/core/Cargo.toml new file mode 100644 index 00000000..2ba78874 --- /dev/null +++ b/examples/axum_example/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "axum-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "debug-print", + "runtime-async-std-native-tls", + "sqlx-postgres", + # "sqlx-mysql", + # "sqlx-sqlite", +] + +[dev-dependencies] +tokio = { version = "1.20.0", features = ["macros", "rt"] } + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/axum_example/core/src/lib.rs b/examples/axum_example/core/src/lib.rs new file mode 100644 index 00000000..4a80f239 --- /dev/null +++ b/examples/axum_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/axum_example/core/src/mutation.rs b/examples/axum_example/core/src/mutation.rs new file mode 100644 index 00000000..dd6891d4 --- /dev/null +++ b/examples/axum_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/axum_example/core/src/query.rs b/examples/axum_example/core/src/query.rs new file mode 100644 index 00000000..e8d2668f --- /dev/null +++ b/examples/axum_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/axum_example/core/tests/mock.rs b/examples/axum_example/core/tests/mock.rs new file mode 100644 index 00000000..83210530 --- /dev/null +++ b/examples/axum_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use axum_example_core::{Mutation, Query}; +use entity::post; +use prepare::prepare_mock_db; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/axum_example/core/tests/prepare.rs b/examples/axum_example/core/tests/prepare.rs new file mode 100644 index 00000000..45180493 --- /dev/null +++ b/examples/axum_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/axum_example/entity/src/post.rs b/examples/axum_example/entity/src/post.rs index 76d78196..66a7b652 100644 --- a/examples/axum_example/entity/src/post.rs +++ b/examples/axum_example/entity/src/post.rs @@ -14,13 +14,7 @@ pub struct Model { pub text: String, } -#[derive(Copy, Clone, Debug, EnumIter)] +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} -impl RelationTrait for Relation { - fn def(&self) -> RelationDef { - panic!("No RelationDef") - } -} - impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/axum_example/src/main.rs b/examples/axum_example/src/main.rs index 3669faca..e0b58d2e 100644 --- a/examples/axum_example/src/main.rs +++ b/examples/axum_example/src/main.rs @@ -1,220 +1,3 @@ -mod flash; - -use axum::{ - extract::{Extension, Form, Path, Query}, - http::StatusCode, - response::Html, - routing::{get, get_service, post}, - Router, Server, -}; -use entity::post; -use flash::{get_flash_cookie, post_response, PostResponse}; -use migration::{Migrator, MigratorTrait}; -use post::Entity as Post; -use sea_orm::{prelude::*, Database, QueryOrder, Set}; -use serde::{Deserialize, Serialize}; -use std::str::FromStr; -use std::{env, net::SocketAddr}; -use tera::Tera; -use tower::ServiceBuilder; -use tower_cookies::{CookieManagerLayer, Cookies}; -use tower_http::services::ServeDir; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - env::set_var("RUST_LOG", "debug"); - tracing_subscriber::fmt::init(); - - dotenv::dotenv().ok(); - let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); - let host = env::var("HOST").expect("HOST is not set in .env file"); - let port = env::var("PORT").expect("PORT is not set in .env file"); - let server_url = format!("{}:{}", host, port); - - let conn = Database::connect(db_url) - .await - .expect("Database connection failed"); - Migrator::up(&conn, None).await.unwrap(); - let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")) - .expect("Tera initialization failed"); - // let state = AppState { templates, conn }; - - let app = Router::new() - .route("/", get(list_posts).post(create_post)) - .route("/:id", get(edit_post).post(update_post)) - .route("/new", get(new_post)) - .route("/delete/:id", post(delete_post)) - .nest( - "/static", - get_service(ServeDir::new(concat!( - env!("CARGO_MANIFEST_DIR"), - "/static" - ))) - .handle_error(|error: std::io::Error| async move { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Unhandled internal error: {}", error), - ) - }), - ) - .layer( - ServiceBuilder::new() - .layer(CookieManagerLayer::new()) - .layer(Extension(conn)) - .layer(Extension(templates)), - ); - - let addr = SocketAddr::from_str(&server_url).unwrap(); - Server::bind(&addr).serve(app.into_make_service()).await?; - - Ok(()) -} - -#[derive(Deserialize)] -struct Params { - page: Option, - posts_per_page: Option, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -struct FlashData { - kind: String, - message: String, -} - -async fn list_posts( - Extension(ref templates): Extension, - Extension(ref conn): Extension, - Query(params): Query, - cookies: Cookies, -) -> Result, (StatusCode, &'static str)> { - let page = params.page.unwrap_or(1); - let posts_per_page = params.posts_per_page.unwrap_or(5); - let paginator = Post::find() - .order_by_asc(post::Column::Id) - .paginate(conn, posts_per_page); - let num_pages = paginator.num_pages().await.ok().unwrap(); - let posts = paginator - .fetch_page(page - 1) - .await - .expect("could not retrieve posts"); - - let mut ctx = tera::Context::new(); - ctx.insert("posts", &posts); - ctx.insert("page", &page); - ctx.insert("posts_per_page", &posts_per_page); - ctx.insert("num_pages", &num_pages); - - if let Some(value) = get_flash_cookie::(&cookies) { - ctx.insert("flash", &value); - } - - let body = templates - .render("index.html.tera", &ctx) - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Template error"))?; - - Ok(Html(body)) -} - -async fn new_post( - Extension(ref templates): Extension, -) -> Result, (StatusCode, &'static str)> { - let ctx = tera::Context::new(); - let body = templates - .render("new.html.tera", &ctx) - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Template error"))?; - - Ok(Html(body)) -} - -async fn create_post( - Extension(ref conn): Extension, - form: Form, - mut cookies: Cookies, -) -> Result { - let model = form.0; - - post::ActiveModel { - title: Set(model.title.to_owned()), - text: Set(model.text.to_owned()), - ..Default::default() - } - .save(conn) - .await - .expect("could not insert post"); - - let data = FlashData { - kind: "success".to_owned(), - message: "Post succcessfully added".to_owned(), - }; - - Ok(post_response(&mut cookies, data)) -} - -async fn edit_post( - Extension(ref templates): Extension, - Extension(ref conn): Extension, - Path(id): Path, -) -> Result, (StatusCode, &'static str)> { - let post: post::Model = Post::find_by_id(id) - .one(conn) - .await - .expect("could not find post") - .unwrap(); - - let mut ctx = tera::Context::new(); - ctx.insert("post", &post); - - let body = templates - .render("edit.html.tera", &ctx) - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Template error"))?; - - Ok(Html(body)) -} - -async fn update_post( - Extension(ref conn): Extension, - Path(id): Path, - form: Form, - mut cookies: Cookies, -) -> Result { - let model = form.0; - - post::ActiveModel { - id: Set(id), - title: Set(model.title.to_owned()), - text: Set(model.text.to_owned()), - } - .save(conn) - .await - .expect("could not edit post"); - - let data = FlashData { - kind: "success".to_owned(), - message: "Post succcessfully updated".to_owned(), - }; - - Ok(post_response(&mut cookies, data)) -} - -async fn delete_post( - Extension(ref conn): Extension, - Path(id): Path, - mut cookies: Cookies, -) -> Result { - let post: post::ActiveModel = Post::find_by_id(id) - .one(conn) - .await - .unwrap() - .unwrap() - .into(); - - post.delete(conn).await.unwrap(); - - let data = FlashData { - kind: "success".to_owned(), - message: "Post succcessfully deleted".to_owned(), - }; - - Ok(post_response(&mut cookies, data)) +fn main() { + axum_example_api::main(); } diff --git a/examples/graphql_example/Cargo.toml b/examples/graphql_example/Cargo.toml index 107de341..c3e342dc 100644 --- a/examples/graphql_example/Cargo.toml +++ b/examples/graphql_example/Cargo.toml @@ -7,22 +7,7 @@ publish = false # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] -tokio = { version = "1.0", features = ["full"] } -axum = "^0.5.1" -dotenv = "0.15.0" -async-graphql-axum = "^4.0.6" -entity = { path = "entity" } -migration = { path = "migration" } - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "runtime-tokio-native-tls", - # "sqlx-postgres", - # "sqlx-mysql", - "sqlx-sqlite" -] +graphql-example-api = { path = "api" } diff --git a/examples/graphql_example/README.md b/examples/graphql_example/README.md index 88501958..351c7df5 100644 --- a/examples/graphql_example/README.md +++ b/examples/graphql_example/README.md @@ -6,8 +6,15 @@ 1. Modify the `DATABASE_URL` var in `.env` to point to your chosen database -1. Turn on the appropriate database feature for your chosen db in `Cargo.toml` (the `"sqlx-sqlite",` line) +1. Turn on the appropriate database feature for your chosen db in `core/Cargo.toml` (the `"sqlx-sqlite",` line) 1. Execute `cargo run` to start the server 1. Visit [localhost:3000/api/graphql](http://localhost:3000/api/graphql) in browser + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/graphql_example/api/Cargo.toml b/examples/graphql_example/api/Cargo.toml new file mode 100644 index 00000000..a4fd78f4 --- /dev/null +++ b/examples/graphql_example/api/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "graphql-example-api" +authors = ["Aaron Leopold "] +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +graphql-example-core = { path = "../core" } +tokio = { version = "1.0", features = ["full"] } +axum = "^0.5.1" +dotenvy = "0.15.0" +async-graphql-axum = "^4.0.6" +entity = { path = "../entity" } +migration = { path = "../migration" } diff --git a/examples/graphql_example/api/src/db.rs b/examples/graphql_example/api/src/db.rs new file mode 100644 index 00000000..a1b79cd4 --- /dev/null +++ b/examples/graphql_example/api/src/db.rs @@ -0,0 +1,21 @@ +use graphql_example_core::sea_orm::DatabaseConnection; + +pub struct Database { + pub connection: DatabaseConnection, +} + +impl Database { + pub async fn new() -> Self { + let connection = graphql_example_core::sea_orm::Database::connect( + std::env::var("DATABASE_URL").unwrap(), + ) + .await + .expect("Could not connect to database"); + + Database { connection } + } + + pub fn get_connection(&self) -> &DatabaseConnection { + &self.connection + } +} diff --git a/examples/graphql_example/src/graphql/mod.rs b/examples/graphql_example/api/src/graphql/mod.rs similarity index 100% rename from examples/graphql_example/src/graphql/mod.rs rename to examples/graphql_example/api/src/graphql/mod.rs diff --git a/examples/graphql_example/src/graphql/mutation/mod.rs b/examples/graphql_example/api/src/graphql/mutation/mod.rs similarity index 100% rename from examples/graphql_example/src/graphql/mutation/mod.rs rename to examples/graphql_example/api/src/graphql/mutation/mod.rs diff --git a/examples/graphql_example/src/graphql/mutation/note.rs b/examples/graphql_example/api/src/graphql/mutation/note.rs similarity index 68% rename from examples/graphql_example/src/graphql/mutation/note.rs rename to examples/graphql_example/api/src/graphql/mutation/note.rs index 600b462b..a3ce9f48 100644 --- a/examples/graphql_example/src/graphql/mutation/note.rs +++ b/examples/graphql_example/api/src/graphql/mutation/note.rs @@ -1,7 +1,7 @@ use async_graphql::{Context, Object, Result}; use entity::async_graphql::{self, InputObject, SimpleObject}; use entity::note; -use sea_orm::{ActiveModelTrait, Set}; +use graphql_example_core::Mutation; use crate::db::Database; @@ -14,6 +14,16 @@ pub struct CreateNoteInput { pub text: String, } +impl CreateNoteInput { + fn into_model_with_arbitrary_id(self) -> note::Model { + note::Model { + id: 0, + title: self.title, + text: self.text, + } + } +} + #[derive(SimpleObject)] pub struct DeleteResult { pub success: bool, @@ -31,22 +41,18 @@ impl NoteMutation { input: CreateNoteInput, ) -> Result { let db = ctx.data::().unwrap(); + let conn = db.get_connection(); - let note = note::ActiveModel { - title: Set(input.title), - text: Set(input.text), - ..Default::default() - }; - - Ok(note.insert(db.get_connection()).await?) + Ok(Mutation::create_note(conn, input.into_model_with_arbitrary_id()).await?) } pub async fn delete_note(&self, ctx: &Context<'_>, id: i32) -> Result { let db = ctx.data::().unwrap(); + let conn = db.get_connection(); - let res = note::Entity::delete_by_id(id) - .exec(db.get_connection()) - .await?; + let res = Mutation::delete_note(conn, id) + .await + .expect("Cannot delete note"); if res.rows_affected <= 1 { Ok(DeleteResult { diff --git a/examples/graphql_example/src/graphql/query/mod.rs b/examples/graphql_example/api/src/graphql/query/mod.rs similarity index 100% rename from examples/graphql_example/src/graphql/query/mod.rs rename to examples/graphql_example/api/src/graphql/query/mod.rs diff --git a/examples/graphql_example/src/graphql/query/note.rs b/examples/graphql_example/api/src/graphql/query/note.rs similarity index 75% rename from examples/graphql_example/src/graphql/query/note.rs rename to examples/graphql_example/api/src/graphql/query/note.rs index 696d4720..1ac2549d 100644 --- a/examples/graphql_example/src/graphql/query/note.rs +++ b/examples/graphql_example/api/src/graphql/query/note.rs @@ -1,6 +1,6 @@ use async_graphql::{Context, Object, Result}; use entity::{async_graphql, note}; -use sea_orm::EntityTrait; +use graphql_example_core::Query; use crate::db::Database; @@ -11,18 +11,18 @@ pub struct NoteQuery; impl NoteQuery { async fn get_notes(&self, ctx: &Context<'_>) -> Result> { let db = ctx.data::().unwrap(); + let conn = db.get_connection(); - Ok(note::Entity::find() - .all(db.get_connection()) + Ok(Query::get_all_notes(conn) .await .map_err(|e| e.to_string())?) } async fn get_note_by_id(&self, ctx: &Context<'_>, id: i32) -> Result> { let db = ctx.data::().unwrap(); + let conn = db.get_connection(); - Ok(note::Entity::find_by_id(id) - .one(db.get_connection()) + Ok(Query::find_note_by_id(conn, id) .await .map_err(|e| e.to_string())?) } diff --git a/examples/graphql_example/src/graphql/schema.rs b/examples/graphql_example/api/src/graphql/schema.rs similarity index 100% rename from examples/graphql_example/src/graphql/schema.rs rename to examples/graphql_example/api/src/graphql/schema.rs diff --git a/examples/graphql_example/api/src/lib.rs b/examples/graphql_example/api/src/lib.rs new file mode 100644 index 00000000..85275fcb --- /dev/null +++ b/examples/graphql_example/api/src/lib.rs @@ -0,0 +1,49 @@ +mod db; +mod graphql; + +use entity::async_graphql; + +use async_graphql::http::{playground_source, GraphQLPlaygroundConfig}; +use async_graphql_axum::{GraphQLRequest, GraphQLResponse}; +use axum::{ + extract::Extension, + response::{Html, IntoResponse}, + routing::get, + Router, +}; +use graphql::schema::{build_schema, AppSchema}; + +#[cfg(debug_assertions)] +use dotenvy::dotenv; + +async fn graphql_handler(schema: Extension, req: GraphQLRequest) -> GraphQLResponse { + schema.execute(req.into_inner()).await.into() +} + +async fn graphql_playground() -> impl IntoResponse { + Html(playground_source(GraphQLPlaygroundConfig::new( + "/api/graphql", + ))) +} + +#[tokio::main] +pub async fn main() { + #[cfg(debug_assertions)] + dotenv().ok(); + + let schema = build_schema().await; + + let app = Router::new() + .route( + "/api/graphql", + get(graphql_playground).post(graphql_handler), + ) + .layer(Extension(schema)); + + println!("Playground: http://localhost:3000/api/graphql"); + + axum::Server::bind(&"0.0.0.0:3000".parse().unwrap()) + .serve(app.into_make_service()) + .await + .unwrap(); +} diff --git a/examples/graphql_example/core/Cargo.toml b/examples/graphql_example/core/Cargo.toml new file mode 100644 index 00000000..739d4b38 --- /dev/null +++ b/examples/graphql_example/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "graphql-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "debug-print", + "runtime-async-std-native-tls", + # "sqlx-postgres", + # "sqlx-mysql", + "sqlx-sqlite", +] + +[dev-dependencies] +tokio = { version = "1.20.0", features = ["macros", "rt"] } + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/graphql_example/core/src/lib.rs b/examples/graphql_example/core/src/lib.rs new file mode 100644 index 00000000..4a80f239 --- /dev/null +++ b/examples/graphql_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/graphql_example/core/src/mutation.rs b/examples/graphql_example/core/src/mutation.rs new file mode 100644 index 00000000..1f2447fb --- /dev/null +++ b/examples/graphql_example/core/src/mutation.rs @@ -0,0 +1,54 @@ +use ::entity::{note, note::Entity as Note}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_note(db: &DbConn, form_data: note::Model) -> Result { + let active_model = note::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + }; + let res = Note::insert(active_model).exec(db).await?; + + Ok(note::Model { + id: res.last_insert_id, + ..form_data + }) + } + + pub async fn update_note_by_id( + db: &DbConn, + id: i32, + form_data: note::Model, + ) -> Result { + let note: note::ActiveModel = Note::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find note.".to_owned())) + .map(Into::into)?; + + note::ActiveModel { + id: note.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_note(db: &DbConn, id: i32) -> Result { + let note: note::ActiveModel = Note::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find note.".to_owned())) + .map(Into::into)?; + + note.delete(db).await + } + + pub async fn delete_all_notes(db: &DbConn) -> Result { + Note::delete_many().exec(db).await + } +} diff --git a/examples/graphql_example/core/src/query.rs b/examples/graphql_example/core/src/query.rs new file mode 100644 index 00000000..7b6dc1b7 --- /dev/null +++ b/examples/graphql_example/core/src/query.rs @@ -0,0 +1,30 @@ +use ::entity::{note, note::Entity as Note}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_note_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Note::find_by_id(id).one(db).await + } + + pub async fn get_all_notes(db: &DbConn) -> Result, DbErr> { + Note::find().all(db).await + } + + /// If ok, returns (note models, num pages). + pub async fn find_notes_in_page( + db: &DbConn, + page: u64, + notes_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Note::find() + .order_by_asc(note::Column::Id) + .paginate(db, notes_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated notes + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/graphql_example/core/tests/mock.rs b/examples/graphql_example/core/tests/mock.rs new file mode 100644 index 00000000..16a56189 --- /dev/null +++ b/examples/graphql_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use entity::note; +use graphql_example_core::{Mutation, Query}; +use prepare::prepare_mock_db; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let note = Query::find_note_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(note.id, 1); + } + + { + let note = Query::find_note_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(note.id, 5); + } + + { + let note = Mutation::create_note( + db, + note::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + note, + note::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + } + ); + } + + { + let note = Mutation::update_note_by_id( + db, + 1, + note::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + note, + note::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_note(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_notes(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/graphql_example/core/tests/prepare.rs b/examples/graphql_example/core/tests/prepare.rs new file mode 100644 index 00000000..fd55936d --- /dev/null +++ b/examples/graphql_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::note; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![note::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![note::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![note::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![note::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![note::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![note::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/graphql_example/entity/src/note.rs b/examples/graphql_example/entity/src/note.rs index 46f0c49b..a03f6d3f 100644 --- a/examples/graphql_example/entity/src/note.rs +++ b/examples/graphql_example/entity/src/note.rs @@ -13,15 +13,9 @@ pub struct Model { pub text: String, } -#[derive(Copy, Clone, Debug, EnumIter)] +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} -impl RelationTrait for Relation { - fn def(&self) -> RelationDef { - panic!("No RelationDef") - } -} - impl ActiveModelBehavior for ActiveModel {} impl Entity { diff --git a/examples/graphql_example/migration/Cargo.toml b/examples/graphql_example/migration/Cargo.toml index 4d42e621..f63e03ba 100644 --- a/examples/graphql_example/migration/Cargo.toml +++ b/examples/graphql_example/migration/Cargo.toml @@ -9,7 +9,7 @@ name = "migration" path = "src/lib.rs" [dependencies] -dotenv = "0.15.0" +dotenvy = "0.15.0" async-std = { version = "^1", features = ["attributes", "tokio1"] } [dependencies.sea-orm-migration] diff --git a/examples/graphql_example/migration/src/main.rs b/examples/graphql_example/migration/src/main.rs index 37517f25..8678e0ad 100644 --- a/examples/graphql_example/migration/src/main.rs +++ b/examples/graphql_example/migration/src/main.rs @@ -1,7 +1,7 @@ use sea_orm_migration::prelude::*; #[cfg(debug_assertions)] -use dotenv::dotenv; +use dotenvy::dotenv; #[async_std::main] async fn main() { diff --git a/examples/graphql_example/src/db.rs b/examples/graphql_example/src/db.rs deleted file mode 100644 index 3cc41e27..00000000 --- a/examples/graphql_example/src/db.rs +++ /dev/null @@ -1,19 +0,0 @@ -use sea_orm::DatabaseConnection; - -pub struct Database { - pub connection: DatabaseConnection, -} - -impl Database { - pub async fn new() -> Self { - let connection = sea_orm::Database::connect(std::env::var("DATABASE_URL").unwrap()) - .await - .expect("Could not connect to database"); - - Database { connection } - } - - pub fn get_connection(&self) -> &DatabaseConnection { - &self.connection - } -} diff --git a/examples/graphql_example/src/main.rs b/examples/graphql_example/src/main.rs index 665f79f2..308b656a 100644 --- a/examples/graphql_example/src/main.rs +++ b/examples/graphql_example/src/main.rs @@ -1,49 +1,3 @@ -mod db; -mod graphql; - -use entity::async_graphql; - -use async_graphql::http::{playground_source, GraphQLPlaygroundConfig}; -use async_graphql_axum::{GraphQLRequest, GraphQLResponse}; -use axum::{ - extract::Extension, - response::{Html, IntoResponse}, - routing::get, - Router, -}; -use graphql::schema::{build_schema, AppSchema}; - -#[cfg(debug_assertions)] -use dotenv::dotenv; - -async fn graphql_handler(schema: Extension, req: GraphQLRequest) -> GraphQLResponse { - schema.execute(req.into_inner()).await.into() -} - -async fn graphql_playground() -> impl IntoResponse { - Html(playground_source(GraphQLPlaygroundConfig::new( - "/api/graphql", - ))) -} - -#[tokio::main] -async fn main() { - #[cfg(debug_assertions)] - dotenv().ok(); - - let schema = build_schema().await; - - let app = Router::new() - .route( - "/api/graphql", - get(graphql_playground).post(graphql_handler), - ) - .layer(Extension(schema)); - - println!("Playground: http://localhost:3000/api/graphql"); - - axum::Server::bind(&"0.0.0.0:3000".parse().unwrap()) - .serve(app.into_make_service()) - .await - .unwrap(); +fn main() { + graphql_example_api::main(); } diff --git a/examples/jsonrpsee_example/Cargo.toml b/examples/jsonrpsee_example/Cargo.toml index d507a5c6..7365aa55 100644 --- a/examples/jsonrpsee_example/Cargo.toml +++ b/examples/jsonrpsee_example/Cargo.toml @@ -6,28 +6,7 @@ publish = false # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] -jsonrpsee = { version = "^0.8.0", features = ["full"] } -jsonrpsee-core = "0.9.0" -tokio = { version = "1.8.0", features = ["full"] } -serde = { version = "1", features = ["derive"] } -dotenv = "0.15" -entity = { path = "entity" } -migration = { path = "migration" } -anyhow = "1.0.52" -async-trait = "0.1.52" -log = { version = "0.4", features = ["std"] } -simplelog = "*" - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "debug-print", - "runtime-tokio-native-tls", - "sqlx-sqlite", - # "sqlx-postgres", - # "sqlx-mysql", -] +jsonrpsee-example-api = { path = "api" } diff --git a/examples/jsonrpsee_example/README.md b/examples/jsonrpsee_example/README.md index 80489331..4d45e5b6 100644 --- a/examples/jsonrpsee_example/README.md +++ b/examples/jsonrpsee_example/README.md @@ -2,11 +2,11 @@ 1. Modify the `DATABASE_URL` var in `.env` to point to your chosen database -1. Turn on the appropriate database feature for your chosen db in `Cargo.toml` (the `"sqlx-sqlite",` line) +1. Turn on the appropriate database feature for your chosen db in `core/Cargo.toml` (the `"sqlx-sqlite",` line) 1. Execute `cargo run` to start the server -2. Send jsonrpc request to server +1. Send jsonrpc request to server ```shell #insert @@ -20,7 +20,7 @@ curl --location --request POST 'http://127.0.0.1:8000' \ } ], "id": 2}' -#list +#list curl --location --request POST 'http://127.0.0.1:8000' \ --header 'Content-Type: application/json' \ --data-raw '{ @@ -33,7 +33,7 @@ curl --location --request POST 'http://127.0.0.1:8000' \ "id": 2 }' -#delete +#delete curl --location --request POST 'http://127.0.0.1:8000' \ --header 'Content-Type: application/json' \ --data-raw '{ @@ -61,4 +61,11 @@ curl --location --request POST 'http://127.0.0.1:8000' \ "id": 2 }' -``` \ No newline at end of file +``` + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/jsonrpsee_example/api/Cargo.toml b/examples/jsonrpsee_example/api/Cargo.toml new file mode 100644 index 00000000..7f84ecd3 --- /dev/null +++ b/examples/jsonrpsee_example/api/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "jsonrpsee-example-api" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +jsonrpsee-example-core = { path = "../core" } +jsonrpsee = { version = "^0.8.0", features = ["full"] } +jsonrpsee-core = "0.9.0" +tokio = { version = "1.8.0", features = ["full"] } +serde = { version = "1", features = ["derive"] } +dotenvy = "0.15" +entity = { path = "../entity" } +migration = { path = "../migration" } +anyhow = "1.0.52" +async-trait = "0.1.52" +log = { version = "0.4", features = ["std"] } +simplelog = "*" diff --git a/examples/jsonrpsee_example/api/src/lib.rs b/examples/jsonrpsee_example/api/src/lib.rs new file mode 100644 index 00000000..584c5065 --- /dev/null +++ b/examples/jsonrpsee_example/api/src/lib.rs @@ -0,0 +1,143 @@ +use std::env; + +use anyhow::anyhow; +use entity::post; +use jsonrpsee::core::{async_trait, RpcResult}; +use jsonrpsee::http_server::HttpServerBuilder; +use jsonrpsee::proc_macros::rpc; +use jsonrpsee::types::error::CallError; +use jsonrpsee_example_core::sea_orm::{Database, DatabaseConnection}; +use jsonrpsee_example_core::{Mutation, Query}; +use log::info; +use migration::{Migrator, MigratorTrait}; +use simplelog::*; +use std::fmt::Display; +use std::net::SocketAddr; +use tokio::signal::ctrl_c; +use tokio::signal::unix::{signal, SignalKind}; + +const DEFAULT_POSTS_PER_PAGE: u64 = 5; + +#[rpc(server, client)] +trait PostRpc { + #[method(name = "Post.List")] + async fn list( + &self, + page: Option, + posts_per_page: Option, + ) -> RpcResult>; + + #[method(name = "Post.Insert")] + async fn insert(&self, p: post::Model) -> RpcResult; + + #[method(name = "Post.Update")] + async fn update(&self, p: post::Model) -> RpcResult; + + #[method(name = "Post.Delete")] + async fn delete(&self, id: i32) -> RpcResult; +} + +struct PpcImpl { + conn: DatabaseConnection, +} + +#[async_trait] +impl PostRpcServer for PpcImpl { + async fn list( + &self, + page: Option, + posts_per_page: Option, + ) -> RpcResult> { + let page = page.unwrap_or(1); + let posts_per_page = posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); + + Query::find_posts_in_page(&self.conn, page, posts_per_page) + .await + .map(|(p, _)| p) + .internal_call_error() + } + + async fn insert(&self, p: post::Model) -> RpcResult { + let new_post = Mutation::create_post(&self.conn, p) + .await + .internal_call_error()?; + + Ok(new_post.id.unwrap()) + } + + async fn update(&self, p: post::Model) -> RpcResult { + Mutation::update_post_by_id(&self.conn, p.id, p) + .await + .map(|_| true) + .internal_call_error() + } + async fn delete(&self, id: i32) -> RpcResult { + Mutation::delete_post(&self.conn, id) + .await + .map(|res| res.rows_affected == 1) + .internal_call_error() + } +} + +trait IntoJsonRpcResult { + fn internal_call_error(self) -> RpcResult; +} + +impl IntoJsonRpcResult for Result +where + E: Display, +{ + fn internal_call_error(self) -> RpcResult { + self.map_err(|e| jsonrpsee::core::Error::Call(CallError::Failed(anyhow!("{}", e)))) + } +} + +#[tokio::main] +async fn start() -> std::io::Result<()> { + let _ = TermLogger::init( + LevelFilter::Trace, + Config::default(), + TerminalMode::Mixed, + ColorChoice::Auto, + ); + + // get env vars + dotenvy::dotenv().ok(); + let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); + let host = env::var("HOST").expect("HOST is not set in .env file"); + let port = env::var("PORT").expect("PORT is not set in .env file"); + let server_url = format!("{}:{}", host, port); + + // create post table if not exists + let conn = Database::connect(&db_url).await.unwrap(); + Migrator::up(&conn, None).await.unwrap(); + + let server = HttpServerBuilder::default() + .build(server_url.parse::().unwrap()) + .unwrap(); + + let rpc_impl = PpcImpl { conn }; + let server_addr = server.local_addr().unwrap(); + let handle = server.start(rpc_impl.into_rpc()).unwrap(); + + info!("starting listening {}", server_addr); + let mut sig_int = signal(SignalKind::interrupt()).unwrap(); + let mut sig_term = signal(SignalKind::terminate()).unwrap(); + + tokio::select! { + _ = sig_int.recv() => info!("receive SIGINT"), + _ = sig_term.recv() => info!("receive SIGTERM"), + _ = ctrl_c() => info!("receive Ctrl C"), + } + handle.stop().unwrap(); + info!("Shutdown program"); + Ok(()) +} + +pub fn main() { + let result = start(); + + if let Some(err) = result.err() { + println!("Error: {}", err); + } +} diff --git a/examples/jsonrpsee_example/core/Cargo.toml b/examples/jsonrpsee_example/core/Cargo.toml new file mode 100644 index 00000000..31ebbfcc --- /dev/null +++ b/examples/jsonrpsee_example/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "jsonrpsee-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "debug-print", + "runtime-tokio-native-tls", + "sqlx-sqlite", + # "sqlx-postgres", + # "sqlx-mysql", +] + +[dev-dependencies] +tokio = { version = "1.20.0", features = ["macros", "rt"] } + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/jsonrpsee_example/core/src/lib.rs b/examples/jsonrpsee_example/core/src/lib.rs new file mode 100644 index 00000000..4a80f239 --- /dev/null +++ b/examples/jsonrpsee_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/jsonrpsee_example/core/src/mutation.rs b/examples/jsonrpsee_example/core/src/mutation.rs new file mode 100644 index 00000000..dd6891d4 --- /dev/null +++ b/examples/jsonrpsee_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/jsonrpsee_example/core/src/query.rs b/examples/jsonrpsee_example/core/src/query.rs new file mode 100644 index 00000000..e8d2668f --- /dev/null +++ b/examples/jsonrpsee_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/jsonrpsee_example/core/tests/mock.rs b/examples/jsonrpsee_example/core/tests/mock.rs new file mode 100644 index 00000000..068f31b6 --- /dev/null +++ b/examples/jsonrpsee_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use entity::post; +use jsonrpsee_example_core::{Mutation, Query}; +use prepare::prepare_mock_db; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/jsonrpsee_example/core/tests/prepare.rs b/examples/jsonrpsee_example/core/tests/prepare.rs new file mode 100644 index 00000000..45180493 --- /dev/null +++ b/examples/jsonrpsee_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/jsonrpsee_example/src/main.rs b/examples/jsonrpsee_example/src/main.rs index e1d26f32..2625a68d 100644 --- a/examples/jsonrpsee_example/src/main.rs +++ b/examples/jsonrpsee_example/src/main.rs @@ -1,148 +1,3 @@ -use std::env; - -use anyhow::anyhow; -use entity::post; -use jsonrpsee::core::{async_trait, RpcResult}; -use jsonrpsee::http_server::HttpServerBuilder; -use jsonrpsee::proc_macros::rpc; -use jsonrpsee::types::error::CallError; -use log::info; -use migration::{Migrator, MigratorTrait}; -use sea_orm::NotSet; -use sea_orm::{entity::*, query::*, DatabaseConnection}; -use simplelog::*; -use std::fmt::Display; -use std::net::SocketAddr; -use tokio::signal::ctrl_c; -use tokio::signal::unix::{signal, SignalKind}; - -const DEFAULT_POSTS_PER_PAGE: u64 = 5; - -#[rpc(server, client)] -pub trait PostRpc { - #[method(name = "Post.List")] - async fn list( - &self, - page: Option, - posts_per_page: Option, - ) -> RpcResult>; - - #[method(name = "Post.Insert")] - async fn insert(&self, p: post::Model) -> RpcResult; - - #[method(name = "Post.Update")] - async fn update(&self, p: post::Model) -> RpcResult; - - #[method(name = "Post.Delete")] - async fn delete(&self, id: i32) -> RpcResult; -} - -pub struct PpcImpl { - conn: DatabaseConnection, -} - -#[async_trait] -impl PostRpcServer for PpcImpl { - async fn list( - &self, - page: Option, - posts_per_page: Option, - ) -> RpcResult> { - let page = page.unwrap_or(1); - let posts_per_page = posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); - let paginator = post::Entity::find() - .order_by_asc(post::Column::Id) - .paginate(&self.conn, posts_per_page); - paginator.fetch_page(page - 1).await.internal_call_error() - } - - async fn insert(&self, p: post::Model) -> RpcResult { - let active_post = post::ActiveModel { - id: NotSet, - title: Set(p.title), - text: Set(p.text), - }; - let new_post = active_post.insert(&self.conn).await.internal_call_error()?; - Ok(new_post.id) - } - - async fn update(&self, p: post::Model) -> RpcResult { - let update_post = post::ActiveModel { - id: Set(p.id), - title: Set(p.title), - text: Set(p.text), - }; - update_post - .update(&self.conn) - .await - .map(|_| true) - .internal_call_error() - } - async fn delete(&self, id: i32) -> RpcResult { - let post = post::Entity::find_by_id(id) - .one(&self.conn) - .await - .internal_call_error()?; - - post.unwrap() - .delete(&self.conn) - .await - .map(|res| res.rows_affected == 1) - .internal_call_error() - } -} - -pub trait IntoJsonRpcResult { - fn internal_call_error(self) -> RpcResult; -} - -impl IntoJsonRpcResult for Result -where - E: Display, -{ - fn internal_call_error(self) -> RpcResult { - self.map_err(|e| jsonrpsee::core::Error::Call(CallError::Failed(anyhow!("{}", e)))) - } -} - -#[tokio::main] -async fn main() -> std::io::Result<()> { - let _ = TermLogger::init( - LevelFilter::Trace, - Config::default(), - TerminalMode::Mixed, - ColorChoice::Auto, - ); - - // get env vars - dotenv::dotenv().ok(); - let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); - let host = env::var("HOST").expect("HOST is not set in .env file"); - let port = env::var("PORT").expect("PORT is not set in .env file"); - let server_url = format!("{}:{}", host, port); - - // create post table if not exists - let conn = sea_orm::Database::connect(&db_url).await.unwrap(); - Migrator::up(&conn, None).await.unwrap(); - - let server = HttpServerBuilder::default() - .build(server_url.parse::().unwrap()) - .unwrap(); - - let rpc_impl = PpcImpl { conn }; - let server_addr = server.local_addr().unwrap(); - let handle = server.start(rpc_impl.into_rpc()).unwrap(); - - info!("starting listening {}", server_addr); - let mut sig_int = signal(SignalKind::interrupt()).unwrap(); - let mut sig_term = signal(SignalKind::terminate()).unwrap(); - - tokio::select! { - _ = sig_int.recv() => info!("receive SIGINT"), - _ = sig_term.recv() => info!("receive SIGTERM"), - _ = ctrl_c() => info!("receive Ctrl C"), - } - handle.stop().unwrap(); - info!("Shutdown program"); - Ok(()) +fn main() { + jsonrpsee_example_api::main(); } diff --git a/examples/poem_example/Cargo.toml b/examples/poem_example/Cargo.toml index 0139a5da..9024d729 100644 --- a/examples/poem_example/Cargo.toml +++ b/examples/poem_example/Cargo.toml @@ -5,25 +5,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] -tokio = { version = "1.15.0", features = ["macros", "rt-multi-thread"] } -poem = { version = "1.2.33", features = ["static-files"] } -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -serde = { version = "1", features = ["derive"] } -tera = "1.8.0" -dotenv = "0.15" -entity = { path = "entity" } -migration = { path = "migration" } - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "debug-print", - "runtime-tokio-native-tls", - "sqlx-sqlite", - # "sqlx-postgres", - # "sqlx-mysql", -] +poem-example-api = { path = "api" } diff --git a/examples/poem_example/README.md b/examples/poem_example/README.md index bd4a4539..d0aa6973 100644 --- a/examples/poem_example/README.md +++ b/examples/poem_example/README.md @@ -4,8 +4,15 @@ 1. Modify the `DATABASE_URL` var in `.env` to point to your chosen database -1. Turn on the appropriate database feature for your chosen db in `Cargo.toml` (the `"sqlx-sqlite",` line) +1. Turn on the appropriate database feature for your chosen db in `core/Cargo.toml` (the `"sqlx-sqlite",` line) 1. Execute `cargo run` to start the server 1. Visit [localhost:8000](http://localhost:8000) in browser after seeing the `server started` line + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/poem_example/api/Cargo.toml b/examples/poem_example/api/Cargo.toml new file mode 100644 index 00000000..00211749 --- /dev/null +++ b/examples/poem_example/api/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "poem-example-api" +version = "0.1.0" +edition = "2021" + +[dependencies] +poem-example-core = { path = "../core" } +tokio = { version = "1.15.0", features = ["macros", "rt-multi-thread"] } +poem = { version = "1.2.33", features = ["static-files"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +serde = { version = "1", features = ["derive"] } +tera = "1.8.0" +dotenvy = "0.15" +entity = { path = "../entity" } +migration = { path = "../migration" } diff --git a/examples/poem_example/api/src/lib.rs b/examples/poem_example/api/src/lib.rs new file mode 100644 index 00000000..34eacdcd --- /dev/null +++ b/examples/poem_example/api/src/lib.rs @@ -0,0 +1,163 @@ +use std::env; + +use entity::post; +use migration::{Migrator, MigratorTrait}; +use poem::endpoint::StaticFilesEndpoint; +use poem::error::InternalServerError; +use poem::http::StatusCode; +use poem::listener::TcpListener; +use poem::web::{Data, Form, Html, Path, Query}; +use poem::{get, handler, post, EndpointExt, Error, IntoResponse, Result, Route, Server}; +use poem_example_core::{ + sea_orm::{Database, DatabaseConnection}, + Mutation as MutationCore, Query as QueryCore, +}; +use serde::Deserialize; +use tera::Tera; + +const DEFAULT_POSTS_PER_PAGE: u64 = 5; + +#[derive(Debug, Clone)] +struct AppState { + templates: tera::Tera, + conn: DatabaseConnection, +} + +#[derive(Deserialize)] +struct Params { + page: Option, + posts_per_page: Option, +} + +#[handler] +async fn create(state: Data<&AppState>, form: Form) -> Result { + let form = form.0; + let conn = &state.conn; + + MutationCore::create_post(conn, form) + .await + .map_err(InternalServerError)?; + + Ok(StatusCode::FOUND.with_header("location", "/")) +} + +#[handler] +async fn list(state: Data<&AppState>, Query(params): Query) -> Result { + let conn = &state.conn; + let page = params.page.unwrap_or(1); + let posts_per_page = params.posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); + + let (posts, num_pages) = QueryCore::find_posts_in_page(conn, page, posts_per_page) + .await + .map_err(InternalServerError)?; + + let mut ctx = tera::Context::new(); + ctx.insert("posts", &posts); + ctx.insert("page", &page); + ctx.insert("posts_per_page", &posts_per_page); + ctx.insert("num_pages", &num_pages); + + let body = state + .templates + .render("index.html.tera", &ctx) + .map_err(InternalServerError)?; + Ok(Html(body)) +} + +#[handler] +async fn new(state: Data<&AppState>) -> Result { + let ctx = tera::Context::new(); + let body = state + .templates + .render("new.html.tera", &ctx) + .map_err(InternalServerError)?; + Ok(Html(body)) +} + +#[handler] +async fn edit(state: Data<&AppState>, Path(id): Path) -> Result { + let conn = &state.conn; + + let post: post::Model = QueryCore::find_post_by_id(conn, id) + .await + .map_err(InternalServerError)? + .ok_or_else(|| Error::from_status(StatusCode::NOT_FOUND))?; + + let mut ctx = tera::Context::new(); + ctx.insert("post", &post); + + let body = state + .templates + .render("edit.html.tera", &ctx) + .map_err(InternalServerError)?; + Ok(Html(body)) +} + +#[handler] +async fn update( + state: Data<&AppState>, + Path(id): Path, + form: Form, +) -> Result { + let conn = &state.conn; + let form = form.0; + + MutationCore::update_post_by_id(conn, id, form) + .await + .map_err(InternalServerError)?; + + Ok(StatusCode::FOUND.with_header("location", "/")) +} + +#[handler] +async fn delete(state: Data<&AppState>, Path(id): Path) -> Result { + let conn = &state.conn; + + MutationCore::delete_post(conn, id) + .await + .map_err(InternalServerError)?; + + Ok(StatusCode::FOUND.with_header("location", "/")) +} + +#[tokio::main] +async fn start() -> std::io::Result<()> { + std::env::set_var("RUST_LOG", "debug"); + tracing_subscriber::fmt::init(); + + // get env vars + dotenvy::dotenv().ok(); + let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); + let host = env::var("HOST").expect("HOST is not set in .env file"); + let port = env::var("PORT").expect("PORT is not set in .env file"); + let server_url = format!("{}:{}", host, port); + + // create post table if not exists + let conn = Database::connect(&db_url).await.unwrap(); + Migrator::up(&conn, None).await.unwrap(); + let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap(); + let state = AppState { templates, conn }; + + println!("Starting server at {}", server_url); + + let app = Route::new() + .at("/", post(create).get(list)) + .at("/new", new) + .at("/:id", get(edit).post(update)) + .at("/delete/:id", post(delete)) + .nest( + "/static", + StaticFilesEndpoint::new(concat!(env!("CARGO_MANIFEST_DIR"), "/static")), + ) + .data(state); + let server = Server::new(TcpListener::bind(format!("{}:{}", host, port))); + server.run(app).await +} + +pub fn main() { + let result = start(); + + if let Some(err) = result.err() { + println!("Error: {}", err); + } +} diff --git a/examples/poem_example/static/css/normalize.css b/examples/poem_example/api/static/css/normalize.css similarity index 100% rename from examples/poem_example/static/css/normalize.css rename to examples/poem_example/api/static/css/normalize.css diff --git a/examples/poem_example/static/css/skeleton.css b/examples/poem_example/api/static/css/skeleton.css similarity index 100% rename from examples/poem_example/static/css/skeleton.css rename to examples/poem_example/api/static/css/skeleton.css diff --git a/examples/poem_example/static/css/style.css b/examples/poem_example/api/static/css/style.css similarity index 100% rename from examples/poem_example/static/css/style.css rename to examples/poem_example/api/static/css/style.css diff --git a/examples/poem_example/static/images/favicon.png b/examples/poem_example/api/static/images/favicon.png similarity index 100% rename from examples/poem_example/static/images/favicon.png rename to examples/poem_example/api/static/images/favicon.png diff --git a/examples/poem_example/templates/edit.html.tera b/examples/poem_example/api/templates/edit.html.tera similarity index 100% rename from examples/poem_example/templates/edit.html.tera rename to examples/poem_example/api/templates/edit.html.tera diff --git a/examples/poem_example/templates/error/404.html.tera b/examples/poem_example/api/templates/error/404.html.tera similarity index 100% rename from examples/poem_example/templates/error/404.html.tera rename to examples/poem_example/api/templates/error/404.html.tera diff --git a/examples/poem_example/templates/index.html.tera b/examples/poem_example/api/templates/index.html.tera similarity index 100% rename from examples/poem_example/templates/index.html.tera rename to examples/poem_example/api/templates/index.html.tera diff --git a/examples/poem_example/templates/layout.html.tera b/examples/poem_example/api/templates/layout.html.tera similarity index 100% rename from examples/poem_example/templates/layout.html.tera rename to examples/poem_example/api/templates/layout.html.tera diff --git a/examples/poem_example/templates/new.html.tera b/examples/poem_example/api/templates/new.html.tera similarity index 100% rename from examples/poem_example/templates/new.html.tera rename to examples/poem_example/api/templates/new.html.tera diff --git a/examples/poem_example/core/Cargo.toml b/examples/poem_example/core/Cargo.toml new file mode 100644 index 00000000..e7b64f35 --- /dev/null +++ b/examples/poem_example/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "poem-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "debug-print", + "runtime-async-std-native-tls", + # "sqlx-mysql", + # "sqlx-postgres", + "sqlx-sqlite", +] + +[dev-dependencies] +tokio = { version = "1.20.0", features = ["macros", "rt"] } + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/poem_example/core/src/lib.rs b/examples/poem_example/core/src/lib.rs new file mode 100644 index 00000000..4a80f239 --- /dev/null +++ b/examples/poem_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/poem_example/core/src/mutation.rs b/examples/poem_example/core/src/mutation.rs new file mode 100644 index 00000000..dd6891d4 --- /dev/null +++ b/examples/poem_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/poem_example/core/src/query.rs b/examples/poem_example/core/src/query.rs new file mode 100644 index 00000000..e8d2668f --- /dev/null +++ b/examples/poem_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/poem_example/core/tests/mock.rs b/examples/poem_example/core/tests/mock.rs new file mode 100644 index 00000000..e4b3ef4f --- /dev/null +++ b/examples/poem_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use entity::post; +use poem_example_core::{Mutation, Query}; +use prepare::prepare_mock_db; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/poem_example/core/tests/prepare.rs b/examples/poem_example/core/tests/prepare.rs new file mode 100644 index 00000000..45180493 --- /dev/null +++ b/examples/poem_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/poem_example/src/main.rs b/examples/poem_example/src/main.rs index 14c7c3f4..e3c79da1 100644 --- a/examples/poem_example/src/main.rs +++ b/examples/poem_example/src/main.rs @@ -1,162 +1,3 @@ -use std::env; - -use entity::post; -use migration::{Migrator, MigratorTrait}; -use poem::endpoint::StaticFilesEndpoint; -use poem::error::{BadRequest, InternalServerError}; -use poem::http::StatusCode; -use poem::listener::TcpListener; -use poem::web::{Data, Form, Html, Path, Query}; -use poem::{get, handler, post, EndpointExt, Error, IntoResponse, Result, Route, Server}; -use sea_orm::{entity::*, query::*, DatabaseConnection}; -use serde::Deserialize; -use tera::Tera; - -const DEFAULT_POSTS_PER_PAGE: u64 = 5; - -#[derive(Debug, Clone)] -struct AppState { - templates: tera::Tera, - conn: DatabaseConnection, -} - -#[derive(Deserialize)] -struct Params { - page: Option, - posts_per_page: Option, -} - -#[handler] -async fn create(state: Data<&AppState>, form: Form) -> Result { - post::ActiveModel { - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - ..Default::default() - } - .save(&state.conn) - .await - .map_err(InternalServerError)?; - - Ok(StatusCode::FOUND.with_header("location", "/")) -} - -#[handler] -async fn list(state: Data<&AppState>, Query(params): Query) -> Result { - let page = params.page.unwrap_or(1); - let posts_per_page = params.posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); - let paginator = post::Entity::find() - .order_by_asc(post::Column::Id) - .paginate(&state.conn, posts_per_page); - let num_pages = paginator.num_pages().await.map_err(BadRequest)?; - let posts = paginator - .fetch_page(page - 1) - .await - .map_err(InternalServerError)?; - - let mut ctx = tera::Context::new(); - ctx.insert("posts", &posts); - ctx.insert("page", &page); - ctx.insert("posts_per_page", &posts_per_page); - ctx.insert("num_pages", &num_pages); - - let body = state - .templates - .render("index.html.tera", &ctx) - .map_err(InternalServerError)?; - Ok(Html(body)) -} - -#[handler] -async fn new(state: Data<&AppState>) -> Result { - let ctx = tera::Context::new(); - let body = state - .templates - .render("new.html.tera", &ctx) - .map_err(InternalServerError)?; - Ok(Html(body)) -} - -#[handler] -async fn edit(state: Data<&AppState>, Path(id): Path) -> Result { - let post: post::Model = post::Entity::find_by_id(id) - .one(&state.conn) - .await - .map_err(InternalServerError)? - .ok_or_else(|| Error::from_status(StatusCode::NOT_FOUND))?; - - let mut ctx = tera::Context::new(); - ctx.insert("post", &post); - - let body = state - .templates - .render("edit.html.tera", &ctx) - .map_err(InternalServerError)?; - Ok(Html(body)) -} - -#[handler] -async fn update( - state: Data<&AppState>, - Path(id): Path, - form: Form, -) -> Result { - post::ActiveModel { - id: Set(id), - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - } - .save(&state.conn) - .await - .map_err(InternalServerError)?; - - Ok(StatusCode::FOUND.with_header("location", "/")) -} - -#[handler] -async fn delete(state: Data<&AppState>, Path(id): Path) -> Result { - let post: post::ActiveModel = post::Entity::find_by_id(id) - .one(&state.conn) - .await - .map_err(InternalServerError)? - .ok_or_else(|| Error::from_status(StatusCode::NOT_FOUND))? - .into(); - post.delete(&state.conn) - .await - .map_err(InternalServerError)?; - - Ok(StatusCode::FOUND.with_header("location", "/")) -} - -#[tokio::main] -async fn main() -> std::io::Result<()> { - std::env::set_var("RUST_LOG", "debug"); - tracing_subscriber::fmt::init(); - - // get env vars - dotenv::dotenv().ok(); - let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); - let host = env::var("HOST").expect("HOST is not set in .env file"); - let port = env::var("PORT").expect("PORT is not set in .env file"); - let server_url = format!("{}:{}", host, port); - - // create post table if not exists - let conn = sea_orm::Database::connect(&db_url).await.unwrap(); - Migrator::up(&conn, None).await.unwrap(); - let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap(); - let state = AppState { templates, conn }; - - println!("Starting server at {}", server_url); - - let app = Route::new() - .at("/", post(create).get(list)) - .at("/new", new) - .at("/:id", get(edit).post(update)) - .at("/delete/:id", post(delete)) - .nest( - "/static", - StaticFilesEndpoint::new(concat!(env!("CARGO_MANIFEST_DIR"), "/static")), - ) - .data(state); - let server = Server::new(TcpListener::bind(format!("{}:{}", host, port))); - server.run(app).await +fn main() { + poem_example_api::main(); } diff --git a/examples/rocket_example/Cargo.toml b/examples/rocket_example/Cargo.toml index e16a7aeb..992ad036 100644 --- a/examples/rocket_example/Cargo.toml +++ b/examples/rocket_example/Cargo.toml @@ -6,33 +6,7 @@ edition = "2021" publish = false [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] -async-stream = { version = "^0.3" } -async-trait = { version = "0.1" } -futures = { version = "^0.3" } -futures-util = { version = "^0.3" } -rocket = { version = "0.5.0-rc.1", features = [ - "json", -] } -rocket_dyn_templates = { version = "0.1.0-rc.1", features = [ - "tera", -] } -serde_json = { version = "^1" } -entity = { path = "entity" } -migration = { path = "migration" } - -[dependencies.sea-orm-rocket] -path = "../../sea-orm-rocket/lib" # remove this line in your own project and use the git line -# git = "https://github.com/SeaQL/sea-orm" - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "runtime-tokio-native-tls", - "sqlx-postgres", - # "sqlx-mysql", - # "sqlx-sqlite", -] +rocket-example-api = { path = "api" } diff --git a/examples/rocket_example/README.md b/examples/rocket_example/README.md index a1e3af0f..20845db6 100644 --- a/examples/rocket_example/README.md +++ b/examples/rocket_example/README.md @@ -2,10 +2,17 @@ # Rocket with SeaORM example app -1. Modify the `url` var in `Rocket.toml` to point to your chosen database +1. Modify the `url` var in `api/Rocket.toml` to point to your chosen database -1. Turn on the appropriate database feature for your chosen db in `Cargo.toml` (the `"sqlx-postgres",` line) +1. Turn on the appropriate database feature for your chosen db in `core/Cargo.toml` (the `"sqlx-postgres",` line) 1. Execute `cargo run` to start the server 1. Visit [localhost:8000](http://localhost:8000) in browser after seeing the `🚀 Rocket has launched from http://localhost:8000` line + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/rocket_example/Rocket.toml b/examples/rocket_example/Rocket.toml index fc294bd2..41e0183c 100644 --- a/examples/rocket_example/Rocket.toml +++ b/examples/rocket_example/Rocket.toml @@ -1,5 +1,5 @@ [default] -template_dir = "templates/" +template_dir = "api/templates/" [default.databases.sea_orm] # Mysql diff --git a/examples/rocket_example/api/Cargo.toml b/examples/rocket_example/api/Cargo.toml new file mode 100644 index 00000000..2756af20 --- /dev/null +++ b/examples/rocket_example/api/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "rocket-example-api" +version = "0.1.0" +authors = ["Sam Samai "] +edition = "2021" +publish = false + +[dependencies] +async-stream = { version = "^0.3" } +async-trait = { version = "0.1" } +rocket-example-core = { path = "../core" } +futures = { version = "^0.3" } +futures-util = { version = "^0.3" } +rocket = { version = "0.5.0-rc.1", features = [ + "json", +] } +rocket_dyn_templates = { version = "0.1.0-rc.1", features = [ + "tera", +] } +serde_json = { version = "^1" } +entity = { path = "../entity" } +migration = { path = "../migration" } +tokio = "1.20.0" + +[dependencies.sea-orm-rocket] +path = "../../../sea-orm-rocket/lib" # remove this line in your own project and uncomment the following line +# version = "0.5.0" diff --git a/examples/rocket_example/api/src/lib.rs b/examples/rocket_example/api/src/lib.rs new file mode 100644 index 00000000..51918c7a --- /dev/null +++ b/examples/rocket_example/api/src/lib.rs @@ -0,0 +1,171 @@ +#[macro_use] +extern crate rocket; + +use rocket::fairing::{self, AdHoc}; +use rocket::form::{Context, Form}; +use rocket::fs::{relative, FileServer}; +use rocket::request::FlashMessage; +use rocket::response::{Flash, Redirect}; +use rocket::{Build, Request, Rocket}; +use rocket_dyn_templates::Template; +use rocket_example_core::{Mutation, Query}; +use serde_json::json; + +use migration::MigratorTrait; +use sea_orm_rocket::{Connection, Database}; + +mod pool; +use pool::Db; + +pub use entity::post; +pub use entity::post::Entity as Post; + +const DEFAULT_POSTS_PER_PAGE: u64 = 5; + +#[get("/new")] +async fn new() -> Template { + Template::render("new", &Context::default()) +} + +#[post("/", data = "")] +async fn create(conn: Connection<'_, Db>, post_form: Form) -> Flash { + let db = conn.into_inner(); + + let form = post_form.into_inner(); + + Mutation::create_post(db, form) + .await + .expect("could not insert post"); + + Flash::success(Redirect::to("/"), "Post successfully added.") +} + +#[post("/", data = "")] +async fn update( + conn: Connection<'_, Db>, + id: i32, + post_form: Form, +) -> Flash { + let db = conn.into_inner(); + + let form = post_form.into_inner(); + + Mutation::update_post_by_id(db, id, form) + .await + .expect("could not update post"); + + Flash::success(Redirect::to("/"), "Post successfully edited.") +} + +#[get("/?&")] +async fn list( + conn: Connection<'_, Db>, + page: Option, + posts_per_page: Option, + flash: Option>, +) -> Template { + let db = conn.into_inner(); + + // Set page number and items per page + let page = page.unwrap_or(1); + let posts_per_page = posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); + if page == 0 { + panic!("Page number cannot be zero"); + } + + let (posts, num_pages) = Query::find_posts_in_page(db, page, posts_per_page) + .await + .expect("Cannot find posts in page"); + + Template::render( + "index", + json! ({ + "page": page, + "posts_per_page": posts_per_page, + "num_pages": num_pages, + "posts": posts, + "flash": flash.map(FlashMessage::into_inner), + }), + ) +} + +#[get("/")] +async fn edit(conn: Connection<'_, Db>, id: i32) -> Template { + let db = conn.into_inner(); + + let post: Option = Query::find_post_by_id(db, id) + .await + .expect("could not find post"); + + Template::render( + "edit", + json! ({ + "post": post, + }), + ) +} + +#[delete("/")] +async fn delete(conn: Connection<'_, Db>, id: i32) -> Flash { + let db = conn.into_inner(); + + Mutation::delete_post(db, id) + .await + .expect("could not delete post"); + + Flash::success(Redirect::to("/"), "Post successfully deleted.") +} + +#[delete("/")] +async fn destroy(conn: Connection<'_, Db>) -> Result<(), rocket::response::Debug> { + let db = conn.into_inner(); + + Mutation::delete_all_posts(db) + .await + .map_err(|e| e.to_string())?; + + Ok(()) +} + +#[catch(404)] +pub fn not_found(req: &Request<'_>) -> Template { + Template::render( + "error/404", + json! ({ + "uri": req.uri() + }), + ) +} + +async fn run_migrations(rocket: Rocket) -> fairing::Result { + let conn = &Db::fetch(&rocket).unwrap().conn; + let _ = migration::Migrator::up(conn, None).await; + Ok(rocket) +} + +#[tokio::main] +async fn start() -> Result<(), rocket::Error> { + rocket::build() + .attach(Db::init()) + .attach(AdHoc::try_on_ignite("Migrations", run_migrations)) + .mount("/", FileServer::from(relative!("/static"))) + .mount( + "/", + routes![new, create, delete, destroy, list, edit, update], + ) + .register("/", catchers![not_found]) + .attach(Template::fairing()) + .launch() + .await + .map(|_| ()) +} + +pub fn main() { + let result = start(); + + println!("Rocket: deorbit."); + + if let Some(err) = result.err() { + println!("Error: {}", err); + } +} diff --git a/examples/rocket_example/src/pool.rs b/examples/rocket_example/api/src/pool.rs similarity index 97% rename from examples/rocket_example/src/pool.rs rename to examples/rocket_example/api/src/pool.rs index fe5f8c6e..b1c05677 100644 --- a/examples/rocket_example/src/pool.rs +++ b/examples/rocket_example/api/src/pool.rs @@ -1,3 +1,5 @@ +use rocket_example_core::sea_orm; + use async_trait::async_trait; use sea_orm::ConnectOptions; use sea_orm_rocket::{rocket::figment::Figment, Config, Database}; diff --git a/examples/rocket_example/static/css/normalize.css b/examples/rocket_example/api/static/css/normalize.css similarity index 100% rename from examples/rocket_example/static/css/normalize.css rename to examples/rocket_example/api/static/css/normalize.css diff --git a/examples/rocket_example/static/css/skeleton.css b/examples/rocket_example/api/static/css/skeleton.css similarity index 100% rename from examples/rocket_example/static/css/skeleton.css rename to examples/rocket_example/api/static/css/skeleton.css diff --git a/examples/rocket_example/static/css/style.css b/examples/rocket_example/api/static/css/style.css similarity index 100% rename from examples/rocket_example/static/css/style.css rename to examples/rocket_example/api/static/css/style.css diff --git a/examples/rocket_example/static/images/favicon.png b/examples/rocket_example/api/static/images/favicon.png similarity index 100% rename from examples/rocket_example/static/images/favicon.png rename to examples/rocket_example/api/static/images/favicon.png diff --git a/examples/rocket_example/templates/base.html.tera b/examples/rocket_example/api/templates/base.html.tera similarity index 100% rename from examples/rocket_example/templates/base.html.tera rename to examples/rocket_example/api/templates/base.html.tera diff --git a/examples/rocket_example/templates/edit.html.tera b/examples/rocket_example/api/templates/edit.html.tera similarity index 100% rename from examples/rocket_example/templates/edit.html.tera rename to examples/rocket_example/api/templates/edit.html.tera diff --git a/examples/rocket_example/templates/error/404.html.tera b/examples/rocket_example/api/templates/error/404.html.tera similarity index 100% rename from examples/rocket_example/templates/error/404.html.tera rename to examples/rocket_example/api/templates/error/404.html.tera diff --git a/examples/rocket_example/templates/index.html.tera b/examples/rocket_example/api/templates/index.html.tera similarity index 100% rename from examples/rocket_example/templates/index.html.tera rename to examples/rocket_example/api/templates/index.html.tera diff --git a/examples/rocket_example/templates/new.html.tera b/examples/rocket_example/api/templates/new.html.tera similarity index 100% rename from examples/rocket_example/templates/new.html.tera rename to examples/rocket_example/api/templates/new.html.tera diff --git a/examples/rocket_example/core/Cargo.toml b/examples/rocket_example/core/Cargo.toml new file mode 100644 index 00000000..a57a5560 --- /dev/null +++ b/examples/rocket_example/core/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "rocket-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "runtime-tokio-native-tls", + "sqlx-postgres", + # "sqlx-mysql", + # "sqlx-sqlite", +] + +[dev-dependencies] +tokio = "1.20.0" + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/rocket_example/core/src/lib.rs b/examples/rocket_example/core/src/lib.rs new file mode 100644 index 00000000..4a80f239 --- /dev/null +++ b/examples/rocket_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/rocket_example/core/src/mutation.rs b/examples/rocket_example/core/src/mutation.rs new file mode 100644 index 00000000..dd6891d4 --- /dev/null +++ b/examples/rocket_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/rocket_example/core/src/query.rs b/examples/rocket_example/core/src/query.rs new file mode 100644 index 00000000..e8d2668f --- /dev/null +++ b/examples/rocket_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/rocket_example/core/tests/mock.rs b/examples/rocket_example/core/tests/mock.rs new file mode 100644 index 00000000..84b187e5 --- /dev/null +++ b/examples/rocket_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use entity::post; +use prepare::prepare_mock_db; +use rocket_example_core::{Mutation, Query}; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/rocket_example/core/tests/prepare.rs b/examples/rocket_example/core/tests/prepare.rs new file mode 100644 index 00000000..45180493 --- /dev/null +++ b/examples/rocket_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/rocket_example/src/main.rs b/examples/rocket_example/src/main.rs index 4187f6a1..182a6875 100644 --- a/examples/rocket_example/src/main.rs +++ b/examples/rocket_example/src/main.rs @@ -1,184 +1,3 @@ -#[macro_use] -extern crate rocket; - -use rocket::fairing::{self, AdHoc}; -use rocket::form::{Context, Form}; -use rocket::fs::{relative, FileServer}; -use rocket::request::FlashMessage; -use rocket::response::{Flash, Redirect}; -use rocket::{Build, Request, Rocket}; -use rocket_dyn_templates::Template; -use serde_json::json; - -use migration::MigratorTrait; -use sea_orm::{entity::*, query::*}; -use sea_orm_rocket::{Connection, Database}; - -mod pool; -use pool::Db; - -pub use entity::post; -pub use entity::post::Entity as Post; - -const DEFAULT_POSTS_PER_PAGE: u64 = 5; - -#[get("/new")] -async fn new() -> Template { - Template::render("new", &Context::default()) -} - -#[post("/", data = "")] -async fn create(conn: Connection<'_, Db>, post_form: Form) -> Flash { - let db = conn.into_inner(); - - let form = post_form.into_inner(); - - post::ActiveModel { - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - ..Default::default() - } - .save(db) - .await - .expect("could not insert post"); - - Flash::success(Redirect::to("/"), "Post successfully added.") -} - -#[post("/", data = "")] -async fn update( - conn: Connection<'_, Db>, - id: i32, - post_form: Form, -) -> Flash { - let db = conn.into_inner(); - - let post: post::ActiveModel = Post::find_by_id(id).one(db).await.unwrap().unwrap().into(); - - let form = post_form.into_inner(); - - db.transaction::<_, (), sea_orm::DbErr>(|txn| { - Box::pin(async move { - post::ActiveModel { - id: post.id, - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - } - .save(txn) - .await - .expect("could not edit post"); - - Ok(()) - }) - }) - .await - .unwrap(); - - Flash::success(Redirect::to("/"), "Post successfully edited.") -} - -#[get("/?&")] -async fn list( - conn: Connection<'_, Db>, - page: Option, - posts_per_page: Option, - flash: Option>, -) -> Template { - let db = conn.into_inner(); - - // Set page number and items per page - let page = page.unwrap_or(1); - let posts_per_page = posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); - if page == 0 { - panic!("Page number cannot be zero"); - } - - // Setup paginator - let paginator = Post::find() - .order_by_asc(post::Column::Id) - .paginate(db, posts_per_page); - let num_pages = paginator.num_pages().await.ok().unwrap(); - - // Fetch paginated posts - let posts = paginator - .fetch_page(page - 1) - .await - .expect("could not retrieve posts"); - - Template::render( - "index", - json! ({ - "page": page, - "posts_per_page": posts_per_page, - "num_pages": num_pages, - "posts": posts, - "flash": flash.map(FlashMessage::into_inner), - }), - ) -} - -#[get("/")] -async fn edit(conn: Connection<'_, Db>, id: i32) -> Template { - let db = conn.into_inner(); - - let post: Option = Post::find_by_id(id) - .one(db) - .await - .expect("could not find post"); - - Template::render( - "edit", - json! ({ - "post": post, - }), - ) -} - -#[delete("/")] -async fn delete(conn: Connection<'_, Db>, id: i32) -> Flash { - let db = conn.into_inner(); - - let post: post::ActiveModel = Post::find_by_id(id).one(db).await.unwrap().unwrap().into(); - - post.delete(db).await.unwrap(); - - Flash::success(Redirect::to("/"), "Post successfully deleted.") -} - -#[delete("/")] -async fn destroy(conn: Connection<'_, Db>) -> Result<(), rocket::response::Debug> { - let db = conn.into_inner(); - - Post::delete_many().exec(db).await.unwrap(); - Ok(()) -} - -#[catch(404)] -pub fn not_found(req: &Request<'_>) -> Template { - Template::render( - "error/404", - json! ({ - "uri": req.uri() - }), - ) -} - -async fn run_migrations(rocket: Rocket) -> fairing::Result { - let conn = &Db::fetch(&rocket).unwrap().conn; - let _ = migration::Migrator::up(conn, None).await; - Ok(rocket) -} - -#[launch] -fn rocket() -> _ { - rocket::build() - .attach(Db::init()) - .attach(AdHoc::try_on_ignite("Migrations", run_migrations)) - .mount("/", FileServer::from(relative!("/static"))) - .mount( - "/", - routes![new, create, delete, destroy, list, edit, update], - ) - .register("/", catchers![not_found]) - .attach(Template::fairing()) +fn main() { + rocket_example_api::main(); } diff --git a/examples/rocket_okapi_example/Cargo.toml b/examples/rocket_okapi_example/Cargo.toml new file mode 100644 index 00000000..87dfde6f --- /dev/null +++ b/examples/rocket_okapi_example/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "sea-orm-rocket-okapi-example" +version = "0.1.0" +authors = ["Sam Samai ", "Erick Pacheco "] +edition = "2021" +publish = false + +[dependencies] +async-stream = { version = "^0.3" } +async-trait = { version = "0.1" } +rocket-example-core = { path = "../core" } +futures = { version = "^0.3" } +futures-util = { version = "^0.3" } +rocket = { version = "0.5.0-rc.1", features = [ + "json", +] } +rocket_dyn_templates = { version = "0.1.0-rc.1", features = [ + "tera", +] } +serde_json = { version = "^1" } +entity = { path = "../entity" } +migration = { path = "../migration" } +tokio = "1.20.0" +serde = "1.0" +dto = { path = "../dto" } + +[dependencies.sea-orm-rocket] +path = "../../../sea-orm-rocket/lib" # remove this line in your own project and use the version line +features = ["rocket_okapi"] # enables rocket_okapi so to have open api features enabled +# version = "0.5.1" + +[dependencies.rocket_okapi] +version = "0.8.0-rc.2" +features = ["swagger", "rapidoc","rocket_db_pools"] + +[dependencies.rocket_cors] +git = "https://github.com/lawliet89/rocket_cors.git" +rev = "54fae070" +default-features = false \ No newline at end of file diff --git a/examples/rocket_okapi_example/api/src/error.rs b/examples/rocket_okapi_example/api/src/error.rs new file mode 100644 index 00000000..88a27472 --- /dev/null +++ b/examples/rocket_okapi_example/api/src/error.rs @@ -0,0 +1,117 @@ +use rocket::{ + http::{ContentType, Status}, + request::Request, + response::{self, Responder, Response}, +}; +use rocket_okapi::okapi::openapi3::Responses; +use rocket_okapi::okapi::schemars::{self, Map}; +use rocket_okapi::{gen::OpenApiGenerator, response::OpenApiResponderInner, OpenApiError}; + +/// Error messages returned to user +#[derive(Debug, serde::Serialize, schemars::JsonSchema)] +pub struct Error { + /// The title of the error message + pub err: String, + /// The description of the error + pub msg: Option, + // HTTP Status Code returned + #[serde(skip)] + pub http_status_code: u16, +} + +impl OpenApiResponderInner for Error { + fn responses(_generator: &mut OpenApiGenerator) -> Result { + use rocket_okapi::okapi::openapi3::{RefOr, Response as OpenApiReponse}; + + let mut responses = Map::new(); + responses.insert( + "400".to_string(), + RefOr::Object(OpenApiReponse { + description: "\ + # [400 Bad Request](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400)\n\ + The request given is wrongly formatted or data asked could not be fulfilled. \ + " + .to_string(), + ..Default::default() + }), + ); + responses.insert( + "404".to_string(), + RefOr::Object(OpenApiReponse { + description: "\ + # [404 Not Found](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404)\n\ + This response is given when you request a page that does not exists.\ + " + .to_string(), + ..Default::default() + }), + ); + responses.insert( + "422".to_string(), + RefOr::Object(OpenApiReponse { + description: "\ + # [422 Unprocessable Entity](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/422)\n\ + This response is given when you request body is not correctly formatted. \ + ".to_string(), + ..Default::default() + }), + ); + responses.insert( + "500".to_string(), + RefOr::Object(OpenApiReponse { + description: "\ + # [500 Internal Server Error](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500)\n\ + This response is given when something wend wrong on the server. \ + ".to_string(), + ..Default::default() + }), + ); + Ok(Responses { + responses, + ..Default::default() + }) + } +} + +impl std::fmt::Display for Error { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "Error `{}`: {}", + self.err, + self.msg.as_deref().unwrap_or("") + ) + } +} + +impl std::error::Error for Error {} + +impl<'r> Responder<'r, 'static> for Error { + fn respond_to(self, _: &'r Request<'_>) -> response::Result<'static> { + // Convert object to json + let body = serde_json::to_string(&self).unwrap(); + Response::build() + .sized_body(body.len(), std::io::Cursor::new(body)) + .header(ContentType::JSON) + .status(Status::new(self.http_status_code)) + .ok() + } +} + +impl From> for Error { + fn from(err: rocket::serde::json::Error) -> Self { + use rocket::serde::json::Error::*; + match err { + Io(io_error) => Error { + err: "IO Error".to_owned(), + msg: Some(io_error.to_string()), + http_status_code: 422, + }, + Parse(_raw_data, parse_error) => Error { + err: "Parse Error".to_owned(), + msg: Some(parse_error.to_string()), + http_status_code: 422, + }, + } + } +} diff --git a/examples/rocket_okapi_example/api/src/lib.rs b/examples/rocket_okapi_example/api/src/lib.rs new file mode 100644 index 00000000..03cf66b2 --- /dev/null +++ b/examples/rocket_okapi_example/api/src/lib.rs @@ -0,0 +1,139 @@ +#[macro_use] +extern crate rocket; + +use rocket::fairing::{self, AdHoc}; +use rocket::{Build, Rocket}; + +use migration::MigratorTrait; +use sea_orm_rocket::Database; + +use rocket_okapi::mount_endpoints_and_merged_docs; +use rocket_okapi::okapi::openapi3::OpenApi; +use rocket_okapi::rapidoc::{make_rapidoc, GeneralConfig, HideShowConfig, RapiDocConfig}; +use rocket_okapi::settings::UrlObject; +use rocket_okapi::swagger_ui::{make_swagger_ui, SwaggerUIConfig}; + +use rocket::http::Method; +use rocket_cors::{AllowedHeaders, AllowedOrigins, Cors}; + +mod pool; +use pool::Db; +mod error; +mod okapi_example; + +pub use entity::post; +pub use entity::post::Entity as Post; + +async fn run_migrations(rocket: Rocket) -> fairing::Result { + let conn = &Db::fetch(&rocket).unwrap().conn; + let _ = migration::Migrator::up(conn, None).await; + Ok(rocket) +} + +#[tokio::main] +async fn start() -> Result<(), rocket::Error> { + let mut building_rocket = rocket::build() + .attach(Db::init()) + .attach(AdHoc::try_on_ignite("Migrations", run_migrations)) + .mount( + "/swagger-ui/", + make_swagger_ui(&SwaggerUIConfig { + url: "../v1/openapi.json".to_owned(), + ..Default::default() + }), + ) + .mount( + "/rapidoc/", + make_rapidoc(&RapiDocConfig { + title: Some("Rocket/SeaOrm - RapiDoc documentation | RapiDoc".to_owned()), + general: GeneralConfig { + spec_urls: vec![UrlObject::new("General", "../v1/openapi.json")], + ..Default::default() + }, + hide_show: HideShowConfig { + allow_spec_url_load: false, + allow_spec_file_load: false, + ..Default::default() + }, + ..Default::default() + }), + ) + .attach(cors()); + + let openapi_settings = rocket_okapi::settings::OpenApiSettings::default(); + let custom_route_spec = (vec![], custom_openapi_spec()); + mount_endpoints_and_merged_docs! { + building_rocket, "/v1".to_owned(), openapi_settings, + "/additional" => custom_route_spec, + "/okapi-example" => okapi_example::get_routes_and_docs(&openapi_settings), + }; + + building_rocket.launch().await.map(|_| ()) +} + +fn cors() -> Cors { + let allowed_origins = + AllowedOrigins::some_exact(&["http://localhost:8000", "http://127.0.0.1:8000"]); + + let cors = rocket_cors::CorsOptions { + allowed_origins, + allowed_methods: vec![Method::Get, Method::Post, Method::Delete] + .into_iter() + .map(From::from) + .collect(), + allowed_headers: AllowedHeaders::all(), + allow_credentials: true, + ..Default::default() + } + .to_cors() + .unwrap(); + cors +} + +fn custom_openapi_spec() -> OpenApi { + use rocket_okapi::okapi::openapi3::*; + OpenApi { + openapi: OpenApi::default_version(), + info: Info { + title: "SeaOrm-Rocket-Okapi Example".to_owned(), + description: Some("API Docs for Rocket/SeaOrm example".to_owned()), + terms_of_service: Some("https://github.com/SeaQL/sea-orm#license".to_owned()), + contact: Some(Contact { + name: Some("SeaOrm".to_owned()), + url: Some("https://github.com/SeaQL/sea-orm".to_owned()), + email: None, + ..Default::default() + }), + license: Some(License { + name: "MIT".to_owned(), + url: Some("https://github.com/SeaQL/sea-orm/blob/master/LICENSE-MIT".to_owned()), + ..Default::default() + }), + version: env!("CARGO_PKG_VERSION").to_owned(), + ..Default::default() + }, + servers: vec![ + Server { + url: "http://127.0.0.1:8000/v1".to_owned(), + description: Some("Localhost".to_owned()), + ..Default::default() + }, + Server { + url: "https://production-server.com/".to_owned(), + description: Some("Remote development server".to_owned()), + ..Default::default() + }, + ], + ..Default::default() + } +} + +pub fn main() { + let result = start(); + + println!("Rocket: deorbit."); + + if let Some(err) = result.err() { + println!("Error: {}", err); + } +} diff --git a/examples/rocket_okapi_example/api/src/okapi_example.rs b/examples/rocket_okapi_example/api/src/okapi_example.rs new file mode 100644 index 00000000..69d15d18 --- /dev/null +++ b/examples/rocket_okapi_example/api/src/okapi_example.rs @@ -0,0 +1,166 @@ +use dto::dto; +use rocket::serde::json::Json; +use rocket_example_core::{Mutation, Query}; + +use sea_orm_rocket::Connection; + +use rocket_okapi::okapi::openapi3::OpenApi; + +use crate::error; +use crate::pool; +use pool::Db; + +pub use entity::post; +pub use entity::post::Entity as Post; + +use rocket_okapi::settings::OpenApiSettings; + +use rocket_okapi::{openapi, openapi_get_routes_spec}; + +const DEFAULT_POSTS_PER_PAGE: u64 = 5; + +pub fn get_routes_and_docs(settings: &OpenApiSettings) -> (Vec, OpenApi) { + openapi_get_routes_spec![settings: create, update, list, get_by_id, delete, destroy] +} + +pub type R = std::result::Result, error::Error>; +pub type DataResult<'a, T> = + std::result::Result, rocket::serde::json::Error<'a>>; + +/// # Add a new post +#[openapi(tag = "POST")] +#[post("/", data = "")] +async fn create( + conn: Connection<'_, Db>, + post_data: DataResult<'_, post::Model>, +) -> R> { + let db = conn.into_inner(); + let form = post_data?.into_inner(); + let cmd = Mutation::create_post(db, form); + match cmd.await { + Ok(_) => Ok(Json(Some("Post successfully added.".to_string()))), + Err(e) => { + let m = error::Error { + err: "Could not insert post".to_string(), + msg: Some(e.to_string()), + http_status_code: 400, + }; + Err(m) + } + } +} + +/// # Update a post +#[openapi(tag = "POST")] +#[post("/", data = "")] +async fn update( + conn: Connection<'_, Db>, + id: i32, + post_data: DataResult<'_, post::Model>, +) -> R> { + let db = conn.into_inner(); + + let form = post_data?.into_inner(); + + let cmd = Mutation::update_post_by_id(db, id, form); + match cmd.await { + Ok(_) => Ok(Json(Some("Post successfully updated.".to_string()))), + Err(e) => { + let m = error::Error { + err: "Could not update post".to_string(), + msg: Some(e.to_string()), + http_status_code: 400, + }; + Err(m) + } + } +} + +/// # Get post list +#[openapi(tag = "POST")] +#[get("/?&")] +async fn list( + conn: Connection<'_, Db>, + page: Option, + posts_per_page: Option, +) -> R { + let db = conn.into_inner(); + + // Set page number and items per page + let page = page.unwrap_or(1); + let posts_per_page = posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); + if page == 0 { + let m = error::Error { + err: "error getting posts".to_string(), + msg: Some("'page' param cannot be zero".to_string()), + http_status_code: 400, + }; + return Err(m); + } + + let (posts, num_pages) = Query::find_posts_in_page(db, page, posts_per_page) + .await + .expect("Cannot find posts in page"); + + Ok(Json(dto::PostsDto { + page, + posts_per_page, + num_pages, + posts, + })) +} + +/// # get post by Id +#[openapi(tag = "POST")] +#[get("/")] +async fn get_by_id(conn: Connection<'_, Db>, id: i32) -> R> { + let db = conn.into_inner(); + + let post: Option = Query::find_post_by_id(db, id) + .await + .expect("could not find post"); + Ok(Json(post)) +} + +/// # delete post by Id +#[openapi(tag = "POST")] +#[delete("/")] +async fn delete(conn: Connection<'_, Db>, id: i32) -> R> { + let db = conn.into_inner(); + + let cmd = Mutation::delete_post(db, id); + match cmd.await { + Ok(_) => Ok(Json(Some("Post successfully deleted.".to_string()))), + Err(e) => { + let m = error::Error { + err: "Error deleting post".to_string(), + msg: Some(e.to_string()), + http_status_code: 400, + }; + Err(m) + } + } +} + +/// # delete all posts +#[openapi(tag = "POST")] +#[delete("/")] +async fn destroy(conn: Connection<'_, Db>) -> R> { + let db = conn.into_inner(); + + let cmd = Mutation::delete_all_posts(db); + + match cmd.await { + Ok(_) => Ok(Json(Some( + "All Posts were successfully deleted.".to_string(), + ))), + Err(e) => { + let m = error::Error { + err: "Error deleting all posts at once".to_string(), + msg: Some(e.to_string()), + http_status_code: 400, + }; + Err(m) + } + } +} diff --git a/examples/rocket_okapi_example/api/src/pool.rs b/examples/rocket_okapi_example/api/src/pool.rs new file mode 100644 index 00000000..b1c05677 --- /dev/null +++ b/examples/rocket_okapi_example/api/src/pool.rs @@ -0,0 +1,41 @@ +use rocket_example_core::sea_orm; + +use async_trait::async_trait; +use sea_orm::ConnectOptions; +use sea_orm_rocket::{rocket::figment::Figment, Config, Database}; +use std::time::Duration; + +#[derive(Database, Debug)] +#[database("sea_orm")] +pub struct Db(SeaOrmPool); + +#[derive(Debug, Clone)] +pub struct SeaOrmPool { + pub conn: sea_orm::DatabaseConnection, +} + +#[async_trait] +impl sea_orm_rocket::Pool for SeaOrmPool { + type Error = sea_orm::DbErr; + + type Connection = sea_orm::DatabaseConnection; + + async fn init(figment: &Figment) -> Result { + let config = figment.extract::().unwrap(); + let mut options: ConnectOptions = config.url.into(); + options + .max_connections(config.max_connections as u32) + .min_connections(config.min_connections.unwrap_or_default()) + .connect_timeout(Duration::from_secs(config.connect_timeout)); + if let Some(idle_timeout) = config.idle_timeout { + options.idle_timeout(Duration::from_secs(idle_timeout)); + } + let conn = sea_orm::Database::connect(options).await?; + + Ok(SeaOrmPool { conn }) + } + + fn borrow(&self) -> &Self::Connection { + &self.conn + } +} diff --git a/examples/rocket_okapi_example/core/Cargo.toml b/examples/rocket_okapi_example/core/Cargo.toml new file mode 100644 index 00000000..a57a5560 --- /dev/null +++ b/examples/rocket_okapi_example/core/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "rocket-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "runtime-tokio-native-tls", + "sqlx-postgres", + # "sqlx-mysql", + # "sqlx-sqlite", +] + +[dev-dependencies] +tokio = "1.20.0" + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/rocket_okapi_example/core/src/lib.rs b/examples/rocket_okapi_example/core/src/lib.rs new file mode 100644 index 00000000..4a80f239 --- /dev/null +++ b/examples/rocket_okapi_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/rocket_okapi_example/core/src/mutation.rs b/examples/rocket_okapi_example/core/src/mutation.rs new file mode 100644 index 00000000..dd6891d4 --- /dev/null +++ b/examples/rocket_okapi_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/rocket_okapi_example/core/src/query.rs b/examples/rocket_okapi_example/core/src/query.rs new file mode 100644 index 00000000..e8d2668f --- /dev/null +++ b/examples/rocket_okapi_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/rocket_okapi_example/core/tests/mock.rs b/examples/rocket_okapi_example/core/tests/mock.rs new file mode 100644 index 00000000..84b187e5 --- /dev/null +++ b/examples/rocket_okapi_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use entity::post; +use prepare::prepare_mock_db; +use rocket_example_core::{Mutation, Query}; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/rocket_okapi_example/core/tests/prepare.rs b/examples/rocket_okapi_example/core/tests/prepare.rs new file mode 100644 index 00000000..45180493 --- /dev/null +++ b/examples/rocket_okapi_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/rocket_okapi_example/dto/Cargo.toml b/examples/rocket_okapi_example/dto/Cargo.toml new file mode 100644 index 00000000..a0f208db --- /dev/null +++ b/examples/rocket_okapi_example/dto/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "dto" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +name = "dto" +path = "src/lib.rs" + +[dependencies] +rocket = { version = "0.5.0-rc.1", features = [ + "json", +] } + +[dependencies.entity] +path = "../entity" + +[dependencies.rocket_okapi] +version = "0.8.0-rc.2" \ No newline at end of file diff --git a/examples/rocket_okapi_example/dto/src/dto.rs b/examples/rocket_okapi_example/dto/src/dto.rs new file mode 100644 index 00000000..976b2cf0 --- /dev/null +++ b/examples/rocket_okapi_example/dto/src/dto.rs @@ -0,0 +1,12 @@ +use entity::*; +use rocket::serde::{Deserialize, Serialize}; +use rocket_okapi::okapi::schemars::{self, JsonSchema}; + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +#[serde(crate = "rocket::serde")] +pub struct PostsDto { + pub page: u64, + pub posts_per_page: u64, + pub num_pages: u64, + pub posts: Vec, +} diff --git a/examples/rocket_okapi_example/dto/src/lib.rs b/examples/rocket_okapi_example/dto/src/lib.rs new file mode 100644 index 00000000..a07dce5c --- /dev/null +++ b/examples/rocket_okapi_example/dto/src/lib.rs @@ -0,0 +1 @@ +pub mod dto; diff --git a/examples/rocket_okapi_example/entity/Cargo.toml b/examples/rocket_okapi_example/entity/Cargo.toml new file mode 100644 index 00000000..c1cf045d --- /dev/null +++ b/examples/rocket_okapi_example/entity/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "entity" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +name = "entity" +path = "src/lib.rs" + +[dependencies] +rocket = { version = "0.5.0-rc.1", features = [ + "json", +] } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version + +[dependencies.rocket_okapi] +version = "0.8.0-rc.2" diff --git a/examples/rocket_okapi_example/entity/src/lib.rs b/examples/rocket_okapi_example/entity/src/lib.rs new file mode 100644 index 00000000..06480a10 --- /dev/null +++ b/examples/rocket_okapi_example/entity/src/lib.rs @@ -0,0 +1,4 @@ +#[macro_use] +extern crate rocket; + +pub mod post; diff --git a/examples/rocket_okapi_example/entity/src/post.rs b/examples/rocket_okapi_example/entity/src/post.rs new file mode 100644 index 00000000..a5797f48 --- /dev/null +++ b/examples/rocket_okapi_example/entity/src/post.rs @@ -0,0 +1,21 @@ +use rocket::serde::{Deserialize, Serialize}; +use rocket_okapi::okapi::schemars::{self, JsonSchema}; +use sea_orm::entity::prelude::*; + +#[derive( + Clone, Debug, PartialEq, Eq, DeriveEntityModel, Deserialize, Serialize, FromForm, JsonSchema, +)] +#[serde(crate = "rocket::serde")] +#[sea_orm(table_name = "posts")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub title: String, + #[sea_orm(column_type = "Text")] + pub text: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/rocket_okapi_example/migration/Cargo.toml b/examples/rocket_okapi_example/migration/Cargo.toml new file mode 100644 index 00000000..b8251d20 --- /dev/null +++ b/examples/rocket_okapi_example/migration/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "migration" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +name = "migration" +path = "src/lib.rs" + +[dependencies] +rocket = { version = "0.5.0-rc.1" } +async-std = { version = "^1", features = ["attributes", "tokio1"] } + +[dependencies.sea-orm-migration] +path = "../../../sea-orm-migration" # remove this line in your own project +version = "^0.10.0" # sea-orm-migration version +features = [ + # Enable following runtime and db backend features if you want to run migration via CLI + # "runtime-tokio-native-tls", + # "sqlx-postgres", +] diff --git a/examples/rocket_okapi_example/migration/README.md b/examples/rocket_okapi_example/migration/README.md new file mode 100644 index 00000000..963caaeb --- /dev/null +++ b/examples/rocket_okapi_example/migration/README.md @@ -0,0 +1,37 @@ +# Running Migrator CLI + +- Apply all pending migrations + ```sh + cargo run + ``` + ```sh + cargo run -- up + ``` +- Apply first 10 pending migrations + ```sh + cargo run -- up -n 10 + ``` +- Rollback last applied migrations + ```sh + cargo run -- down + ``` +- Rollback last 10 applied migrations + ```sh + cargo run -- down -n 10 + ``` +- Drop all tables from the database, then reapply all migrations + ```sh + cargo run -- fresh + ``` +- Rollback all applied migrations, then reapply all migrations + ```sh + cargo run -- refresh + ``` +- Rollback all applied migrations + ```sh + cargo run -- reset + ``` +- Check the status of all migrations + ```sh + cargo run -- status + ``` diff --git a/examples/rocket_okapi_example/migration/src/lib.rs b/examples/rocket_okapi_example/migration/src/lib.rs new file mode 100644 index 00000000..af8d9b2a --- /dev/null +++ b/examples/rocket_okapi_example/migration/src/lib.rs @@ -0,0 +1,12 @@ +pub use sea_orm_migration::prelude::*; + +mod m20220120_000001_create_post_table; + +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![Box::new(m20220120_000001_create_post_table::Migration)] + } +} diff --git a/examples/rocket_okapi_example/migration/src/m20220120_000001_create_post_table.rs b/examples/rocket_okapi_example/migration/src/m20220120_000001_create_post_table.rs new file mode 100644 index 00000000..a2fa0219 --- /dev/null +++ b/examples/rocket_okapi_example/migration/src/m20220120_000001_create_post_table.rs @@ -0,0 +1,42 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Posts::Table) + .if_not_exists() + .col( + ColumnDef::new(Posts::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(Posts::Title).string().not_null()) + .col(ColumnDef::new(Posts::Text).string().not_null()) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Posts::Table).to_owned()) + .await + } +} + +/// Learn more at https://docs.rs/sea-query#iden +#[derive(Iden)] +enum Posts { + Table, + Id, + Title, + Text, +} diff --git a/examples/rocket_okapi_example/migration/src/main.rs b/examples/rocket_okapi_example/migration/src/main.rs new file mode 100644 index 00000000..4626e82f --- /dev/null +++ b/examples/rocket_okapi_example/migration/src/main.rs @@ -0,0 +1,17 @@ +use sea_orm_migration::prelude::*; + +#[async_std::main] +async fn main() { + // Setting `DATABASE_URL` environment variable + let key = "DATABASE_URL"; + if std::env::var(key).is_err() { + // Getting the database URL from Rocket.toml if it's not set + let figment = rocket::Config::figment(); + let database_url: String = figment + .extract_inner("databases.sea_orm.url") + .expect("Cannot find Database URL in Rocket.toml"); + std::env::set_var(key, database_url); + } + + cli::run_cli(migration::Migrator).await; +} diff --git a/examples/rocket_okapi_example/rapidoc.png b/examples/rocket_okapi_example/rapidoc.png new file mode 100644 index 00000000..f3f6c55c Binary files /dev/null and b/examples/rocket_okapi_example/rapidoc.png differ diff --git a/examples/rocket_okapi_example/src/main.rs b/examples/rocket_okapi_example/src/main.rs new file mode 100644 index 00000000..182a6875 --- /dev/null +++ b/examples/rocket_okapi_example/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + rocket_example_api::main(); +} diff --git a/examples/rocket_okapi_example/swagger.png b/examples/rocket_okapi_example/swagger.png new file mode 100644 index 00000000..b929be4f Binary files /dev/null and b/examples/rocket_okapi_example/swagger.png differ diff --git a/examples/salvo_example/Cargo.toml b/examples/salvo_example/Cargo.toml index a1028a77..aef02284 100644 --- a/examples/salvo_example/Cargo.toml +++ b/examples/salvo_example/Cargo.toml @@ -5,25 +5,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] -tokio = { version = "1.15.0", features = ["macros", "rt-multi-thread"] } -salvo = { version = "0.27", features = ["affix", "serve-static"] } -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -serde = { version = "1", features = ["derive"] } -tera = "1.8.0" -dotenv = "0.15" -entity = { path = "entity" } -migration = { path = "migration" } - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "debug-print", - "runtime-tokio-native-tls", - "sqlx-sqlite", - # "sqlx-postgres", - # "sqlx-mysql", -] +salvo-example-api = { path = "api" } diff --git a/examples/salvo_example/README.md b/examples/salvo_example/README.md index bd4a4539..d0aa6973 100644 --- a/examples/salvo_example/README.md +++ b/examples/salvo_example/README.md @@ -4,8 +4,15 @@ 1. Modify the `DATABASE_URL` var in `.env` to point to your chosen database -1. Turn on the appropriate database feature for your chosen db in `Cargo.toml` (the `"sqlx-sqlite",` line) +1. Turn on the appropriate database feature for your chosen db in `core/Cargo.toml` (the `"sqlx-sqlite",` line) 1. Execute `cargo run` to start the server 1. Visit [localhost:8000](http://localhost:8000) in browser after seeing the `server started` line + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/salvo_example/api/Cargo.toml b/examples/salvo_example/api/Cargo.toml new file mode 100644 index 00000000..6f329625 --- /dev/null +++ b/examples/salvo_example/api/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "salvo-example-api" +version = "0.1.0" +edition = "2021" + +[dependencies] +salvo-example-core = { path = "../core" } +tokio = { version = "1.15.0", features = ["macros", "rt-multi-thread"] } +salvo = { version = "0.27", features = ["affix", "serve-static"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +serde = { version = "1", features = ["derive"] } +tera = "1.8.0" +dotenvy = "0.15" +entity = { path = "../entity" } +migration = { path = "../migration" } diff --git a/examples/salvo_example/api/src/lib.rs b/examples/salvo_example/api/src/lib.rs new file mode 100644 index 00000000..70a74e31 --- /dev/null +++ b/examples/salvo_example/api/src/lib.rs @@ -0,0 +1,182 @@ +use std::env; + +use entity::post; +use migration::{Migrator, MigratorTrait}; +use salvo::extra::affix; +use salvo::extra::serve_static::DirHandler; +use salvo::prelude::*; +use salvo::writer::Text; +use salvo_example_core::{ + sea_orm::{Database, DatabaseConnection}, + Mutation, Query, +}; +use tera::Tera; + +const DEFAULT_POSTS_PER_PAGE: u64 = 5; +type Result = std::result::Result; + +#[derive(Debug, Clone)] +struct AppState { + templates: tera::Tera, + conn: DatabaseConnection, +} + +#[handler] +async fn create(req: &mut Request, depot: &mut Depot, res: &mut Response) -> Result<()> { + let state = depot + .obtain::() + .ok_or_else(StatusError::internal_server_error)?; + let conn = &state.conn; + + let form = req + .extract_form::() + .await + .map_err(|_| StatusError::bad_request())?; + + Mutation::create_post(conn, form) + .await + .map_err(|_| StatusError::internal_server_error())?; + + res.redirect_found("/"); + Ok(()) +} + +#[handler] +async fn list(req: &mut Request, depot: &mut Depot) -> Result> { + let state = depot + .obtain::() + .ok_or_else(StatusError::internal_server_error)?; + let conn = &state.conn; + + let page = req.query("page").unwrap_or(1); + let posts_per_page = req + .query("posts_per_page") + .unwrap_or(DEFAULT_POSTS_PER_PAGE); + + let (posts, num_pages) = Query::find_posts_in_page(conn, page, posts_per_page) + .await + .map_err(|_| StatusError::internal_server_error())?; + + let mut ctx = tera::Context::new(); + ctx.insert("posts", &posts); + ctx.insert("page", &page); + ctx.insert("posts_per_page", &posts_per_page); + ctx.insert("num_pages", &num_pages); + + let body = state + .templates + .render("index.html.tera", &ctx) + .map_err(|_| StatusError::internal_server_error())?; + Ok(Text::Html(body)) +} + +#[handler] +async fn new(depot: &mut Depot) -> Result> { + let state = depot + .obtain::() + .ok_or_else(StatusError::internal_server_error)?; + let ctx = tera::Context::new(); + let body = state + .templates + .render("new.html.tera", &ctx) + .map_err(|_| StatusError::internal_server_error())?; + Ok(Text::Html(body)) +} + +#[handler] +async fn edit(req: &mut Request, depot: &mut Depot) -> Result> { + let state = depot + .obtain::() + .ok_or_else(StatusError::internal_server_error)?; + let conn = &state.conn; + let id = req.param::("id").unwrap_or_default(); + + let post: post::Model = Query::find_post_by_id(conn, id) + .await + .map_err(|_| StatusError::internal_server_error())? + .ok_or_else(StatusError::not_found)?; + + let mut ctx = tera::Context::new(); + ctx.insert("post", &post); + + let body = state + .templates + .render("edit.html.tera", &ctx) + .map_err(|_| StatusError::internal_server_error())?; + Ok(Text::Html(body)) +} + +#[handler] +async fn update(req: &mut Request, depot: &mut Depot, res: &mut Response) -> Result<()> { + let state = depot + .obtain::() + .ok_or_else(StatusError::internal_server_error)?; + let conn = &state.conn; + let id = req.param::("id").unwrap_or_default(); + let form = req + .extract_form::() + .await + .map_err(|_| StatusError::bad_request())?; + + Mutation::update_post_by_id(conn, id, form) + .await + .map_err(|_| StatusError::internal_server_error())?; + + res.redirect_found("/"); + Ok(()) +} + +#[handler] +async fn delete(req: &mut Request, depot: &mut Depot, res: &mut Response) -> Result<()> { + let state = depot + .obtain::() + .ok_or_else(StatusError::internal_server_error)?; + let conn = &state.conn; + let id = req.param::("id").unwrap_or_default(); + + Mutation::delete_post(conn, id) + .await + .map_err(|_| StatusError::internal_server_error())?; + + res.redirect_found("/"); + Ok(()) +} + +#[tokio::main] +pub async fn main() { + std::env::set_var("RUST_LOG", "debug"); + tracing_subscriber::fmt::init(); + + // get env vars + dotenvy::dotenv().ok(); + let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); + let host = env::var("HOST").expect("HOST is not set in .env file"); + let port = env::var("PORT").expect("PORT is not set in .env file"); + let server_url = format!("{}:{}", host, port); + + // create post table if not exists + let conn = Database::connect(&db_url).await.unwrap(); + Migrator::up(&conn, None).await.unwrap(); + let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap(); + let state = AppState { templates, conn }; + + println!("Starting server at {}", server_url); + + let router = Router::new() + .hoop(affix::inject(state)) + .post(create) + .get(list) + .push(Router::with_path("new").get(new)) + .push(Router::with_path("").get(edit).post(update)) + .push(Router::with_path("delete/").post(delete)) + .push( + Router::with_path("static/<**>").get(DirHandler::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/static" + ))), + ); + + Server::new(TcpListener::bind(&format!("{}:{}", host, port))) + .serve(router) + .await; +} diff --git a/examples/salvo_example/static/css/normalize.css b/examples/salvo_example/api/static/css/normalize.css similarity index 100% rename from examples/salvo_example/static/css/normalize.css rename to examples/salvo_example/api/static/css/normalize.css diff --git a/examples/salvo_example/static/css/skeleton.css b/examples/salvo_example/api/static/css/skeleton.css similarity index 100% rename from examples/salvo_example/static/css/skeleton.css rename to examples/salvo_example/api/static/css/skeleton.css diff --git a/examples/salvo_example/static/css/style.css b/examples/salvo_example/api/static/css/style.css similarity index 100% rename from examples/salvo_example/static/css/style.css rename to examples/salvo_example/api/static/css/style.css diff --git a/examples/salvo_example/static/images/favicon.png b/examples/salvo_example/api/static/images/favicon.png similarity index 100% rename from examples/salvo_example/static/images/favicon.png rename to examples/salvo_example/api/static/images/favicon.png diff --git a/examples/salvo_example/templates/edit.html.tera b/examples/salvo_example/api/templates/edit.html.tera similarity index 100% rename from examples/salvo_example/templates/edit.html.tera rename to examples/salvo_example/api/templates/edit.html.tera diff --git a/examples/salvo_example/templates/error/404.html.tera b/examples/salvo_example/api/templates/error/404.html.tera similarity index 100% rename from examples/salvo_example/templates/error/404.html.tera rename to examples/salvo_example/api/templates/error/404.html.tera diff --git a/examples/salvo_example/templates/index.html.tera b/examples/salvo_example/api/templates/index.html.tera similarity index 100% rename from examples/salvo_example/templates/index.html.tera rename to examples/salvo_example/api/templates/index.html.tera diff --git a/examples/salvo_example/templates/layout.html.tera b/examples/salvo_example/api/templates/layout.html.tera similarity index 100% rename from examples/salvo_example/templates/layout.html.tera rename to examples/salvo_example/api/templates/layout.html.tera diff --git a/examples/salvo_example/templates/new.html.tera b/examples/salvo_example/api/templates/new.html.tera similarity index 100% rename from examples/salvo_example/templates/new.html.tera rename to examples/salvo_example/api/templates/new.html.tera diff --git a/examples/salvo_example/core/Cargo.toml b/examples/salvo_example/core/Cargo.toml new file mode 100644 index 00000000..58280ed2 --- /dev/null +++ b/examples/salvo_example/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "salvo-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "debug-print", + "runtime-tokio-native-tls", + # "sqlx-mysql", + # "sqlx-postgres", + "sqlx-sqlite", +] + +[dev-dependencies] +tokio = { version = "1.20.0", features = ["macros", "rt"] } + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/salvo_example/core/src/lib.rs b/examples/salvo_example/core/src/lib.rs new file mode 100644 index 00000000..4a80f239 --- /dev/null +++ b/examples/salvo_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/salvo_example/core/src/mutation.rs b/examples/salvo_example/core/src/mutation.rs new file mode 100644 index 00000000..dd6891d4 --- /dev/null +++ b/examples/salvo_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/salvo_example/core/src/query.rs b/examples/salvo_example/core/src/query.rs new file mode 100644 index 00000000..e8d2668f --- /dev/null +++ b/examples/salvo_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/salvo_example/core/tests/mock.rs b/examples/salvo_example/core/tests/mock.rs new file mode 100644 index 00000000..261652bf --- /dev/null +++ b/examples/salvo_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use entity::post; +use prepare::prepare_mock_db; +use salvo_example_core::{Mutation, Query}; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/salvo_example/core/tests/prepare.rs b/examples/salvo_example/core/tests/prepare.rs new file mode 100644 index 00000000..45180493 --- /dev/null +++ b/examples/salvo_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/salvo_example/src/main.rs b/examples/salvo_example/src/main.rs index ba38042b..687ea53e 100644 --- a/examples/salvo_example/src/main.rs +++ b/examples/salvo_example/src/main.rs @@ -1,191 +1,3 @@ -use std::env; - -use entity::post; -use migration::{Migrator, MigratorTrait}; -use salvo::extra::affix; -use salvo::extra::serve_static::DirHandler; -use salvo::prelude::*; -use salvo::writer::Text; -use sea_orm::{entity::*, query::*, DatabaseConnection}; -use tera::Tera; - -const DEFAULT_POSTS_PER_PAGE: u64 = 5; -type Result = std::result::Result; - -#[derive(Debug, Clone)] -struct AppState { - templates: tera::Tera, - conn: DatabaseConnection, -} - -#[handler] -async fn create(req: &mut Request, depot: &mut Depot, res: &mut Response) -> Result<()> { - let state = depot - .obtain::() - .ok_or_else(StatusError::internal_server_error)?; - let form = req - .extract_form::() - .await - .map_err(|_| StatusError::bad_request())?; - post::ActiveModel { - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - ..Default::default() - } - .save(&state.conn) - .await - .map_err(|_| StatusError::internal_server_error())?; - - res.redirect_found("/"); - Ok(()) -} - -#[handler] -async fn list(req: &mut Request, depot: &mut Depot) -> Result> { - let state = depot - .obtain::() - .ok_or_else(StatusError::internal_server_error)?; - let page = req.query("page").unwrap_or(1); - let posts_per_page = req - .query("posts_per_page") - .unwrap_or(DEFAULT_POSTS_PER_PAGE); - let paginator = post::Entity::find() - .order_by_asc(post::Column::Id) - .paginate(&state.conn, posts_per_page); - let num_pages = paginator - .num_pages() - .await - .map_err(|_| StatusError::bad_request())?; - let posts = paginator - .fetch_page(page - 1) - .await - .map_err(|_| StatusError::internal_server_error())?; - - let mut ctx = tera::Context::new(); - ctx.insert("posts", &posts); - ctx.insert("page", &page); - ctx.insert("posts_per_page", &posts_per_page); - ctx.insert("num_pages", &num_pages); - - let body = state - .templates - .render("index.html.tera", &ctx) - .map_err(|_| StatusError::internal_server_error())?; - Ok(Text::Html(body)) -} - -#[handler] -async fn new(depot: &mut Depot) -> Result> { - let state = depot - .obtain::() - .ok_or_else(StatusError::internal_server_error)?; - let ctx = tera::Context::new(); - let body = state - .templates - .render("new.html.tera", &ctx) - .map_err(|_| StatusError::internal_server_error())?; - Ok(Text::Html(body)) -} - -#[handler] -async fn edit(req: &mut Request, depot: &mut Depot) -> Result> { - let state = depot - .obtain::() - .ok_or_else(StatusError::internal_server_error)?; - let id = req.param::("id").unwrap_or_default(); - let post: post::Model = post::Entity::find_by_id(id) - .one(&state.conn) - .await - .map_err(|_| StatusError::internal_server_error())? - .ok_or_else(StatusError::not_found)?; - - let mut ctx = tera::Context::new(); - ctx.insert("post", &post); - - let body = state - .templates - .render("edit.html.tera", &ctx) - .map_err(|_| StatusError::internal_server_error())?; - Ok(Text::Html(body)) -} - -#[handler] -async fn update(req: &mut Request, depot: &mut Depot, res: &mut Response) -> Result<()> { - let state = depot - .obtain::() - .ok_or_else(StatusError::internal_server_error)?; - let id = req.param::("id").unwrap_or_default(); - let form = req - .extract_form::() - .await - .map_err(|_| StatusError::bad_request())?; - post::ActiveModel { - id: Set(id), - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - } - .save(&state.conn) - .await - .map_err(|_| StatusError::internal_server_error())?; - res.redirect_found("/"); - Ok(()) -} - -#[handler] -async fn delete(req: &mut Request, depot: &mut Depot, res: &mut Response) -> Result<()> { - let state = depot - .obtain::() - .ok_or_else(StatusError::internal_server_error)?; - let id = req.param::("id").unwrap_or_default(); - let post: post::ActiveModel = post::Entity::find_by_id(id) - .one(&state.conn) - .await - .map_err(|_| StatusError::internal_server_error())? - .ok_or_else(StatusError::not_found)? - .into(); - post.delete(&state.conn) - .await - .map_err(|_| StatusError::internal_server_error())?; - - res.redirect_found("/"); - Ok(()) -} - -#[tokio::main] -async fn main() { - std::env::set_var("RUST_LOG", "debug"); - tracing_subscriber::fmt::init(); - - // get env vars - dotenv::dotenv().ok(); - let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); - let host = env::var("HOST").expect("HOST is not set in .env file"); - let port = env::var("PORT").expect("PORT is not set in .env file"); - let server_url = format!("{}:{}", host, port); - - // create post table if not exists - let conn = sea_orm::Database::connect(&db_url).await.unwrap(); - Migrator::up(&conn, None).await.unwrap(); - let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap(); - let state = AppState { templates, conn }; - - println!("Starting server at {}", server_url); - - let router = Router::new() - .hoop(affix::inject(state)) - .post(create) - .get(list) - .push(Router::with_path("new").get(new)) - .push(Router::with_path("").get(edit).post(update)) - .push(Router::with_path("delete/").post(delete)) - .push( - Router::with_path("static/<**>").get(DirHandler::new(concat!( - env!("CARGO_MANIFEST_DIR"), - "/static" - ))), - ); - - Server::new(TcpListener::bind(&format!("{}:{}", host, port))) - .serve(router) - .await; +fn main() { + salvo_example_api::main(); } diff --git a/examples/tonic_example/Cargo.toml b/examples/tonic_example/Cargo.toml index 4bb3346f..d3d14704 100644 --- a/examples/tonic_example/Cargo.toml +++ b/examples/tonic_example/Cargo.toml @@ -7,37 +7,17 @@ publish = false # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] +tonic-example-api = { path = "api" } tonic = "0.7" tokio = { version = "1.17", features = ["macros", "rt-multi-thread", "full"] } -entity = { path = "entity" } -migration = { path = "migration" } -prost = "0.10.0" -serde = "1.0" - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "debug-print", - "runtime-tokio-rustls", - # "sqlx-mysql", - "sqlx-postgres", - # "sqlx-sqlite", -] - -[lib] -path = "./src/lib.rs" [[bin]] -name="server" -path="./src/server.rs" +name = "server" +path = "./src/server.rs" [[bin]] -name="client" -path="./src/client.rs" - -[build-dependencies] -tonic-build = "0.7" +name = "client" +path = "./src/client.rs" diff --git a/examples/tonic_example/README.md b/examples/tonic_example/README.md index 22ef798f..7c11feac 100644 --- a/examples/tonic_example/README.md +++ b/examples/tonic_example/README.md @@ -3,11 +3,20 @@ Simple implementation of gRPC using SeaORM. run server using + ```bash cargo run --bin server ``` run client using + ```bash cargo run --bin client -``` \ No newline at end of file +``` + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/tonic_example/api/Cargo.toml b/examples/tonic_example/api/Cargo.toml new file mode 100644 index 00000000..6253c531 --- /dev/null +++ b/examples/tonic_example/api/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "tonic-example-api" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +tonic-example-core = { path = "../core" } +tonic = "0.7" +tokio = { version = "1.17", features = ["macros", "rt-multi-thread", "full"] } +entity = { path = "../entity" } +migration = { path = "../migration" } +prost = "0.10.0" +serde = "1.0" + +[lib] +path = "./src/lib.rs" + +[build-dependencies] +tonic-build = "0.7" diff --git a/examples/tonic_example/build.rs b/examples/tonic_example/api/build.rs similarity index 100% rename from examples/tonic_example/build.rs rename to examples/tonic_example/api/build.rs diff --git a/examples/tonic_example/proto/post.proto b/examples/tonic_example/api/proto/post.proto similarity index 100% rename from examples/tonic_example/proto/post.proto rename to examples/tonic_example/api/proto/post.proto diff --git a/examples/tonic_example/api/src/lib.rs b/examples/tonic_example/api/src/lib.rs new file mode 100644 index 00000000..9b3e2582 --- /dev/null +++ b/examples/tonic_example/api/src/lib.rs @@ -0,0 +1,143 @@ +use tonic::transport::Server; +use tonic::{Request, Response, Status}; + +use entity::post; +use migration::{Migrator, MigratorTrait}; +use tonic_example_core::{ + sea_orm::{Database, DatabaseConnection}, + Mutation, Query, +}; + +use std::env; + +pub mod post_mod { + tonic::include_proto!("post"); +} + +use post_mod::{ + blogpost_server::{Blogpost, BlogpostServer}, + Post, PostId, PostList, PostPerPage, ProcessStatus, +}; + +impl Post { + fn into_model(self) -> post::Model { + post::Model { + id: self.id, + title: self.title, + text: self.content, + } + } +} + +#[derive(Default)] +pub struct MyServer { + connection: DatabaseConnection, +} + +#[tonic::async_trait] +impl Blogpost for MyServer { + async fn get_posts(&self, request: Request) -> Result, Status> { + let conn = &self.connection; + let posts_per_page = request.into_inner().per_page; + + let mut response = PostList { post: Vec::new() }; + + let (posts, _) = Query::find_posts_in_page(conn, 1, posts_per_page) + .await + .expect("Cannot find posts in page"); + + for post in posts { + response.post.push(Post { + id: post.id, + title: post.title, + content: post.text, + }); + } + + Ok(Response::new(response)) + } + + async fn add_post(&self, request: Request) -> Result, Status> { + let conn = &self.connection; + + let input = request.into_inner().into_model(); + + let inserted = Mutation::create_post(conn, input) + .await + .expect("could not insert post"); + + let response = PostId { + id: inserted.id.unwrap(), + }; + + Ok(Response::new(response)) + } + + async fn update_post(&self, request: Request) -> Result, Status> { + let conn = &self.connection; + let input = request.into_inner().into_model(); + + match Mutation::update_post_by_id(conn, input.id, input).await { + Ok(_) => Ok(Response::new(ProcessStatus { success: true })), + Err(_) => Ok(Response::new(ProcessStatus { success: false })), + } + } + + async fn delete_post( + &self, + request: Request, + ) -> Result, Status> { + let conn = &self.connection; + let id = request.into_inner().id; + + match Mutation::delete_post(conn, id).await { + Ok(_) => Ok(Response::new(ProcessStatus { success: true })), + Err(_) => Ok(Response::new(ProcessStatus { success: false })), + } + } + + async fn get_post_by_id(&self, request: Request) -> Result, Status> { + let conn = &self.connection; + let id = request.into_inner().id; + + if let Some(post) = Query::find_post_by_id(conn, id).await.ok().flatten() { + Ok(Response::new(Post { + id, + title: post.title, + content: post.text, + })) + } else { + Err(Status::new( + tonic::Code::Aborted, + "Could not find post with id ".to_owned() + &id.to_string(), + )) + } + } +} + +#[tokio::main] +async fn start() -> Result<(), Box> { + let addr = "0.0.0.0:50051".parse()?; + + let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + + // establish database connection + let connection = Database::connect(&database_url).await?; + Migrator::up(&connection, None).await?; + + let hello_server = MyServer { connection }; + Server::builder() + .add_service(BlogpostServer::new(hello_server)) + .serve(addr) + .await?; + + Ok(()) +} + +pub fn main() { + let result = start(); + + if let Some(err) = result.err() { + println!("Error: {}", err); + } +} diff --git a/examples/tonic_example/core/Cargo.toml b/examples/tonic_example/core/Cargo.toml new file mode 100644 index 00000000..dd9dd4e0 --- /dev/null +++ b/examples/tonic_example/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "tonic-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "debug-print", + "runtime-tokio-rustls", + # "sqlx-mysql", + "sqlx-postgres", + # "sqlx-sqlite", +] + +[dev-dependencies] +tokio = { version = "1.20.0", features = ["macros", "rt"] } + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/tonic_example/core/src/lib.rs b/examples/tonic_example/core/src/lib.rs new file mode 100644 index 00000000..4a80f239 --- /dev/null +++ b/examples/tonic_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/tonic_example/core/src/mutation.rs b/examples/tonic_example/core/src/mutation.rs new file mode 100644 index 00000000..dd6891d4 --- /dev/null +++ b/examples/tonic_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/tonic_example/core/src/query.rs b/examples/tonic_example/core/src/query.rs new file mode 100644 index 00000000..e8d2668f --- /dev/null +++ b/examples/tonic_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/tonic_example/core/tests/mock.rs b/examples/tonic_example/core/tests/mock.rs new file mode 100644 index 00000000..522d3e45 --- /dev/null +++ b/examples/tonic_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use entity::post; +use prepare::prepare_mock_db; +use tonic_example_core::{Mutation, Query}; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/tonic_example/core/tests/prepare.rs b/examples/tonic_example/core/tests/prepare.rs new file mode 100644 index 00000000..45180493 --- /dev/null +++ b/examples/tonic_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/tonic_example/src/client.rs b/examples/tonic_example/src/client.rs index 4cd46cb4..f63ad46a 100644 --- a/examples/tonic_example/src/client.rs +++ b/examples/tonic_example/src/client.rs @@ -1,7 +1,7 @@ use tonic::transport::Endpoint; use tonic::Request; -use sea_orm_tonic_example::post::{blogpost_client::BlogpostClient, PostPerPage}; +use tonic_example_api::post_mod::{blogpost_client::BlogpostClient, PostPerPage}; #[tokio::main] async fn main() -> Result<(), Box> { diff --git a/examples/tonic_example/src/lib.rs b/examples/tonic_example/src/lib.rs deleted file mode 100644 index cd202896..00000000 --- a/examples/tonic_example/src/lib.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod post { - tonic::include_proto!("post"); -} diff --git a/examples/tonic_example/src/server.rs b/examples/tonic_example/src/server.rs index 0857ac7b..a9838cdc 100644 --- a/examples/tonic_example/src/server.rs +++ b/examples/tonic_example/src/server.rs @@ -1,130 +1,3 @@ -use tonic::transport::Server; -use tonic::{Request, Response, Status}; - -use sea_orm_tonic_example::post::{ - blogpost_server::{Blogpost, BlogpostServer}, - Post, PostId, PostList, PostPerPage, ProcessStatus, -}; - -use entity::post::{self, Entity as PostEntity}; -use migration::{Migrator, MigratorTrait}; -use sea_orm::{self, entity::*, query::*, DatabaseConnection}; - -use std::env; - -#[derive(Default)] -pub struct MyServer { - connection: DatabaseConnection, -} - -#[tonic::async_trait] -impl Blogpost for MyServer { - async fn get_posts(&self, request: Request) -> Result, Status> { - let mut response = PostList { post: Vec::new() }; - - let posts = PostEntity::find() - .order_by_asc(post::Column::Id) - .limit(request.into_inner().per_page) - .all(&self.connection) - .await - .unwrap(); - - for post in posts { - response.post.push(Post { - id: post.id, - title: post.title, - content: post.text, - }); - } - - Ok(Response::new(response)) - } - - async fn add_post(&self, request: Request) -> Result, Status> { - let input = request.into_inner(); - let insert_details = post::ActiveModel { - title: Set(input.title.clone()), - text: Set(input.content.clone()), - ..Default::default() - }; - - let response = PostId { - id: insert_details.insert(&self.connection).await.unwrap().id, - }; - - Ok(Response::new(response)) - } - - async fn update_post(&self, request: Request) -> Result, Status> { - let input = request.into_inner(); - let mut update_post: post::ActiveModel = PostEntity::find_by_id(input.id) - .one(&self.connection) - .await - .unwrap() - .unwrap() - .into(); - - update_post.title = Set(input.title.clone()); - update_post.text = Set(input.content.clone()); - - let update = update_post.update(&self.connection).await; - - match update { - Ok(_) => Ok(Response::new(ProcessStatus { success: true })), - Err(_) => Ok(Response::new(ProcessStatus { success: false })), - } - } - - async fn delete_post( - &self, - request: Request, - ) -> Result, Status> { - let delete_post: post::ActiveModel = PostEntity::find_by_id(request.into_inner().id) - .one(&self.connection) - .await - .unwrap() - .unwrap() - .into(); - - let status = delete_post.delete(&self.connection).await; - - match status { - Ok(_) => Ok(Response::new(ProcessStatus { success: true })), - Err(_) => Ok(Response::new(ProcessStatus { success: false })), - } - } - - async fn get_post_by_id(&self, request: Request) -> Result, Status> { - let post = PostEntity::find_by_id(request.into_inner().id) - .one(&self.connection) - .await - .unwrap() - .unwrap(); - - let response = Post { - id: post.id, - title: post.title, - content: post.text, - }; - Ok(Response::new(response)) - } -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - let addr = "0.0.0.0:50051".parse()?; - - let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); - - // establish database connection - let connection = sea_orm::Database::connect(&database_url).await?; - Migrator::up(&connection, None).await?; - - let hello_server = MyServer { connection }; - Server::builder() - .add_service(BlogpostServer::new(hello_server)) - .serve(addr) - .await?; - - Ok(()) +fn main() { + tonic_example_api::main(); } diff --git a/issues/324/src/model.rs b/issues/324/src/model.rs index ad12d15e..697d1b5c 100644 --- a/issues/324/src/model.rs +++ b/issues/324/src/model.rs @@ -26,10 +26,7 @@ macro_rules! impl_try_from_u64_err { ($newtype: ident) => { impl sea_orm::TryFromU64 for $newtype { fn try_from_u64(_n: u64) -> Result { - Err(sea_orm::DbErr::Exec(format!( - "{} cannot be converted from u64", - stringify!($newtype) - ))) + Err(sea_orm::DbErr::ConvertFromU64(stringify!($newtype))) } } }; diff --git a/issues/400/src/model.rs b/issues/400/src/model.rs index 4a207ce1..99af3888 100644 --- a/issues/400/src/model.rs +++ b/issues/400/src/model.rs @@ -1,5 +1,5 @@ -use std::marker::PhantomData; use sea_orm::entity::prelude::*; +use std::marker::PhantomData; #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] #[sea_orm(table_name = "model")] @@ -31,10 +31,7 @@ impl From> for Uuid { impl sea_orm::TryFromU64 for AccountId { fn try_from_u64(_n: u64) -> Result { - Err(sea_orm::DbErr::Exec(format!( - "{} cannot be converted from u64", - stringify!(AccountId) - ))) + Err(sea_orm::DbErr::ConvertFromU64(stringify!(AccountId))) } } diff --git a/issues/471/Cargo.toml b/issues/471/Cargo.toml index dffc14c6..5640366f 100644 --- a/issues/471/Cargo.toml +++ b/issues/471/Cargo.toml @@ -11,7 +11,7 @@ publish = false [dependencies] tokio = { version = "1.14", features = ["full"] } anyhow = "1" -dotenv = "0.15" +dotenvy = "0.15" futures-util = "0.3" serde = "1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/issues/471/src/main.rs b/issues/471/src/main.rs index 9d7f664d..d4e93a95 100644 --- a/issues/471/src/main.rs +++ b/issues/471/src/main.rs @@ -11,7 +11,7 @@ async fn main() -> anyhow::Result<()> { env::set_var("RUST_LOG", "debug"); tracing_subscriber::fmt::init(); - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); let db = Database::connect(db_url) .await diff --git a/issues/471/src/post.rs b/issues/471/src/post.rs index 3bb4d6a3..1e2e6046 100644 --- a/issues/471/src/post.rs +++ b/issues/471/src/post.rs @@ -14,13 +14,7 @@ pub struct Model { pub text: String, } -#[derive(Copy, Clone, Debug, EnumIter)] +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} -impl RelationTrait for Relation { - fn def(&self) -> RelationDef { - panic!("No RelationDef") - } -} - impl ActiveModelBehavior for ActiveModel {} diff --git a/issues/630/src/entity/underscores.rs b/issues/630/src/entity/underscores.rs index ddd0be85..4dd20ac1 100644 --- a/issues/630/src/entity/underscores.rs +++ b/issues/630/src/entity/underscores.rs @@ -15,15 +15,9 @@ pub struct Model { pub aa_b_c_d: i32, } -#[derive(Copy, Clone, Debug, EnumIter)] +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} -impl RelationTrait for Relation { - fn def(&self) -> RelationDef { - panic!("No RelationDef") - } -} - impl ActiveModelBehavior for ActiveModel {} #[cfg(test)] diff --git a/issues/630/src/entity/underscores_workaround.rs b/issues/630/src/entity/underscores_workaround.rs index 6773ad15..68cc0108 100644 --- a/issues/630/src/entity/underscores_workaround.rs +++ b/issues/630/src/entity/underscores_workaround.rs @@ -20,15 +20,9 @@ pub struct Model { pub aa_b_c_d: i32, } -#[derive(Copy, Clone, Debug, EnumIter)] +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} -impl RelationTrait for Relation { - fn def(&self) -> RelationDef { - panic!("No RelationDef") - } -} - impl ActiveModelBehavior for ActiveModel {} #[cfg(test)] diff --git a/issues/693/Cargo.toml b/issues/693/Cargo.toml index acd1bb0e..8ec2caee 100644 --- a/issues/693/Cargo.toml +++ b/issues/693/Cargo.toml @@ -11,7 +11,7 @@ publish = false [dependencies] tokio = { version = "1.14", features = ["full"] } anyhow = "1" -dotenv = "0.15" +dotenvy = "0.15" futures-util = "0.3" serde = "1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/sea-orm-cli/Cargo.toml b/sea-orm-cli/Cargo.toml index f1e26a56..710f84be 100644 --- a/sea-orm-cli/Cargo.toml +++ b/sea-orm-cli/Cargo.toml @@ -32,8 +32,8 @@ required-features = ["codegen"] [dependencies] clap = { version = "^3.2", features = ["env", "derive"] } -dotenv = { version = "^0.15" } -async-std = { version = "^1.9", features = [ "attributes", "tokio1" ] } +dotenvy = { version = "^0.15", optional = true } +async-std = { version = "^1.9", features = [ "attributes", "tokio1" ], optional = true } sea-orm-codegen = { version = "^0.10.0", path = "../sea-orm-codegen", optional = true } sea-schema = { version = "^0.9.3" } sqlx = { version = "^0.6", default-features = false, features = [ "mysql", "postgres" ], optional = true } @@ -47,7 +47,7 @@ regex = "1" smol = "1.2.5" [features] -default = [ "codegen", "runtime-async-std-native-tls" ] +default = [ "codegen", "runtime-async-std-native-tls", "dotenvy", "async-std" ] codegen = [ "sea-schema/sqlx-all", "sea-orm-codegen" ] runtime-actix-native-tls = [ "sqlx/runtime-actix-native-tls", "sea-schema/runtime-actix-native-tls" ] runtime-async-std-native-tls = [ "sqlx/runtime-async-std-native-tls", "sea-schema/runtime-async-std-native-tls" ] diff --git a/sea-orm-cli/src/bin/main.rs b/sea-orm-cli/src/bin/main.rs index ce41c616..e847d003 100644 --- a/sea-orm-cli/src/bin/main.rs +++ b/sea-orm-cli/src/bin/main.rs @@ -1,5 +1,5 @@ use clap::StructOpt; -use dotenv::dotenv; +use dotenvy::dotenv; use sea_orm_cli::{handle_error, run_generate_command, run_migrate_command, Cli, Commands}; #[async_std::main] @@ -17,8 +17,16 @@ async fn main() { } Commands::Migrate { migration_dir, + database_schema, + database_url, command, - } => run_migrate_command(command, migration_dir.as_str(), verbose) - .unwrap_or_else(handle_error), + } => run_migrate_command( + command, + &migration_dir, + database_schema, + database_url, + verbose, + ) + .unwrap_or_else(handle_error), } } diff --git a/sea-orm-cli/src/bin/sea.rs b/sea-orm-cli/src/bin/sea.rs index cc945f95..edf15c83 100644 --- a/sea-orm-cli/src/bin/sea.rs +++ b/sea-orm-cli/src/bin/sea.rs @@ -1,7 +1,7 @@ //! COPY FROM bin/main.rs use clap::StructOpt; -use dotenv::dotenv; +use dotenvy::dotenv; use sea_orm_cli::{handle_error, run_generate_command, run_migrate_command, Cli, Commands}; #[async_std::main] @@ -19,8 +19,16 @@ async fn main() { } Commands::Migrate { migration_dir, + database_schema, + database_url, command, - } => run_migrate_command(command, migration_dir.as_str(), verbose) - .unwrap_or_else(handle_error), + } => run_migrate_command( + command, + &migration_dir, + database_schema, + database_url, + verbose, + ) + .unwrap_or_else(handle_error), } } diff --git a/sea-orm-cli/src/cli.rs b/sea-orm-cli/src/cli.rs index 68f32ef8..ebbbf6e9 100644 --- a/sea-orm-cli/src/cli.rs +++ b/sea-orm-cli/src/cli.rs @@ -25,11 +25,37 @@ pub enum Commands { global = true, short = 'd', long, - help = "Migration script directory", + help = "Migration script directory. +If your migrations are in their own crate, +you can provide the root of that crate. +If your migrations are in a submodule of your app, +you should provide the directory of that submodule.", default_value = "./migration" )] migration_dir: String, + #[clap( + value_parser, + global = true, + short = 's', + long, + env = "DATABASE_SCHEMA", + long_help = "Database schema\n \ + - For MySQL and SQLite, this argument is ignored.\n \ + - For PostgreSQL, this argument is optional with default value 'public'.\n" + )] + database_schema: Option, + + #[clap( + value_parser, + global = true, + short = 'u', + long, + env = "DATABASE_URL", + help = "Database URL" + )] + database_url: Option, + #[clap(subcommand)] command: Option, }, @@ -48,6 +74,14 @@ pub enum MigrateSubcommands { help = "Name of the new migration" )] migration_name: String, + + #[clap( + action, + short, + long, + help = "Generate migration file based on Utc time instead of Local time" + )] + universal_time: bool, }, #[clap(about = "Drop all tables from the database, then reapply all migrations")] Fresh, @@ -163,7 +197,7 @@ pub enum GenerateSubcommands { value_parser, long, default_value = "none", - help = "Automatically derive serde Serialize / Deserialize traits for the entity (none,\ + help = "Automatically derive serde Serialize / Deserialize traits for the entity (none, \ serialize, deserialize, both)" )] with_serde: String, @@ -173,7 +207,7 @@ pub enum GenerateSubcommands { long, default_value = "false", long_help = "Automatically derive the Copy trait on generated enums.\n\ - Enums generated from a database don't have associated data by default, and as such can\ + Enums generated from a database don't have associated data by default, and as such can \ derive Copy. " )] @@ -187,6 +221,15 @@ pub enum GenerateSubcommands { help = "The datetime crate to use for generating entities." )] date_time_crate: DateTimeCrate, + + #[clap( + action, + long, + short = 'l', + default_value = "false", + help = "Generate index file as `lib.rs` instead of `mod.rs`." + )] + lib: bool, }, } diff --git a/sea-orm-cli/src/commands/generate.rs b/sea-orm-cli/src/commands/generate.rs index 0cea31e4..1686e878 100644 --- a/sea-orm-cli/src/commands/generate.rs +++ b/sea-orm-cli/src/commands/generate.rs @@ -26,6 +26,7 @@ pub async fn run_generate_command( with_serde, with_copy_enums, date_time_crate, + lib, } => { if verbose { let _ = tracing_subscriber::fmt() @@ -171,6 +172,7 @@ pub async fn run_generate_command( with_copy_enums, date_time_crate.into(), schema_name, + lib, ); let output = EntityTransformer::transform(table_stmts)?.generate(&writer_context); diff --git a/sea-orm-cli/src/commands/migrate.rs b/sea-orm-cli/src/commands/migrate.rs index 8855c0cd..1ab5f33a 100644 --- a/sea-orm-cli/src/commands/migrate.rs +++ b/sea-orm-cli/src/commands/migrate.rs @@ -1,19 +1,28 @@ -use chrono::Local; +use chrono::{Local, Utc}; use regex::Regex; -use std::{error::Error, fs, io::Write, path::Path, process::Command}; +use std::{ + error::Error, + fs, + io::Write, + path::{Path, PathBuf}, + process::Command, +}; use crate::MigrateSubcommands; pub fn run_migrate_command( command: Option, migration_dir: &str, + database_schema: Option, + database_url: Option, verbose: bool, ) -> Result<(), Box> { match command { Some(MigrateSubcommands::Init) => run_migrate_init(migration_dir)?, - Some(MigrateSubcommands::Generate { migration_name }) => { - run_migrate_generate(migration_dir, &migration_name)? - } + Some(MigrateSubcommands::Generate { + migration_name, + universal_time, + }) => run_migrate_generate(migration_dir, &migration_name, universal_time)?, _ => { let (subcommand, migration_dir, steps, verbose) = match command { Some(MigrateSubcommands::Fresh) => ("fresh", migration_dir, None, verbose), @@ -34,20 +43,20 @@ pub fn run_migrate_command( format!("{}/Cargo.toml", migration_dir) }; // Construct the arguments that will be supplied to `cargo` command - let mut args = vec![ - "run", - "--manifest-path", - manifest_path.as_str(), - "--", - subcommand, - ]; + let mut args = vec!["run", "--manifest-path", &manifest_path, "--", subcommand]; let mut num: String = "".to_string(); if let Some(steps) = steps { num = steps.to_string(); } if !num.is_empty() { - args.extend(["-n", num.as_str()]) + args.extend(["-n", &num]) + } + if let Some(database_url) = &database_url { + args.extend(["-u", database_url]); + } + if let Some(database_schema) = &database_schema { + args.extend(["-s", database_schema]); } if verbose { args.push("-v"); @@ -104,12 +113,18 @@ pub fn run_migrate_init(migration_dir: &str) -> Result<(), Box> { pub fn run_migrate_generate( migration_dir: &str, migration_name: &str, + universal_time: bool, ) -> Result<(), Box> { println!("Generating new migration..."); // build new migration filename - let now = Local::now(); - let migration_name = format!("m{}_{}", now.format("%Y%m%d_%H%M%S"), migration_name); + const FMT: &str = "%Y%m%d_%H%M%S"; + let formatted_now = if universal_time { + Utc::now().format(FMT) + } else { + Local::now().format(FMT) + }; + let migration_name = format!("m{}_{}", formatted_now, migration_name); create_new_migration(&migration_name, migration_dir)?; update_migrator(&migration_name, migration_dir)?; @@ -117,10 +132,27 @@ pub fn run_migrate_generate( Ok(()) } +/// `get_full_migration_dir` looks for a `src` directory +/// inside of `migration_dir` and appends that to the returned path if found. +/// +/// Otherwise, `migration_dir` can point directly to a directory containing the +/// migrations. In that case, nothing is appended. +/// +/// This way, `src` doesn't need to be appended in the standard case where +/// migrations are in their own crate. If the migrations are in a submodule +/// of another crate, `migration_dir` can point directly to that module. +fn get_full_migration_dir(migration_dir: &str) -> PathBuf { + let without_src = Path::new(migration_dir).to_owned(); + let with_src = without_src.join("src"); + match () { + _ if with_src.is_dir() => with_src, + _ => without_src, + } +} + fn create_new_migration(migration_name: &str, migration_dir: &str) -> Result<(), Box> { - let migration_filepath = Path::new(migration_dir) - .join("src") - .join(format!("{}.rs", &migration_name)); + let migration_filepath = + get_full_migration_dir(migration_dir).join(format!("{}.rs", &migration_name)); println!("Creating migration file `{}`", migration_filepath.display()); // TODO: make OS agnostic let migration_template = @@ -130,8 +162,29 @@ fn create_new_migration(migration_name: &str, migration_dir: &str) -> Result<(), Ok(()) } +/// `get_migrator_filepath` looks for a file `migration_dir/src/lib.rs` +/// and returns that path if found. +/// +/// If `src` is not found, it will look directly in `migration_dir` for `lib.rs`. +/// +/// If `lib.rs` is not found, it will look for `mod.rs` instead, +/// e.g. `migration_dir/mod.rs`. +/// +/// This way, `src` doesn't need to be appended in the standard case where +/// migrations are in their own crate (with a file `lib.rs`). If the +/// migrations are in a submodule of another crate (with a file `mod.rs`), +/// `migration_dir` can point directly to that module. +fn get_migrator_filepath(migration_dir: &str) -> PathBuf { + let full_migration_dir = get_full_migration_dir(migration_dir); + let with_lib = full_migration_dir.join("lib.rs"); + match () { + _ if with_lib.is_file() => with_lib, + _ => full_migration_dir.join("mod.rs"), + } +} + fn update_migrator(migration_name: &str, migration_dir: &str) -> Result<(), Box> { - let migrator_filepath = Path::new(migration_dir).join("src").join("lib.rs"); + let migrator_filepath = get_migrator_filepath(migration_dir); println!( "Adding migration `{}` to `{}`", migration_name, @@ -141,7 +194,7 @@ fn update_migrator(migration_name: &str, migration_dir: &str) -> Result<(), Box< let mut updated_migrator_content = migrator_content.clone(); // create a backup of the migrator file in case something goes wrong - let migrator_backup_filepath = migrator_filepath.with_file_name("lib.rs.bak"); + let migrator_backup_filepath = migrator_filepath.with_extension("rs.bak"); fs::copy(&migrator_filepath, &migrator_backup_filepath)?; let mut migrator_file = fs::File::create(&migrator_filepath)?; diff --git a/sea-orm-codegen/src/entity/active_enum.rs b/sea-orm-codegen/src/entity/active_enum.rs index c0d285f7..8b421982 100644 --- a/sea-orm-codegen/src/entity/active_enum.rs +++ b/sea-orm-codegen/src/entity/active_enum.rs @@ -1,9 +1,10 @@ -use crate::WithSerde; use heck::CamelCase; use proc_macro2::TokenStream; use quote::{format_ident, quote}; use sea_query::DynIden; +use crate::WithSerde; + #[derive(Clone, Debug)] pub struct ActiveEnum { pub(crate) enum_name: DynIden, @@ -31,7 +32,7 @@ impl ActiveEnum { }; quote! { - #[derive(Debug, Clone, PartialEq, EnumIter, DeriveActiveEnum #copy_derive #extra_derive)] + #[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum #copy_derive #extra_derive)] #[sea_orm(rs_type = "String", db_type = "Enum", enum_name = #enum_name)] pub enum #enum_iden { #( diff --git a/sea-orm-codegen/src/entity/base_entity.rs b/sea-orm-codegen/src/entity/base_entity.rs index a745b1f7..0147c6f0 100644 --- a/sea-orm-codegen/src/entity/base_entity.rs +++ b/sea-orm-codegen/src/entity/base_entity.rs @@ -1,7 +1,10 @@ -use crate::{Column, ConjunctRelation, DateTimeCrate, PrimaryKey, Relation}; use heck::{CamelCase, SnakeCase}; use proc_macro2::{Ident, TokenStream}; use quote::format_ident; +use quote::quote; +use sea_query::ColumnType; + +use crate::{Column, ConjunctRelation, DateTimeCrate, PrimaryKey, Relation}; #[derive(Clone, Debug)] pub struct Entity { @@ -145,14 +148,29 @@ impl Entity { .map(|con_rel| con_rel.get_to_camel_case()) .collect() } + + pub fn get_eq_needed(&self) -> TokenStream { + self.columns + .iter() + .find(|column| { + matches!( + column.col_type, + ColumnType::Float(_) | ColumnType::Double(_) + ) + }) + // check if float or double exist. + // if exist, return nothing + .map_or(quote! {, Eq}, |_| quote! {}) + } } #[cfg(test)] mod tests { - use crate::{Column, DateTimeCrate, Entity, PrimaryKey, Relation, RelationType}; - use quote::format_ident; + use quote::{format_ident, quote}; use sea_query::{ColumnType, ForeignKeyAction}; + use crate::{Column, DateTimeCrate, Entity, PrimaryKey, Relation, RelationType}; + fn setup() -> Entity { Entity { table_name: "special_cake".to_owned(), @@ -416,4 +434,12 @@ mod tests { assert_eq!(elem, entity.conjunct_relations[i].get_to_camel_case()); } } + + #[test] + fn test_get_eq_needed() { + let entity = setup(); + let expected = quote! {, Eq}; + + assert_eq!(entity.get_eq_needed().to_string(), expected.to_string()); + } } diff --git a/sea-orm-codegen/src/entity/writer.rs b/sea-orm-codegen/src/entity/writer.rs index 2d7bc87c..1c63af09 100644 --- a/sea-orm-codegen/src/entity/writer.rs +++ b/sea-orm-codegen/src/entity/writer.rs @@ -42,6 +42,7 @@ pub struct EntityWriterContext { pub(crate) with_copy_enums: bool, pub(crate) date_time_crate: DateTimeCrate, pub(crate) schema_name: Option, + pub(crate) lib: bool, } impl WithSerde { @@ -101,6 +102,7 @@ impl EntityWriterContext { with_copy_enums: bool, date_time_crate: DateTimeCrate, schema_name: Option, + lib: bool, ) -> Self { Self { expanded_format, @@ -108,6 +110,7 @@ impl EntityWriterContext { with_copy_enums, date_time_crate, schema_name, + lib, } } } @@ -116,7 +119,7 @@ impl EntityWriter { pub fn generate(self, context: &EntityWriterContext) -> WriterOutput { let mut files = Vec::new(); files.extend(self.write_entities(context)); - files.push(self.write_mod()); + files.push(self.write_index_file(context.lib)); files.push(self.write_prelude()); if !self.enums.is_empty() { files.push( @@ -168,7 +171,7 @@ impl EntityWriter { .collect() } - pub fn write_mod(&self) -> OutputFile { + pub fn write_index_file(&self, lib: bool) -> OutputFile { let mut lines = Vec::new(); Self::write_doc_comment(&mut lines); let code_blocks: Vec = self.entities.iter().map(Self::gen_mod).collect(); @@ -188,8 +191,14 @@ impl EntityWriter { }], ); } + + let file_name = match lib { + true => "lib.rs".to_owned(), + false => "mod.rs".to_owned(), + }; + OutputFile { - name: "mod.rs".to_owned(), + name: file_name, content: lines.join("\n"), } } @@ -282,16 +291,8 @@ impl EntityWriter { let mut code_blocks = vec![ imports, Self::gen_compact_model_struct(entity, with_serde, date_time_crate, schema_name), + Self::gen_compact_relation_enum(entity), ]; - let relation_defs = if entity.get_relation_enum_name().is_empty() { - vec![ - Self::gen_relation_enum(entity), - Self::gen_impl_relation_trait(entity), - ] - } else { - vec![Self::gen_compact_relation_enum(entity)] - }; - code_blocks.extend(relation_defs); code_blocks.extend(Self::gen_impl_related(entity)); code_blocks.extend(Self::gen_impl_conjunct_related(entity)); code_blocks.extend(vec![Self::gen_impl_active_model_behavior()]); @@ -380,11 +381,11 @@ impl EntityWriter { ) -> TokenStream { let column_names_snake_case = entity.get_column_names_snake_case(); let column_rs_types = entity.get_column_rs_types(date_time_crate); - + let if_eq_needed = entity.get_eq_needed(); let extra_derive = with_serde.extra_derive(); quote! { - #[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel #extra_derive)] + #[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel #if_eq_needed #extra_derive)] pub struct Model { #(pub #column_names_snake_case: #column_rs_types,)* } @@ -566,6 +567,7 @@ impl EntityWriter { let table_name = entity.table_name.as_str(); let column_names_snake_case = entity.get_column_names_snake_case(); let column_rs_types = entity.get_column_rs_types(date_time_crate); + let if_eq_needed = entity.get_eq_needed(); let primary_keys: Vec = entity .primary_keys .iter() @@ -620,7 +622,7 @@ impl EntityWriter { let extra_derive = with_serde.extra_derive(); quote! { - #[derive(Clone, Debug, PartialEq, DeriveEntityModel #extra_derive)] + #[derive(Clone, Debug, PartialEq, DeriveEntityModel #if_eq_needed #extra_derive)] #[sea_orm( #schema_name table_name = #table_name @@ -1032,6 +1034,92 @@ mod tests { name: "id".to_owned(), }], }, + Entity { + table_name: "cake_with_float".to_owned(), + columns: vec![ + Column { + name: "id".to_owned(), + col_type: ColumnType::Integer(Some(11)), + auto_increment: true, + not_null: true, + unique: false, + }, + Column { + name: "name".to_owned(), + col_type: ColumnType::Text, + auto_increment: false, + not_null: false, + unique: false, + }, + Column { + name: "price".to_owned(), + col_type: ColumnType::Float(Some(2)), + auto_increment: false, + not_null: false, + unique: false, + }, + ], + relations: vec![Relation { + ref_table: "fruit".to_owned(), + columns: vec![], + ref_columns: vec![], + rel_type: RelationType::HasMany, + on_delete: None, + on_update: None, + self_referencing: false, + num_suffix: 0, + }], + conjunct_relations: vec![ConjunctRelation { + via: "cake_filling".to_owned(), + to: "filling".to_owned(), + }], + primary_keys: vec![PrimaryKey { + name: "id".to_owned(), + }], + }, + Entity { + table_name: "cake_with_double".to_owned(), + columns: vec![ + Column { + name: "id".to_owned(), + col_type: ColumnType::Integer(Some(11)), + auto_increment: true, + not_null: true, + unique: false, + }, + Column { + name: "name".to_owned(), + col_type: ColumnType::Text, + auto_increment: false, + not_null: false, + unique: false, + }, + Column { + name: "price".to_owned(), + col_type: ColumnType::Double(Some(2)), + auto_increment: false, + not_null: false, + unique: false, + }, + ], + relations: vec![Relation { + ref_table: "fruit".to_owned(), + columns: vec![], + ref_columns: vec![], + rel_type: RelationType::HasMany, + on_delete: None, + on_update: None, + self_referencing: false, + num_suffix: 0, + }], + conjunct_relations: vec![ConjunctRelation { + via: "cake_filling".to_owned(), + to: "filling".to_owned(), + }], + primary_keys: vec![PrimaryKey { + name: "id".to_owned(), + }], + }, ] } @@ -1056,21 +1144,25 @@ mod tests { #[test] fn test_gen_expanded_code_blocks() -> io::Result<()> { let entities = setup(); - const ENTITY_FILES: [&str; 6] = [ + const ENTITY_FILES: [&str; 8] = [ include_str!("../../tests/expanded/cake.rs"), include_str!("../../tests/expanded/cake_filling.rs"), include_str!("../../tests/expanded/filling.rs"), include_str!("../../tests/expanded/fruit.rs"), include_str!("../../tests/expanded/vendor.rs"), include_str!("../../tests/expanded/rust_keyword.rs"), + include_str!("../../tests/expanded/cake_with_float.rs"), + include_str!("../../tests/expanded/cake_with_double.rs"), ]; - const ENTITY_FILES_WITH_SCHEMA_NAME: [&str; 6] = [ + const ENTITY_FILES_WITH_SCHEMA_NAME: [&str; 8] = [ include_str!("../../tests/expanded_with_schema_name/cake.rs"), include_str!("../../tests/expanded_with_schema_name/cake_filling.rs"), include_str!("../../tests/expanded_with_schema_name/filling.rs"), include_str!("../../tests/expanded_with_schema_name/fruit.rs"), include_str!("../../tests/expanded_with_schema_name/vendor.rs"), include_str!("../../tests/expanded_with_schema_name/rust_keyword.rs"), + include_str!("../../tests/expanded_with_schema_name/cake_with_float.rs"), + include_str!("../../tests/expanded_with_schema_name/cake_with_double.rs"), ]; assert_eq!(entities.len(), ENTITY_FILES.len()); @@ -1132,21 +1224,25 @@ mod tests { #[test] fn test_gen_compact_code_blocks() -> io::Result<()> { let entities = setup(); - const ENTITY_FILES: [&str; 6] = [ + const ENTITY_FILES: [&str; 8] = [ include_str!("../../tests/compact/cake.rs"), include_str!("../../tests/compact/cake_filling.rs"), include_str!("../../tests/compact/filling.rs"), include_str!("../../tests/compact/fruit.rs"), include_str!("../../tests/compact/vendor.rs"), include_str!("../../tests/compact/rust_keyword.rs"), + include_str!("../../tests/compact/cake_with_float.rs"), + include_str!("../../tests/compact/cake_with_double.rs"), ]; - const ENTITY_FILES_WITH_SCHEMA_NAME: [&str; 6] = [ + const ENTITY_FILES_WITH_SCHEMA_NAME: [&str; 8] = [ include_str!("../../tests/compact_with_schema_name/cake.rs"), include_str!("../../tests/compact_with_schema_name/cake_filling.rs"), include_str!("../../tests/compact_with_schema_name/filling.rs"), include_str!("../../tests/compact_with_schema_name/fruit.rs"), include_str!("../../tests/compact_with_schema_name/vendor.rs"), include_str!("../../tests/compact_with_schema_name/rust_keyword.rs"), + include_str!("../../tests/compact_with_schema_name/cake_with_float.rs"), + include_str!("../../tests/compact_with_schema_name/cake_with_double.rs"), ]; assert_eq!(entities.len(), ENTITY_FILES.len()); diff --git a/sea-orm-codegen/tests/compact/cake.rs b/sea-orm-codegen/tests/compact/cake.rs index 2e26257c..7451140d 100644 --- a/sea-orm-codegen/tests/compact/cake.rs +++ b/sea-orm-codegen/tests/compact/cake.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(table_name = "cake")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/compact/cake_filling.rs b/sea-orm-codegen/tests/compact/cake_filling.rs index ba9ed2ca..de27153d 100644 --- a/sea-orm-codegen/tests/compact/cake_filling.rs +++ b/sea-orm-codegen/tests/compact/cake_filling.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(table_name = "_cake_filling_")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] diff --git a/sea-orm-codegen/tests/compact/cake_with_double.rs b/sea-orm-codegen/tests/compact/cake_with_double.rs new file mode 100644 index 00000000..edf4a991 --- /dev/null +++ b/sea-orm-codegen/tests/compact/cake_with_double.rs @@ -0,0 +1,37 @@ +//! SeaORM Entity. Generated by sea-orm-codegen 0.1.0 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "cake_with_double")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(column_type = "Text", nullable)] + pub name: Option , + #[sea_orm(column_type = "Double(Some(2))", nullable)] + pub price: Option , +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::fruit::Entity")] + Fruit, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Fruit.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::cake_filling::Relation::Filling.def() + } + fn via() -> Option { + Some(super::cake_filling::Relation::CakeWithDouble.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-codegen/tests/compact/cake_with_float.rs b/sea-orm-codegen/tests/compact/cake_with_float.rs new file mode 100644 index 00000000..a5f9f701 --- /dev/null +++ b/sea-orm-codegen/tests/compact/cake_with_float.rs @@ -0,0 +1,37 @@ +//! SeaORM Entity. Generated by sea-orm-codegen 0.1.0 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "cake_with_float")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(column_type = "Text", nullable)] + pub name: Option , + #[sea_orm(column_type = "Float(Some(2))", nullable)] + pub price: Option , +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::fruit::Entity")] + Fruit, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Fruit.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::cake_filling::Relation::Filling.def() + } + fn via() -> Option { + Some(super::cake_filling::Relation::CakeWithFloat.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-codegen/tests/compact/filling.rs b/sea-orm-codegen/tests/compact/filling.rs index dfedb1a7..58f5f05c 100644 --- a/sea-orm-codegen/tests/compact/filling.rs +++ b/sea-orm-codegen/tests/compact/filling.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(table_name = "filling")] pub struct Model { #[sea_orm(primary_key)] @@ -10,15 +10,9 @@ pub struct Model { pub name: String, } -#[derive(Copy, Clone, Debug, EnumIter)] +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} -impl RelationTrait for Relation { - fn def(&self) -> RelationDef { - panic!("No RelationDef") - } -} - impl Related for Entity { fn to() -> RelationDef { super::cake_filling::Relation::Cake.def() diff --git a/sea-orm-codegen/tests/compact/fruit.rs b/sea-orm-codegen/tests/compact/fruit.rs index 6399a51f..6ead03d1 100644 --- a/sea-orm-codegen/tests/compact/fruit.rs +++ b/sea-orm-codegen/tests/compact/fruit.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(table_name = "fruit")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/compact/rust_keyword.rs b/sea-orm-codegen/tests/compact/rust_keyword.rs index c5d46f50..1daeba8e 100644 --- a/sea-orm-codegen/tests/compact/rust_keyword.rs +++ b/sea-orm-codegen/tests/compact/rust_keyword.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(table_name = "rust_keyword")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/compact/vendor.rs b/sea-orm-codegen/tests/compact/vendor.rs index 1351c227..f14c2808 100644 --- a/sea-orm-codegen/tests/compact/vendor.rs +++ b/sea-orm-codegen/tests/compact/vendor.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(table_name = "vendor")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/compact_with_schema_name/cake.rs b/sea-orm-codegen/tests/compact_with_schema_name/cake.rs index d2efb986..b8418d64 100644 --- a/sea-orm-codegen/tests/compact_with_schema_name/cake.rs +++ b/sea-orm-codegen/tests/compact_with_schema_name/cake.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(schema_name = "schema_name", table_name = "cake")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/compact_with_schema_name/cake_filling.rs b/sea-orm-codegen/tests/compact_with_schema_name/cake_filling.rs index a9704a8a..7ca8eb91 100644 --- a/sea-orm-codegen/tests/compact_with_schema_name/cake_filling.rs +++ b/sea-orm-codegen/tests/compact_with_schema_name/cake_filling.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(schema_name = "schema_name", table_name = "_cake_filling_")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] diff --git a/sea-orm-codegen/tests/compact_with_schema_name/cake_with_double.rs b/sea-orm-codegen/tests/compact_with_schema_name/cake_with_double.rs new file mode 100644 index 00000000..4afb7d6a --- /dev/null +++ b/sea-orm-codegen/tests/compact_with_schema_name/cake_with_double.rs @@ -0,0 +1,37 @@ +//! SeaORM Entity. Generated by sea-orm-codegen 0.1.0 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(schema_name = "schema_name", table_name = "cake_with_double")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(column_type = "Text", nullable)] + pub name: Option , + #[sea_orm(column_type = "Double(Some(2))", nullable)] + pub price: Option , +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::fruit::Entity")] + Fruit, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Fruit.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::cake_filling::Relation::Filling.def() + } + fn via() -> Option { + Some(super::cake_filling::Relation::CakeWithDouble.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-codegen/tests/compact_with_schema_name/cake_with_float.rs b/sea-orm-codegen/tests/compact_with_schema_name/cake_with_float.rs new file mode 100644 index 00000000..cf84a0a3 --- /dev/null +++ b/sea-orm-codegen/tests/compact_with_schema_name/cake_with_float.rs @@ -0,0 +1,37 @@ +//! SeaORM Entity. Generated by sea-orm-codegen 0.1.0 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(schema_name = "schema_name", table_name = "cake_with_float")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(column_type = "Text", nullable)] + pub name: Option , + #[sea_orm(column_type = "Float(Some(2))", nullable)] + pub price: Option , +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::fruit::Entity")] + Fruit, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Fruit.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::cake_filling::Relation::Filling.def() + } + fn via() -> Option { + Some(super::cake_filling::Relation::CakeWithFloat.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-codegen/tests/compact_with_schema_name/filling.rs b/sea-orm-codegen/tests/compact_with_schema_name/filling.rs index 94795811..f68ca06c 100644 --- a/sea-orm-codegen/tests/compact_with_schema_name/filling.rs +++ b/sea-orm-codegen/tests/compact_with_schema_name/filling.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(schema_name = "schema_name", table_name = "filling")] pub struct Model { #[sea_orm(primary_key)] @@ -10,15 +10,9 @@ pub struct Model { pub name: String, } -#[derive(Copy, Clone, Debug, EnumIter)] +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} -impl RelationTrait for Relation { - fn def(&self) -> RelationDef { - panic!("No RelationDef") - } -} - impl Related for Entity { fn to() -> RelationDef { super::cake_filling::Relation::Cake.def() diff --git a/sea-orm-codegen/tests/compact_with_schema_name/fruit.rs b/sea-orm-codegen/tests/compact_with_schema_name/fruit.rs index 28104324..dc446b1d 100644 --- a/sea-orm-codegen/tests/compact_with_schema_name/fruit.rs +++ b/sea-orm-codegen/tests/compact_with_schema_name/fruit.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(schema_name = "schema_name", table_name = "fruit")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/compact_with_schema_name/rust_keyword.rs b/sea-orm-codegen/tests/compact_with_schema_name/rust_keyword.rs index f3ca0314..014836ea 100644 --- a/sea-orm-codegen/tests/compact_with_schema_name/rust_keyword.rs +++ b/sea-orm-codegen/tests/compact_with_schema_name/rust_keyword.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(schema_name = "schema_name", table_name = "rust_keyword")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/compact_with_schema_name/vendor.rs b/sea-orm-codegen/tests/compact_with_schema_name/vendor.rs index 85209c45..c3909880 100644 --- a/sea-orm-codegen/tests/compact_with_schema_name/vendor.rs +++ b/sea-orm-codegen/tests/compact_with_schema_name/vendor.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(schema_name = "schema_name", table_name = "vendor")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/compact_with_serde/cake_both.rs b/sea-orm-codegen/tests/compact_with_serde/cake_both.rs index 3a1bea9a..54d3cd16 100644 --- a/sea-orm-codegen/tests/compact_with_serde/cake_both.rs +++ b/sea-orm-codegen/tests/compact_with_serde/cake_both.rs @@ -3,7 +3,7 @@ use sea_orm::entity::prelude:: * ; use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] #[sea_orm(table_name = "cake")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/compact_with_serde/cake_deserialize.rs b/sea-orm-codegen/tests/compact_with_serde/cake_deserialize.rs index b36718f9..f11569e4 100644 --- a/sea-orm-codegen/tests/compact_with_serde/cake_deserialize.rs +++ b/sea-orm-codegen/tests/compact_with_serde/cake_deserialize.rs @@ -3,7 +3,7 @@ use sea_orm::entity::prelude:: * ; use serde::Deserialize; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Deserialize)] #[sea_orm(table_name = "cake")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/compact_with_serde/cake_none.rs b/sea-orm-codegen/tests/compact_with_serde/cake_none.rs index 809b9051..d72ea6b2 100644 --- a/sea-orm-codegen/tests/compact_with_serde/cake_none.rs +++ b/sea-orm-codegen/tests/compact_with_serde/cake_none.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude:: * ; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(table_name = "cake")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/compact_with_serde/cake_serialize.rs b/sea-orm-codegen/tests/compact_with_serde/cake_serialize.rs index 81cc3907..77af4c5a 100644 --- a/sea-orm-codegen/tests/compact_with_serde/cake_serialize.rs +++ b/sea-orm-codegen/tests/compact_with_serde/cake_serialize.rs @@ -3,7 +3,7 @@ use sea_orm::entity::prelude:: * ; use serde::Serialize; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize)] #[sea_orm(table_name = "cake")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/expanded/cake.rs b/sea-orm-codegen/tests/expanded/cake.rs index 0b33b618..961b1919 100644 --- a/sea-orm-codegen/tests/expanded/cake.rs +++ b/sea-orm-codegen/tests/expanded/cake.rs @@ -11,7 +11,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub id: i32, pub name: Option , diff --git a/sea-orm-codegen/tests/expanded/cake_filling.rs b/sea-orm-codegen/tests/expanded/cake_filling.rs index d100fa8c..92e157f2 100644 --- a/sea-orm-codegen/tests/expanded/cake_filling.rs +++ b/sea-orm-codegen/tests/expanded/cake_filling.rs @@ -11,7 +11,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub cake_id: i32, pub filling_id: i32, diff --git a/sea-orm-codegen/tests/expanded/cake_with_double.rs b/sea-orm-codegen/tests/expanded/cake_with_double.rs new file mode 100644 index 00000000..d71a9fbd --- /dev/null +++ b/sea-orm-codegen/tests/expanded/cake_with_double.rs @@ -0,0 +1,80 @@ +//! SeaORM Entity. Generated by sea-orm-codegen 0.1.0 + +use sea_orm::entity::prelude::*; + +#[derive(Copy, Clone, Default, Debug, DeriveEntity)] +pub struct Entity; + +impl EntityName for Entity { + fn table_name(&self) -> &str { + "cake_with_double" + } +} + +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +pub struct Model { + pub id: i32, + pub name: Option , + pub price: Option , +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] +pub enum Column { + Id, + Name, + Price, +} + +#[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)] +pub enum PrimaryKey { + Id, +} + +impl PrimaryKeyTrait for PrimaryKey { + type ValueType = i32; + + fn auto_increment() -> bool { + true + } +} + +#[derive(Copy, Clone, Debug, EnumIter)] +pub enum Relation { + Fruit, +} + +impl ColumnTrait for Column { + type EntityName = Entity; + fn def(&self) -> ColumnDef { + match self { + Self::Id => ColumnType::Integer.def(), + Self::Name => ColumnType::Text.def().null(), + Self::Price => ColumnType::Double.def().null(), + } + } +} + +impl RelationTrait for Relation { + fn def(&self) -> RelationDef { + match self { + Self::Fruit => Entity::has_many(super::fruit::Entity).into(), + } + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Fruit.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::cake_filling::Relation::Filling.def() + } + fn via() -> Option { + Some(super::cake_filling::Relation::CakeWithDouble.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-codegen/tests/expanded/cake_with_float.rs b/sea-orm-codegen/tests/expanded/cake_with_float.rs new file mode 100644 index 00000000..d5059be1 --- /dev/null +++ b/sea-orm-codegen/tests/expanded/cake_with_float.rs @@ -0,0 +1,80 @@ +//! SeaORM Entity. Generated by sea-orm-codegen 0.1.0 + +use sea_orm::entity::prelude::*; + +#[derive(Copy, Clone, Default, Debug, DeriveEntity)] +pub struct Entity; + +impl EntityName for Entity { + fn table_name(&self) -> &str { + "cake_with_float" + } +} + +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +pub struct Model { + pub id: i32, + pub name: Option , + pub price: Option , +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] +pub enum Column { + Id, + Name, + Price, +} + +#[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)] +pub enum PrimaryKey { + Id, +} + +impl PrimaryKeyTrait for PrimaryKey { + type ValueType = i32; + + fn auto_increment() -> bool { + true + } +} + +#[derive(Copy, Clone, Debug, EnumIter)] +pub enum Relation { + Fruit, +} + +impl ColumnTrait for Column { + type EntityName = Entity; + fn def(&self) -> ColumnDef { + match self { + Self::Id => ColumnType::Integer.def(), + Self::Name => ColumnType::Text.def().null(), + Self::Price => ColumnType::Float.def().null(), + } + } +} + +impl RelationTrait for Relation { + fn def(&self) -> RelationDef { + match self { + Self::Fruit => Entity::has_many(super::fruit::Entity).into(), + } + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Fruit.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::cake_filling::Relation::Filling.def() + } + fn via() -> Option { + Some(super::cake_filling::Relation::CakeWithFloat.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-codegen/tests/expanded/filling.rs b/sea-orm-codegen/tests/expanded/filling.rs index 76764ecf..22035244 100644 --- a/sea-orm-codegen/tests/expanded/filling.rs +++ b/sea-orm-codegen/tests/expanded/filling.rs @@ -11,7 +11,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub id: i32, pub name: String, diff --git a/sea-orm-codegen/tests/expanded/fruit.rs b/sea-orm-codegen/tests/expanded/fruit.rs index 12919021..df1618ca 100644 --- a/sea-orm-codegen/tests/expanded/fruit.rs +++ b/sea-orm-codegen/tests/expanded/fruit.rs @@ -11,7 +11,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub id: i32, pub name: String, diff --git a/sea-orm-codegen/tests/expanded/rust_keyword.rs b/sea-orm-codegen/tests/expanded/rust_keyword.rs index 1e96f791..8cdec4d8 100644 --- a/sea-orm-codegen/tests/expanded/rust_keyword.rs +++ b/sea-orm-codegen/tests/expanded/rust_keyword.rs @@ -11,7 +11,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub id: i32, pub testing: i8, diff --git a/sea-orm-codegen/tests/expanded/vendor.rs b/sea-orm-codegen/tests/expanded/vendor.rs index ab4ae39a..91cbbd47 100644 --- a/sea-orm-codegen/tests/expanded/vendor.rs +++ b/sea-orm-codegen/tests/expanded/vendor.rs @@ -11,7 +11,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub id: i32, pub name: String, diff --git a/sea-orm-codegen/tests/expanded_with_schema_name/cake.rs b/sea-orm-codegen/tests/expanded_with_schema_name/cake.rs index d37c2b08..72bdb0b1 100644 --- a/sea-orm-codegen/tests/expanded_with_schema_name/cake.rs +++ b/sea-orm-codegen/tests/expanded_with_schema_name/cake.rs @@ -15,7 +15,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub id: i32, pub name: Option , diff --git a/sea-orm-codegen/tests/expanded_with_schema_name/cake_filling.rs b/sea-orm-codegen/tests/expanded_with_schema_name/cake_filling.rs index cde112e7..0113751f 100644 --- a/sea-orm-codegen/tests/expanded_with_schema_name/cake_filling.rs +++ b/sea-orm-codegen/tests/expanded_with_schema_name/cake_filling.rs @@ -15,7 +15,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub cake_id: i32, pub filling_id: i32, diff --git a/sea-orm-codegen/tests/expanded_with_schema_name/cake_with_double.rs b/sea-orm-codegen/tests/expanded_with_schema_name/cake_with_double.rs new file mode 100644 index 00000000..0956e3e5 --- /dev/null +++ b/sea-orm-codegen/tests/expanded_with_schema_name/cake_with_double.rs @@ -0,0 +1,84 @@ +//! SeaORM Entity. Generated by sea-orm-codegen 0.1.0 + +use sea_orm::entity::prelude::*; + +#[derive(Copy, Clone, Default, Debug, DeriveEntity)] +pub struct Entity; + +impl EntityName for Entity { + fn schema_name(&self) -> Option< &str > { + Some("schema_name") + } + + fn table_name(&self) -> &str { + "cake_with_double" + } +} + +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +pub struct Model { + pub id: i32, + pub name: Option , + pub price: Option , +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] +pub enum Column { + Id, + Name, + Price, +} + +#[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)] +pub enum PrimaryKey { + Id, +} + +impl PrimaryKeyTrait for PrimaryKey { + type ValueType = i32; + + fn auto_increment() -> bool { + true + } +} + +#[derive(Copy, Clone, Debug, EnumIter)] +pub enum Relation { + Fruit, +} + +impl ColumnTrait for Column { + type EntityName = Entity; + fn def(&self) -> ColumnDef { + match self { + Self::Id => ColumnType::Integer.def(), + Self::Name => ColumnType::Text.def().null(), + Self::Price => ColumnType::Double.def().null(), + } + } +} + +impl RelationTrait for Relation { + fn def(&self) -> RelationDef { + match self { + Self::Fruit => Entity::has_many(super::fruit::Entity).into(), + } + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Fruit.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::cake_filling::Relation::Filling.def() + } + fn via() -> Option { + Some(super::cake_filling::Relation::CakeWithDouble.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-codegen/tests/expanded_with_schema_name/cake_with_float.rs b/sea-orm-codegen/tests/expanded_with_schema_name/cake_with_float.rs new file mode 100644 index 00000000..b3256ca2 --- /dev/null +++ b/sea-orm-codegen/tests/expanded_with_schema_name/cake_with_float.rs @@ -0,0 +1,84 @@ +//! SeaORM Entity. Generated by sea-orm-codegen 0.1.0 + +use sea_orm::entity::prelude::*; + +#[derive(Copy, Clone, Default, Debug, DeriveEntity)] +pub struct Entity; + +impl EntityName for Entity { + fn schema_name(&self) -> Option< &str > { + Some("schema_name") + } + + fn table_name(&self) -> &str { + "cake_with_float" + } +} + +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +pub struct Model { + pub id: i32, + pub name: Option , + pub price: Option , +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] +pub enum Column { + Id, + Name, + Price, +} + +#[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)] +pub enum PrimaryKey { + Id, +} + +impl PrimaryKeyTrait for PrimaryKey { + type ValueType = i32; + + fn auto_increment() -> bool { + true + } +} + +#[derive(Copy, Clone, Debug, EnumIter)] +pub enum Relation { + Fruit, +} + +impl ColumnTrait for Column { + type EntityName = Entity; + fn def(&self) -> ColumnDef { + match self { + Self::Id => ColumnType::Integer.def(), + Self::Name => ColumnType::Text.def().null(), + Self::Price => ColumnType::Float.def().null(), + } + } +} + +impl RelationTrait for Relation { + fn def(&self) -> RelationDef { + match self { + Self::Fruit => Entity::has_many(super::fruit::Entity).into(), + } + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Fruit.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::cake_filling::Relation::Filling.def() + } + fn via() -> Option { + Some(super::cake_filling::Relation::CakeWithFloat.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-codegen/tests/expanded_with_schema_name/filling.rs b/sea-orm-codegen/tests/expanded_with_schema_name/filling.rs index eb9005fb..918fd1bf 100644 --- a/sea-orm-codegen/tests/expanded_with_schema_name/filling.rs +++ b/sea-orm-codegen/tests/expanded_with_schema_name/filling.rs @@ -15,7 +15,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub id: i32, pub name: String, diff --git a/sea-orm-codegen/tests/expanded_with_schema_name/fruit.rs b/sea-orm-codegen/tests/expanded_with_schema_name/fruit.rs index 2b554f6e..e2268ba6 100644 --- a/sea-orm-codegen/tests/expanded_with_schema_name/fruit.rs +++ b/sea-orm-codegen/tests/expanded_with_schema_name/fruit.rs @@ -15,7 +15,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub id: i32, pub name: String, diff --git a/sea-orm-codegen/tests/expanded_with_schema_name/rust_keyword.rs b/sea-orm-codegen/tests/expanded_with_schema_name/rust_keyword.rs index faa8310f..b402bdac 100644 --- a/sea-orm-codegen/tests/expanded_with_schema_name/rust_keyword.rs +++ b/sea-orm-codegen/tests/expanded_with_schema_name/rust_keyword.rs @@ -15,7 +15,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub id: i32, pub testing: i8, diff --git a/sea-orm-codegen/tests/expanded_with_schema_name/vendor.rs b/sea-orm-codegen/tests/expanded_with_schema_name/vendor.rs index fd3be27e..1ad920ab 100644 --- a/sea-orm-codegen/tests/expanded_with_schema_name/vendor.rs +++ b/sea-orm-codegen/tests/expanded_with_schema_name/vendor.rs @@ -15,7 +15,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub id: i32, pub name: String, diff --git a/sea-orm-codegen/tests/expanded_with_serde/cake_both.rs b/sea-orm-codegen/tests/expanded_with_serde/cake_both.rs index 88821135..924887b4 100644 --- a/sea-orm-codegen/tests/expanded_with_serde/cake_both.rs +++ b/sea-orm-codegen/tests/expanded_with_serde/cake_both.rs @@ -12,7 +12,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq, Serialize, Deserialize)] pub struct Model { pub id: i32, pub name: Option , diff --git a/sea-orm-codegen/tests/expanded_with_serde/cake_deserialize.rs b/sea-orm-codegen/tests/expanded_with_serde/cake_deserialize.rs index 068f17da..88a7c3a9 100644 --- a/sea-orm-codegen/tests/expanded_with_serde/cake_deserialize.rs +++ b/sea-orm-codegen/tests/expanded_with_serde/cake_deserialize.rs @@ -12,7 +12,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Deserialize)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq, Deserialize)] pub struct Model { pub id: i32, pub name: Option , diff --git a/sea-orm-codegen/tests/expanded_with_serde/cake_none.rs b/sea-orm-codegen/tests/expanded_with_serde/cake_none.rs index d0a1299a..a540fad1 100644 --- a/sea-orm-codegen/tests/expanded_with_serde/cake_none.rs +++ b/sea-orm-codegen/tests/expanded_with_serde/cake_none.rs @@ -11,7 +11,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub id: i32, pub name: Option , diff --git a/sea-orm-codegen/tests/expanded_with_serde/cake_serialize.rs b/sea-orm-codegen/tests/expanded_with_serde/cake_serialize.rs index 30313fa1..72a17d58 100644 --- a/sea-orm-codegen/tests/expanded_with_serde/cake_serialize.rs +++ b/sea-orm-codegen/tests/expanded_with_serde/cake_serialize.rs @@ -12,7 +12,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Serialize)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq, Serialize)] pub struct Model { pub id: i32, pub name: Option , diff --git a/sea-orm-macros/src/derives/column.rs b/sea-orm-macros/src/derives/column.rs index 42e1dfe1..3a0f9314 100644 --- a/sea-orm-macros/src/derives/column.rs +++ b/sea-orm-macros/src/derives/column.rs @@ -71,7 +71,7 @@ pub fn impl_default_as_str(ident: &Ident, data: &Data) -> syn::Result syn::Result { let data_enum = match data { Data::Enum(data_enum) => data_enum, @@ -99,7 +99,7 @@ pub fn impl_col_from_str(ident: &Ident, data: &Data) -> syn::Result fn from_str(s: &str) -> std::result::Result { match s { #(#columns),*, - _ => Err(sea_orm::ColumnFromStrErr(format!("Failed to parse '{}' as `{}`", s, stringify!(#ident)))), + _ => Err(sea_orm::ColumnFromStrErr(s.to_owned())), } } } diff --git a/sea-orm-macros/src/derives/entity_model.rs b/sea-orm-macros/src/derives/entity_model.rs index 61a45a43..193e6e99 100644 --- a/sea-orm-macros/src/derives/entity_model.rs +++ b/sea-orm-macros/src/derives/entity_model.rs @@ -306,16 +306,15 @@ pub fn expand_derive_entity_model(data: Data, attrs: Vec) -> syn::Res } } - let primary_key = (!primary_keys.is_empty()) - .then(|| { - let auto_increment = auto_increment && primary_keys.len() == 1; - let primary_key_types = if primary_key_types.len() == 1 { - let first = primary_key_types.first(); - quote! { #first } - } else { - quote! { (#primary_key_types) } - }; - quote! { + let primary_key = { + let auto_increment = auto_increment && primary_keys.len() == 1; + let primary_key_types = if primary_key_types.len() == 1 { + let first = primary_key_types.first(); + quote! { #first } + } else { + quote! { (#primary_key_types) } + }; + quote! { #[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)] pub enum PrimaryKey { #primary_keys @@ -329,9 +328,8 @@ pub fn expand_derive_entity_model(data: Data, attrs: Vec) -> syn::Res #auto_increment } } - } - }) - .unwrap_or_default(); + } + }; Ok(quote! { #[derive(Copy, Clone, Debug, sea_orm::prelude::EnumIter, sea_orm::prelude::DeriveColumn)] diff --git a/sea-orm-macros/src/derives/primary_key.rs b/sea-orm-macros/src/derives/primary_key.rs index 3a05617b..d5c86c96 100644 --- a/sea-orm-macros/src/derives/primary_key.rs +++ b/sea-orm-macros/src/derives/primary_key.rs @@ -14,6 +14,12 @@ pub fn expand_derive_primary_key(ident: Ident, data: Data) -> syn::Result compile_error!("Entity must have a primary key column. See for details."); + }); + } + let variant: Vec = variants .iter() .map(|Variant { ident, fields, .. }| match fields { diff --git a/sea-orm-macros/src/derives/try_getable_from_json.rs b/sea-orm-macros/src/derives/try_getable_from_json.rs index 43cdf637..8742d3c9 100644 --- a/sea-orm-macros/src/derives/try_getable_from_json.rs +++ b/sea-orm-macros/src/derives/try_getable_from_json.rs @@ -14,7 +14,7 @@ pub fn expand_derive_from_json_query_result(ident: Ident) -> syn::Result Result { match v { sea_orm::Value::Json(Some(json)) => Ok( diff --git a/sea-orm-macros/src/lib.rs b/sea-orm-macros/src/lib.rs index d3026d23..ef77c1e4 100644 --- a/sea-orm-macros/src/lib.rs +++ b/sea-orm-macros/src/lib.rs @@ -108,6 +108,27 @@ pub fn derive_entity(input: TokenStream) -> TokenStream { /// # /// # impl ActiveModelBehavior for ActiveModel {} /// ``` +/// +/// Entity should always have a primary key. +/// Or, it will result in a compile error. +/// See for details. +/// +/// ```compile_fail +/// use sea_orm::entity::prelude::*; +/// +/// #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +/// #[sea_orm(table_name = "posts")] +/// pub struct Model { +/// pub title: String, +/// #[sea_orm(column_type = "Text")] +/// pub text: String, +/// } +/// +/// # #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +/// # pub enum Relation {} +/// # +/// # impl ActiveModelBehavior for ActiveModel {} +/// ``` #[proc_macro_derive(DeriveEntityModel, attributes(sea_orm))] pub fn derive_entity_model(input: TokenStream) -> TokenStream { let input_ts = input.clone(); diff --git a/sea-orm-migration/Cargo.toml b/sea-orm-migration/Cargo.toml index 2e358502..4abed040 100644 --- a/sea-orm-migration/Cargo.toml +++ b/sea-orm-migration/Cargo.toml @@ -22,7 +22,7 @@ path = "src/lib.rs" [dependencies] async-trait = { version = "^0.1" } clap = { version = "^3.2", features = ["env", "derive"] } -dotenv = { version = "^0.15" } +dotenvy = { version = "^0.15" } sea-orm = { version = "^0.10.0", path = "../", default-features = false, features = ["macros"] } sea-orm-cli = { version = "^0.10.0", path = "../sea-orm-cli", default-features = false } sea-schema = { version = "^0.9.3" } diff --git a/sea-orm-migration/src/cli.rs b/sea-orm-migration/src/cli.rs index 09183f23..93c7ef38 100644 --- a/sea-orm-migration/src/cli.rs +++ b/sea-orm-migration/src/cli.rs @@ -1,9 +1,9 @@ use clap::Parser; -use dotenv::dotenv; +use dotenvy::dotenv; use std::{error::Error, fmt::Display, process::exit}; use tracing_subscriber::{prelude::*, EnvFilter}; -use sea_orm::{Database, DbConn}; +use sea_orm::{ConnectOptions, Database, DbConn}; use sea_orm_cli::{run_migrate_generate, run_migrate_init, MigrateSubcommands}; use super::MigratorTrait; @@ -15,10 +15,20 @@ where M: MigratorTrait, { dotenv().ok(); - let url = std::env::var("DATABASE_URL").expect("Environment variable 'DATABASE_URL' not set"); - let db = &Database::connect(&url).await.unwrap(); let cli = Cli::parse(); + let url = cli + .database_url + .expect("Environment variable 'DATABASE_URL' not set"); + let schema = cli.database_schema.unwrap_or_else(|| "public".to_owned()); + + let connect_options = ConnectOptions::new(url) + .set_schema_search_path(schema) + .to_owned(); + let db = &Database::connect(connect_options) + .await + .expect("Fail to acquire database connection"); + run_migrate(migrator, db, cli.command, cli.verbose) .await .unwrap_or_else(handle_error); @@ -65,9 +75,10 @@ where Some(MigrateSubcommands::Up { num }) => M::up(db, Some(num)).await?, Some(MigrateSubcommands::Down { num }) => M::down(db, Some(num)).await?, Some(MigrateSubcommands::Init) => run_migrate_init(MIGRATION_DIR)?, - Some(MigrateSubcommands::Generate { migration_name }) => { - run_migrate_generate(MIGRATION_DIR, &migration_name)? - } + Some(MigrateSubcommands::Generate { + migration_name, + universal_time, + }) => run_migrate_generate(MIGRATION_DIR, &migration_name, universal_time)?, _ => M::up(db, None).await?, }; @@ -80,6 +91,28 @@ pub struct Cli { #[clap(action, short = 'v', long, global = true, help = "Show debug messages")] verbose: bool, + #[clap( + value_parser, + global = true, + short = 's', + long, + env = "DATABASE_SCHEMA", + long_help = "Database schema\n \ + - For MySQL and SQLite, this argument is ignored.\n \ + - For PostgreSQL, this argument is optional with default value 'public'.\n" + )] + database_schema: Option, + + #[clap( + value_parser, + global = true, + short = 'u', + long, + env = "DATABASE_URL", + help = "Database URL" + )] + database_url: Option, + #[clap(subcommand)] command: Option, } diff --git a/sea-orm-migration/src/migrator.rs b/sea-orm-migration/src/migrator.rs index 5239d759..89a22475 100644 --- a/sea-orm-migration/src/migrator.rs +++ b/sea-orm-migration/src/migrator.rs @@ -3,7 +3,10 @@ use std::fmt::Display; use std::time::SystemTime; use tracing::info; -use sea_orm::sea_query::{Alias, Expr, ForeignKey, Query, SelectStatement, SimpleExpr, Table}; +use sea_orm::sea_query::{ + self, extension::postgres::Type, Alias, Expr, ForeignKey, Iden, JoinType, Query, + SelectStatement, SimpleExpr, Table, +}; use sea_orm::{ ActiveModelTrait, ActiveValue, ColumnTrait, Condition, ConnectionTrait, DbBackend, DbConn, DbErr, EntityTrait, QueryFilter, QueryOrder, Schema, Statement, @@ -146,25 +149,7 @@ pub trait MigratorTrait: Send { // Drop all foreign keys if db_backend == DbBackend::MySql { info!("Dropping all foreign keys"); - let mut stmt = Query::select(); - stmt.columns([Alias::new("TABLE_NAME"), Alias::new("CONSTRAINT_NAME")]) - .from(( - Alias::new("information_schema"), - Alias::new("table_constraints"), - )) - .cond_where( - Condition::all() - .add( - Expr::expr(get_current_schema(db)).equals( - Alias::new("table_constraints"), - Alias::new("table_schema"), - ), - ) - .add(Expr::expr(Expr::value("FOREIGN KEY")).equals( - Alias::new("table_constraints"), - Alias::new("constraint_type"), - )), - ); + let stmt = query_mysql_foreign_keys(db); let rows = db.query_all(db_backend.build(&stmt)).await?; for row in rows.into_iter() { let constraint_name: String = row.try_get("", "CONSTRAINT_NAME")?; @@ -196,6 +181,21 @@ pub trait MigratorTrait: Send { info!("Table '{}' has been dropped", table_name); } + // Drop all types + if db_backend == DbBackend::Postgres { + info!("Dropping all types"); + let stmt = query_pg_types(db); + let rows = db.query_all(db_backend.build(&stmt)).await?; + for row in rows { + let type_name: String = row.try_get("", "typname")?; + info!("Dropping type '{}'", type_name); + let mut stmt = Type::drop(); + stmt.name(Alias::new(&type_name as &str)); + db.execute(db_backend.build(&stmt)).await?; + info!("Type '{}' has been dropped", type_name); + } + } + // Restore the foreign key check if db_backend == DbBackend::Sqlite { info!("Restoring foreign key check"); @@ -324,3 +324,79 @@ pub(crate) fn get_current_schema(db: &DbConn) -> SimpleExpr { DbBackend::Sqlite => unimplemented!(), } } + +#[derive(Iden)] +enum InformationSchema { + #[iden = "information_schema"] + Schema, + #[iden = "TABLE_NAME"] + TableName, + #[iden = "CONSTRAINT_NAME"] + ConstraintName, + TableConstraints, + TableSchema, + ConstraintType, +} + +fn query_mysql_foreign_keys(db: &DbConn) -> SelectStatement { + let mut stmt = Query::select(); + stmt.columns([ + InformationSchema::TableName, + InformationSchema::ConstraintName, + ]) + .from(( + InformationSchema::Schema, + InformationSchema::TableConstraints, + )) + .cond_where( + Condition::all() + .add(Expr::expr(get_current_schema(db)).equals( + InformationSchema::TableConstraints, + InformationSchema::TableSchema, + )) + .add( + Expr::tbl( + InformationSchema::TableConstraints, + InformationSchema::ConstraintType, + ) + .eq("FOREIGN KEY"), + ), + ); + stmt +} + +#[derive(Iden)] +enum PgType { + Table, + Typname, + Typnamespace, + Typelem, +} + +#[derive(Iden)] +enum PgNamespace { + Table, + Oid, + Nspname, +} + +fn query_pg_types(db: &DbConn) -> SelectStatement { + let mut stmt = Query::select(); + stmt.column(PgType::Typname) + .from(PgType::Table) + .join( + JoinType::LeftJoin, + PgNamespace::Table, + Expr::tbl(PgNamespace::Table, PgNamespace::Oid) + .equals(PgType::Table, PgType::Typnamespace), + ) + .cond_where( + Condition::all() + .add( + Expr::expr(get_current_schema(db)) + .equals(PgNamespace::Table, PgNamespace::Nspname), + ) + .add(Expr::tbl(PgType::Table, PgType::Typelem).eq(0)), + ); + stmt +} diff --git a/sea-orm-migration/tests/main.rs b/sea-orm-migration/tests/main.rs index 2dfcf93d..fe006c47 100644 --- a/sea-orm-migration/tests/main.rs +++ b/sea-orm-migration/tests/main.rs @@ -1,7 +1,7 @@ mod migrator; use migrator::Migrator; -use sea_orm::{ConnectionTrait, Database, DbBackend, DbErr, Statement}; +use sea_orm::{ConnectOptions, ConnectionTrait, Database, DbBackend, DbErr, Statement}; use sea_orm_migration::prelude::*; #[async_std::test] @@ -11,9 +11,26 @@ async fn main() -> Result<(), DbErr> { .with_test_writer() .init(); - let url = std::env::var("DATABASE_URL").expect("Environment variable 'DATABASE_URL' not set"); - let db_name = "sea_orm_migration"; - let db = Database::connect(&url).await?; + let url = &std::env::var("DATABASE_URL").expect("Environment variable 'DATABASE_URL' not set"); + + run_migration(url, "sea_orm_migration", "public").await?; + + run_migration(url, "sea_orm_migration_schema", "my_schema").await?; + + Ok(()) +} + +async fn run_migration(url: &str, db_name: &str, schema: &str) -> Result<(), DbErr> { + let db_connect = |url: String| async { + let connect_options = ConnectOptions::new(url) + .set_schema_search_path(schema.to_owned()) + .to_owned(); + + Database::connect(connect_options).await + }; + + let db = db_connect(url.to_owned()).await?; + let db = &match db.get_database_backend() { DbBackend::MySql => { db.execute(Statement::from_string( @@ -23,7 +40,7 @@ async fn main() -> Result<(), DbErr> { .await?; let url = format!("{}/{}", url, db_name); - Database::connect(&url).await? + db_connect(url).await? } DbBackend::Postgres => { db.execute(Statement::from_string( @@ -38,7 +55,15 @@ async fn main() -> Result<(), DbErr> { .await?; let url = format!("{}/{}", url, db_name); - Database::connect(&url).await? + let db = db_connect(url).await?; + + db.execute(Statement::from_string( + db.get_database_backend(), + format!("CREATE SCHEMA IF NOT EXISTS \"{}\";", schema), + )) + .await?; + + db } DbBackend::Sqlite => db, }; diff --git a/sea-orm-migration/tests/migrator/m20220118_000004_create_tea_enum.rs b/sea-orm-migration/tests/migrator/m20220118_000004_create_tea_enum.rs new file mode 100644 index 00000000..ba9b68e6 --- /dev/null +++ b/sea-orm-migration/tests/migrator/m20220118_000004_create_tea_enum.rs @@ -0,0 +1,53 @@ +use sea_orm_migration::prelude::{sea_query::extension::postgres::Type, *}; +use sea_orm_migration::sea_orm::{ConnectionTrait, DbBackend}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + match db.get_database_backend() { + DbBackend::MySql | DbBackend::Sqlite => {} + DbBackend::Postgres => { + manager + .create_type( + Type::create() + .as_enum(Tea::Table) + .values([Tea::EverydayTea, Tea::BreakfastTea]) + .to_owned(), + ) + .await?; + } + } + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + match db.get_database_backend() { + DbBackend::MySql | DbBackend::Sqlite => {} + DbBackend::Postgres => { + manager + .drop_type(Type::drop().name(Tea::Table).to_owned()) + .await?; + } + } + + Ok(()) + } +} + +/// Learn more at https://docs.rs/sea-query#iden +#[derive(Iden)] +pub enum Tea { + Table, + #[iden = "EverydayTea"] + EverydayTea, + #[iden = "BreakfastTea"] + BreakfastTea, +} diff --git a/sea-orm-migration/tests/migrator/m20220923_000001_seed_cake_table.rs b/sea-orm-migration/tests/migrator/m20220923_000001_seed_cake_table.rs new file mode 100644 index 00000000..ece73e1a --- /dev/null +++ b/sea-orm-migration/tests/migrator/m20220923_000001_seed_cake_table.rs @@ -0,0 +1,39 @@ +use sea_orm_migration::prelude::*; +use sea_orm_migration::sea_orm::{entity::*, query::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let insert = Query::insert() + .into_table(Cake::Table) + .columns([Cake::Name]) + .values_panic(["Tiramisu".into()]) + .to_owned(); + + manager.exec_stmt(insert).await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let delete = Query::delete() + .from_table(Cake::Table) + .and_where(Expr::col(Cake::Name).eq("Tiramisu")) + .to_owned(); + + manager.exec_stmt(delete).await?; + + Ok(()) + } +} + +/// Learn more at https://docs.rs/sea-query#iden +#[derive(Iden)] +pub enum Cake { + Table, + Id, + Name, +} diff --git a/sea-orm-migration/tests/migrator/mod.rs b/sea-orm-migration/tests/migrator/mod.rs index fdb92d9e..042e5323 100644 --- a/sea-orm-migration/tests/migrator/mod.rs +++ b/sea-orm-migration/tests/migrator/mod.rs @@ -3,6 +3,8 @@ use sea_orm_migration::prelude::*; mod m20220118_000001_create_cake_table; mod m20220118_000002_create_fruit_table; mod m20220118_000003_seed_cake_table; +mod m20220118_000004_create_tea_enum; +mod m20220923_000001_seed_cake_table; pub struct Migrator; @@ -13,6 +15,8 @@ impl MigratorTrait for Migrator { Box::new(m20220118_000001_create_cake_table::Migration), Box::new(m20220118_000002_create_fruit_table::Migration), Box::new(m20220118_000003_seed_cake_table::Migration), + Box::new(m20220118_000004_create_tea_enum::Migration), + Box::new(m20220923_000001_seed_cake_table::Migration), ] } } diff --git a/sea-orm-rocket/lib/Cargo.toml b/sea-orm-rocket/lib/Cargo.toml index c2be9508..32402355 100644 --- a/sea-orm-rocket/lib/Cargo.toml +++ b/sea-orm-rocket/lib/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sea-orm-rocket" -version = "0.5.0" +version = "0.5.1" authors = ["Sergio Benitez ", "Jeb Rosen "] description = "SeaORM Rocket support crate" repository = "https://github.com/SeaQL/sea-orm" @@ -20,7 +20,12 @@ default-features = false path = "../codegen" version = "0.5.0-rc.1" +[dependencies.rocket_okapi] +version = "0.8.0-rc.2" +default-features = false +optional = true + [dev-dependencies.rocket] version = "0.5.0-rc.1" default-features = false -features = ["json"] +features = ["json"] \ No newline at end of file diff --git a/sea-orm-rocket/lib/src/database.rs b/sea-orm-rocket/lib/src/database.rs index 8826b5f9..43792672 100644 --- a/sea-orm-rocket/lib/src/database.rs +++ b/sea-orm-rocket/lib/src/database.rs @@ -9,6 +9,12 @@ use rocket::{error, info_, Build, Ignite, Phase, Rocket, Sentinel}; use rocket::figment::providers::Serialized; use rocket::yansi::Paint; +#[cfg(feature = "rocket_okapi")] +use rocket_okapi::{ + gen::OpenApiGenerator, + request::{OpenApiFromRequest, RequestHeaderInput}, +}; + use crate::Pool; /// Derivable trait which ties a database [`Pool`] with a configuration name. @@ -205,6 +211,17 @@ impl<'a, D: Database> Connection<'a, D> { } } +#[cfg(feature = "rocket_okapi")] +impl<'r, D: Database> OpenApiFromRequest<'r> for Connection<'r, D> { + fn from_request_input( + _gen: &mut OpenApiGenerator, + _name: String, + _required: bool, + ) -> rocket_okapi::Result { + Ok(RequestHeaderInput::None) + } +} + #[rocket::async_trait] impl Fairing for Initializer { fn info(&self) -> Info { diff --git a/src/database/db_connection.rs b/src/database/db_connection.rs index ac4fd7e6..3fa48ec0 100644 --- a/src/database/db_connection.rs +++ b/src/database/db_connection.rs @@ -116,7 +116,9 @@ impl ConnectionTrait for DatabaseConnection { DatabaseConnection::SqlxSqlitePoolConnection(conn) => conn.execute(stmt).await, #[cfg(feature = "mock")] DatabaseConnection::MockDatabaseConnection(conn) => conn.execute(stmt), - DatabaseConnection::Disconnected => Err(DbErr::Conn("Disconnected".to_owned())), + DatabaseConnection::Disconnected => { + Err(DbErr::Conn(RuntimeErr::Internal("Disconnected".to_owned()))) + } } } @@ -132,7 +134,9 @@ impl ConnectionTrait for DatabaseConnection { DatabaseConnection::SqlxSqlitePoolConnection(conn) => conn.query_one(stmt).await, #[cfg(feature = "mock")] DatabaseConnection::MockDatabaseConnection(conn) => conn.query_one(stmt), - DatabaseConnection::Disconnected => Err(DbErr::Conn("Disconnected".to_owned())), + DatabaseConnection::Disconnected => { + Err(DbErr::Conn(RuntimeErr::Internal("Disconnected".to_owned()))) + } } } @@ -148,7 +152,9 @@ impl ConnectionTrait for DatabaseConnection { DatabaseConnection::SqlxSqlitePoolConnection(conn) => conn.query_all(stmt).await, #[cfg(feature = "mock")] DatabaseConnection::MockDatabaseConnection(conn) => conn.query_all(stmt), - DatabaseConnection::Disconnected => Err(DbErr::Conn("Disconnected".to_owned())), + DatabaseConnection::Disconnected => { + Err(DbErr::Conn(RuntimeErr::Internal("Disconnected".to_owned()))) + } } } diff --git a/src/database/mock.rs b/src/database/mock.rs index 7af7cdb5..1677938f 100644 --- a/src/database/mock.rs +++ b/src/database/mock.rs @@ -102,7 +102,9 @@ impl MockDatabaseTrait for MockDatabase { result: ExecResultHolder::Mock(std::mem::take(&mut self.exec_results[counter])), }) } else { - Err(DbErr::Exec("`exec_results` buffer is empty.".to_owned())) + Err(DbErr::Exec(RuntimeErr::Internal( + "`exec_results` buffer is empty.".to_owned(), + ))) } } @@ -121,7 +123,9 @@ impl MockDatabaseTrait for MockDatabase { }) .collect()) } else { - Err(DbErr::Query("`query_results` buffer is empty.".to_owned())) + Err(DbErr::Query(RuntimeErr::Internal( + "`query_results` buffer is empty.".to_owned(), + ))) } } @@ -176,7 +180,7 @@ impl MockRow { where T: ValueType, { - T::try_from(self.values.get(col).unwrap().clone()).map_err(|e| DbErr::Query(e.to_string())) + T::try_from(self.values.get(col).unwrap().clone()).map_err(|e| DbErr::Type(e.to_string())) } /// An iterator over the keys and values of a mock row diff --git a/src/database/mod.rs b/src/database/mod.rs index 4a24ec88..795789e6 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -18,7 +18,7 @@ pub use stream::*; use tracing::instrument; pub use transaction::*; -use crate::DbErr; +use crate::{DbErr, RuntimeErr}; /// Defines a database #[derive(Debug, Default)] @@ -38,6 +38,8 @@ pub struct ConnectOptions { /// Maximum idle time for a particular connection to prevent /// network resource exhaustion pub(crate) idle_timeout: Option, + /// Set the maximum amount of time to spend waiting for acquiring a connection + pub(crate) acquire_timeout: Option, /// Set the maximum lifetime of individual connections pub(crate) max_lifetime: Option, /// Enable SQLx statement logging @@ -46,6 +48,8 @@ pub struct ConnectOptions { pub(crate) sqlx_logging_level: log::LevelFilter, /// set sqlcipher key pub(crate) sqlcipher_key: Option>, + /// Schema search path (PostgreSQL only) + pub(crate) schema_search_path: Option, } impl Database { @@ -73,10 +77,10 @@ impl Database { if crate::MockDatabaseConnector::accepts(&opt.url) { return crate::MockDatabaseConnector::connect(&opt.url).await; } - Err(DbErr::Conn(format!( + Err(DbErr::Conn(RuntimeErr::Internal(format!( "The connection string '{}' has no supporting driver.", opt.url - ))) + )))) } } @@ -107,10 +111,12 @@ impl ConnectOptions { min_connections: None, connect_timeout: None, idle_timeout: None, + acquire_timeout: None, max_lifetime: None, sqlx_logging: true, sqlx_logging_level: log::LevelFilter::Info, sqlcipher_key: None, + schema_search_path: None, } } @@ -137,6 +143,9 @@ impl ConnectOptions { if let Some(idle_timeout) = self.idle_timeout { opt = opt.idle_timeout(Some(idle_timeout)); } + if let Some(acquire_timeout) = self.acquire_timeout { + opt = opt.acquire_timeout(acquire_timeout); + } if let Some(max_lifetime) = self.max_lifetime { opt = opt.max_lifetime(Some(max_lifetime)); } @@ -192,6 +201,17 @@ impl ConnectOptions { self.idle_timeout } + /// Set the maximum amount of time to spend waiting for acquiring a connection + pub fn acquire_timeout(&mut self, value: Duration) -> &mut Self { + self.acquire_timeout = Some(value); + self + } + + /// Get the maximum amount of time to spend waiting for acquiring a connection + pub fn get_acquire_timeout(&self) -> Option { + self.acquire_timeout + } + /// Set the maximum lifetime of individual connections pub fn max_lifetime(&mut self, lifetime: Duration) -> &mut Self { self.max_lifetime = Some(lifetime); @@ -234,4 +254,10 @@ impl ConnectOptions { self.sqlcipher_key = Some(value.into()); self } + + /// Set schema search path (PostgreSQL only) + pub fn set_schema_search_path(&mut self, schema_search_path: String) -> &mut Self { + self.schema_search_path = Some(schema_search_path); + self + } } diff --git a/src/database/transaction.rs b/src/database/transaction.rs index cb1ad122..66ae25e5 100644 --- a/src/database/transaction.rs +++ b/src/database/transaction.rs @@ -238,6 +238,17 @@ impl DatabaseTransaction { } } } + + #[cfg(feature = "sqlx-dep")] + fn map_err_ignore_not_found( + err: Result, sqlx::Error>, + ) -> Result, DbErr> { + if let Err(sqlx::Error::RowNotFound) = err { + Ok(None) + } else { + err.map_err(|e| sqlx_error_to_query_err(e)) + } + } } impl Drop for DatabaseTransaction { @@ -258,13 +269,14 @@ impl ConnectionTrait for DatabaseTransaction { async fn execute(&self, stmt: Statement) -> Result { debug_print!("{}", stmt); - let _res = match &mut *self.conn.lock().await { + match &mut *self.conn.lock().await { #[cfg(feature = "sqlx-mysql")] InnerConnection::MySql(conn) => { let query = crate::driver::sqlx_mysql::sqlx_query(&stmt); crate::metric::metric!(self.metric_callback, &stmt, { query.execute(conn).await.map(Into::into) }) + .map_err(sqlx_error_to_exec_err) } #[cfg(feature = "sqlx-postgres")] InnerConnection::Postgres(conn) => { @@ -272,6 +284,7 @@ impl ConnectionTrait for DatabaseTransaction { crate::metric::metric!(self.metric_callback, &stmt, { query.execute(conn).await.map(Into::into) }) + .map_err(sqlx_error_to_exec_err) } #[cfg(feature = "sqlx-sqlite")] InnerConnection::Sqlite(conn) => { @@ -279,14 +292,13 @@ impl ConnectionTrait for DatabaseTransaction { crate::metric::metric!(self.metric_callback, &stmt, { query.execute(conn).await.map(Into::into) }) + .map_err(sqlx_error_to_exec_err) } #[cfg(feature = "mock")] InnerConnection::Mock(conn) => return conn.execute(stmt), #[allow(unreachable_patterns)] _ => unreachable!(), - }; - #[cfg(feature = "sqlx-dep")] - _res.map_err(sqlx_error_to_exec_err) + } } #[instrument(level = "trace")] @@ -294,32 +306,32 @@ impl ConnectionTrait for DatabaseTransaction { async fn query_one(&self, stmt: Statement) -> Result, DbErr> { debug_print!("{}", stmt); - let _res = match &mut *self.conn.lock().await { + match &mut *self.conn.lock().await { #[cfg(feature = "sqlx-mysql")] InnerConnection::MySql(conn) => { let query = crate::driver::sqlx_mysql::sqlx_query(&stmt); - query.fetch_one(conn).await.map(|row| Some(row.into())) + Self::map_err_ignore_not_found( + query.fetch_one(conn).await.map(|row| Some(row.into())), + ) } #[cfg(feature = "sqlx-postgres")] InnerConnection::Postgres(conn) => { let query = crate::driver::sqlx_postgres::sqlx_query(&stmt); - query.fetch_one(conn).await.map(|row| Some(row.into())) + Self::map_err_ignore_not_found( + query.fetch_one(conn).await.map(|row| Some(row.into())), + ) } #[cfg(feature = "sqlx-sqlite")] InnerConnection::Sqlite(conn) => { let query = crate::driver::sqlx_sqlite::sqlx_query(&stmt); - query.fetch_one(conn).await.map(|row| Some(row.into())) + Self::map_err_ignore_not_found( + query.fetch_one(conn).await.map(|row| Some(row.into())), + ) } #[cfg(feature = "mock")] InnerConnection::Mock(conn) => return conn.query_one(stmt), #[allow(unreachable_patterns)] _ => unreachable!(), - }; - #[cfg(feature = "sqlx-dep")] - if let Err(sqlx::Error::RowNotFound) = _res { - Ok(None) - } else { - _res.map_err(sqlx_error_to_query_err) } } @@ -328,7 +340,7 @@ impl ConnectionTrait for DatabaseTransaction { async fn query_all(&self, stmt: Statement) -> Result, DbErr> { debug_print!("{}", stmt); - let _res = match &mut *self.conn.lock().await { + match &mut *self.conn.lock().await { #[cfg(feature = "sqlx-mysql")] InnerConnection::MySql(conn) => { let query = crate::driver::sqlx_mysql::sqlx_query(&stmt); @@ -336,6 +348,7 @@ impl ConnectionTrait for DatabaseTransaction { .fetch_all(conn) .await .map(|rows| rows.into_iter().map(|r| r.into()).collect()) + .map_err(sqlx_error_to_query_err) } #[cfg(feature = "sqlx-postgres")] InnerConnection::Postgres(conn) => { @@ -344,6 +357,7 @@ impl ConnectionTrait for DatabaseTransaction { .fetch_all(conn) .await .map(|rows| rows.into_iter().map(|r| r.into()).collect()) + .map_err(sqlx_error_to_query_err) } #[cfg(feature = "sqlx-sqlite")] InnerConnection::Sqlite(conn) => { @@ -352,14 +366,13 @@ impl ConnectionTrait for DatabaseTransaction { .fetch_all(conn) .await .map(|rows| rows.into_iter().map(|r| r.into()).collect()) + .map_err(sqlx_error_to_query_err) } #[cfg(feature = "mock")] InnerConnection::Mock(conn) => return conn.query_all(stmt), #[allow(unreachable_patterns)] _ => unreachable!(), - }; - #[cfg(feature = "sqlx-dep")] - _res.map_err(sqlx_error_to_query_err) + } } } diff --git a/src/driver/sqlx_common.rs b/src/driver/sqlx_common.rs index 18ca059d..37a250ed 100644 --- a/src/driver/sqlx_common.rs +++ b/src/driver/sqlx_common.rs @@ -1,16 +1,16 @@ -use crate::DbErr; +use crate::{DbErr, RuntimeErr}; /// Converts an [sqlx::error] execution error to a [DbErr] pub fn sqlx_error_to_exec_err(err: sqlx::Error) -> DbErr { - DbErr::Exec(err.to_string()) + DbErr::Exec(RuntimeErr::SqlxError(err)) } /// Converts an [sqlx::error] query error to a [DbErr] pub fn sqlx_error_to_query_err(err: sqlx::Error) -> DbErr { - DbErr::Query(err.to_string()) + DbErr::Query(RuntimeErr::SqlxError(err)) } /// Converts an [sqlx::error] connection error to a [DbErr] pub fn sqlx_error_to_conn_err(err: sqlx::Error) -> DbErr { - DbErr::Conn(err.to_string()) + DbErr::Conn(RuntimeErr::SqlxError(err)) } diff --git a/src/driver/sqlx_mysql.rs b/src/driver/sqlx_mysql.rs index ac53f556..73dfa59f 100644 --- a/src/driver/sqlx_mysql.rs +++ b/src/driver/sqlx_mysql.rs @@ -45,7 +45,7 @@ impl SqlxMySqlConnector { let mut opt = options .url .parse::() - .map_err(|e| DbErr::Conn(e.to_string()))?; + .map_err(sqlx_error_to_conn_err)?; use sqlx::ConnectOptions; if !options.sqlx_logging { opt.disable_statement_logging(); @@ -89,9 +89,7 @@ impl SqlxMySqlPoolConnection { } }) } else { - Err(DbErr::Exec( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnectionAcquire) } } @@ -107,14 +105,12 @@ impl SqlxMySqlPoolConnection { Ok(row) => Ok(Some(row.into())), Err(err) => match err { sqlx::Error::RowNotFound => Ok(None), - _ => Err(DbErr::Query(err.to_string())), + _ => Err(sqlx_error_to_query_err(err)), }, } }) } else { - Err(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnectionAcquire) } } @@ -132,9 +128,7 @@ impl SqlxMySqlPoolConnection { } }) } else { - Err(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnectionAcquire) } } @@ -150,9 +144,7 @@ impl SqlxMySqlPoolConnection { self.metric_callback.clone(), ))) } else { - Err(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnectionAcquire) } } @@ -162,9 +154,7 @@ impl SqlxMySqlPoolConnection { if let Ok(conn) = self.pool.acquire().await { DatabaseTransaction::new_mysql(conn, self.metric_callback.clone()).await } else { - Err(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnectionAcquire) } } @@ -185,9 +175,7 @@ impl SqlxMySqlPoolConnection { .map_err(|e| TransactionError::Connection(e))?; transaction.run(callback).await } else { - Err(TransactionError::Connection(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - ))) + Err(TransactionError::Connection(DbErr::ConnectionAcquire)) } } diff --git a/src/driver/sqlx_postgres.rs b/src/driver/sqlx_postgres.rs index 7ae5cd40..167b34b8 100644 --- a/src/driver/sqlx_postgres.rs +++ b/src/driver/sqlx_postgres.rs @@ -45,14 +45,29 @@ impl SqlxPostgresConnector { let mut opt = options .url .parse::() - .map_err(|e| DbErr::Conn(e.to_string()))?; + .map_err(sqlx_error_to_conn_err)?; use sqlx::ConnectOptions; if !options.sqlx_logging { opt.disable_statement_logging(); } else { opt.log_statements(options.sqlx_logging_level); } - match options.pool_options().connect_with(opt).await { + let set_search_path_sql = options + .schema_search_path + .as_ref() + .map(|schema| format!("SET search_path = '{}'", schema)); + let mut pool_options = options.pool_options(); + if let Some(sql) = set_search_path_sql { + pool_options = pool_options.after_connect(move |conn, _| { + let sql = sql.clone(); + Box::pin(async move { + sqlx::Executor::execute(conn, sql.as_str()) + .await + .map(|_| ()) + }) + }); + } + match pool_options.connect_with(opt).await { Ok(pool) => Ok(DatabaseConnection::SqlxPostgresPoolConnection( SqlxPostgresPoolConnection { pool, @@ -89,9 +104,7 @@ impl SqlxPostgresPoolConnection { } }) } else { - Err(DbErr::Exec( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnectionAcquire) } } @@ -107,14 +120,12 @@ impl SqlxPostgresPoolConnection { Ok(row) => Ok(Some(row.into())), Err(err) => match err { sqlx::Error::RowNotFound => Ok(None), - _ => Err(DbErr::Query(err.to_string())), + _ => Err(sqlx_error_to_query_err(err)), }, } }) } else { - Err(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnectionAcquire) } } @@ -132,9 +143,7 @@ impl SqlxPostgresPoolConnection { } }) } else { - Err(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnectionAcquire) } } @@ -150,9 +159,7 @@ impl SqlxPostgresPoolConnection { self.metric_callback.clone(), ))) } else { - Err(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnectionAcquire) } } @@ -162,9 +169,7 @@ impl SqlxPostgresPoolConnection { if let Ok(conn) = self.pool.acquire().await { DatabaseTransaction::new_postgres(conn, self.metric_callback.clone()).await } else { - Err(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnectionAcquire) } } @@ -185,9 +190,7 @@ impl SqlxPostgresPoolConnection { .map_err(|e| TransactionError::Connection(e))?; transaction.run(callback).await } else { - Err(TransactionError::Connection(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - ))) + Err(TransactionError::Connection(DbErr::ConnectionAcquire)) } } diff --git a/src/driver/sqlx_sqlite.rs b/src/driver/sqlx_sqlite.rs index 2d3a4814..b09f1bd0 100644 --- a/src/driver/sqlx_sqlite.rs +++ b/src/driver/sqlx_sqlite.rs @@ -46,7 +46,7 @@ impl SqlxSqliteConnector { let mut opt = options .url .parse::() - .map_err(|e| DbErr::Conn(e.to_string()))?; + .map_err(sqlx_error_to_conn_err)?; if options.sqlcipher_key.is_some() { opt = opt.pragma("key", options.sqlcipher_key.clone().unwrap()); } @@ -96,9 +96,7 @@ impl SqlxSqlitePoolConnection { } }) } else { - Err(DbErr::Exec( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnectionAcquire) } } @@ -114,14 +112,12 @@ impl SqlxSqlitePoolConnection { Ok(row) => Ok(Some(row.into())), Err(err) => match err { sqlx::Error::RowNotFound => Ok(None), - _ => Err(DbErr::Query(err.to_string())), + _ => Err(sqlx_error_to_query_err(err)), }, } }) } else { - Err(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnectionAcquire) } } @@ -139,9 +135,7 @@ impl SqlxSqlitePoolConnection { } }) } else { - Err(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnectionAcquire) } } @@ -157,9 +151,7 @@ impl SqlxSqlitePoolConnection { self.metric_callback.clone(), ))) } else { - Err(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnectionAcquire) } } @@ -169,9 +161,7 @@ impl SqlxSqlitePoolConnection { if let Ok(conn) = self.pool.acquire().await { DatabaseTransaction::new_sqlite(conn, self.metric_callback.clone()).await } else { - Err(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnectionAcquire) } } @@ -192,9 +182,7 @@ impl SqlxSqlitePoolConnection { .map_err(|e| TransactionError::Connection(e))?; transaction.run(callback).await } else { - Err(TransactionError::Connection(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - ))) + Err(TransactionError::Connection(DbErr::ConnectionAcquire)) } } diff --git a/src/entity/active_model.rs b/src/entity/active_model.rs index afc8bd28..e63cbea4 100644 --- a/src/entity/active_model.rs +++ b/src/entity/active_model.rs @@ -641,6 +641,30 @@ where fn into_active_value(self) -> ActiveValue; } +impl IntoActiveValue> for Option +where + V: IntoActiveValue + Into + Nullable, +{ + fn into_active_value(self) -> ActiveValue> { + match self { + Some(value) => Set(Some(value)), + None => NotSet, + } + } +} + +impl IntoActiveValue> for Option> +where + V: IntoActiveValue + Into + Nullable, +{ + fn into_active_value(self) -> ActiveValue> { + match self { + Some(value) => Set(value), + None => NotSet, + } + } +} + macro_rules! impl_into_active_value { ($ty: ty) => { impl IntoActiveValue<$ty> for $ty { @@ -648,24 +672,6 @@ macro_rules! impl_into_active_value { Set(self) } } - - impl IntoActiveValue> for Option<$ty> { - fn into_active_value(self) -> ActiveValue> { - match self { - Some(value) => Set(Some(value)), - None => NotSet, - } - } - } - - impl IntoActiveValue> for Option> { - fn into_active_value(self) -> ActiveValue> { - match self { - Some(value) => Set(value), - None => NotSet, - } - } - } }; } @@ -682,6 +688,7 @@ impl_into_active_value!(f32); impl_into_active_value!(f64); impl_into_active_value!(&'static str); impl_into_active_value!(String); +impl_into_active_value!(Vec); #[cfg(feature = "with-json")] #[cfg_attr(docsrs, doc(cfg(feature = "with-json")))] @@ -719,6 +726,22 @@ impl_into_active_value!(crate::prelude::Decimal); #[cfg_attr(docsrs, doc(cfg(feature = "with-uuid")))] impl_into_active_value!(crate::prelude::Uuid); +#[cfg(feature = "with-time")] +#[cfg_attr(docsrs, doc(cfg(feature = "with-time")))] +impl_into_active_value!(crate::prelude::TimeDate); + +#[cfg(feature = "with-time")] +#[cfg_attr(docsrs, doc(cfg(feature = "with-time")))] +impl_into_active_value!(crate::prelude::TimeTime); + +#[cfg(feature = "with-time")] +#[cfg_attr(docsrs, doc(cfg(feature = "with-time")))] +impl_into_active_value!(crate::prelude::TimeDateTime); + +#[cfg(feature = "with-time")] +#[cfg_attr(docsrs, doc(cfg(feature = "with-time")))] +impl_into_active_value!(crate::prelude::TimeDateTimeWithTimeZone); + impl Default for ActiveValue where V: Into, diff --git a/src/error.rs b/src/error.rs index aecc3f52..01c4f21f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,49 +1,80 @@ +#[cfg(feature = "sqlx-dep")] +use sqlx::error::Error as SqlxError; +use thiserror::Error; + /// An error from unsuccessful database operations -#[derive(Debug, PartialEq, Eq, Clone)] +#[derive(Error, Debug)] pub enum DbErr { + /// This error can happen when the connection pool is fully-utilized + #[error("Failed to acquire connection from pool")] + ConnectionAcquire, + /// Runtime type conversion error + #[error("Error converting `{from}` into `{into}`: {source}")] + TryIntoErr { + /// From type + from: &'static str, + /// Into type + into: &'static str, + /// TryError + source: Box, + }, /// There was a problem with the database connection - Conn(String), + #[error("Connection Error: {0}")] + Conn(#[source] RuntimeErr), /// An operation did not execute successfully - Exec(String), + #[error("Execution Error: {0}")] + Exec(#[source] RuntimeErr), /// An error occurred while performing a query - Query(String), + #[error("Query Error: {0}")] + Query(#[source] RuntimeErr), + /// Type error: the specified type cannot be converted from u64. This is not a runtime error. + #[error("Type '{0}' cannot be converted from u64")] + ConvertFromU64(&'static str), + /// After an insert statement it was impossible to retrieve the last_insert_id + #[error("Failed to unpack last_insert_id")] + UnpackInsertId, + /// When updating, a model should know it's primary key to check + /// if the record has been correctly updated, otherwise this error will occur + #[error("Failed to get primary key from model")] + UpdateGetPrimeryKey, /// The record was not found in the database + #[error("RecordNotFound Error: {0}")] RecordNotFound(String), /// A custom error + #[error("Custom Error: {0}")] Custom(String), /// Error occurred while parsing value as target type + #[error("Type Error: {0}")] Type(String), /// Error occurred while parsing json value as target type + #[error("Json Error: {0}")] Json(String), /// A migration error + #[error("Migration Error: {0}")] Migration(String), } -impl std::error::Error for DbErr {} +/// Runtime error +#[derive(Error, Debug)] +pub enum RuntimeErr { + /// SQLx Error + #[cfg(feature = "sqlx-dep")] + #[error("{0}")] + SqlxError(SqlxError), + /// Error generated from within SeaORM + #[error("{0}")] + Internal(String), +} -impl std::fmt::Display for DbErr { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match self { - Self::Conn(s) => write!(f, "Connection Error: {}", s), - Self::Exec(s) => write!(f, "Execution Error: {}", s), - Self::Query(s) => write!(f, "Query Error: {}", s), - Self::RecordNotFound(s) => write!(f, "RecordNotFound Error: {}", s), - Self::Custom(s) => write!(f, "Custom Error: {}", s), - Self::Type(s) => write!(f, "Type Error: {}", s), - Self::Json(s) => write!(f, "Json Error: {}", s), - Self::Migration(s) => write!(f, "Migration Error: {}", s), - } +impl PartialEq for DbErr { + fn eq(&self, other: &Self) -> bool { + self.to_string() == other.to_string() } } -/// An error from a failed column operation when trying to convert the column to a string -#[derive(Debug, Clone)] +impl Eq for DbErr {} + +/// Error during `impl FromStr for Entity::Column` +#[derive(Error, Debug)] +#[error("Failed to match \"{0}\" as Column")] pub struct ColumnFromStrErr(pub String); - -impl std::error::Error for ColumnFromStrErr {} - -impl std::fmt::Display for ColumnFromStrErr { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "{}", self.0.as_str()) - } -} diff --git a/src/executor/cursor.rs b/src/executor/cursor.rs index 7f59480e..b8345524 100644 --- a/src/executor/cursor.rs +++ b/src/executor/cursor.rs @@ -8,6 +8,9 @@ use sea_query::{ }; use std::marker::PhantomData; +#[cfg(feature = "with-json")] +use crate::JsonValue; + /// Cursor pagination #[derive(Debug, Clone)] pub struct Cursor @@ -140,6 +143,32 @@ where } Ok(buffer) } + + /// Construct a [Cursor] that fetch any custom struct + pub fn into_model(self) -> Cursor> + where + M: FromQueryResult, + { + Cursor { + query: self.query, + table: self.table, + order_columns: self.order_columns, + last: self.last, + phantom: PhantomData, + } + } + + /// Construct a [Cursor] that fetch JSON value + #[cfg(feature = "with-json")] + pub fn into_json(self) -> Cursor> { + Cursor { + query: self.query, + table: self.table, + order_columns: self.order_columns, + last: self.last, + phantom: PhantomData, + } + } } impl QueryOrder for Cursor diff --git a/src/executor/delete.rs b/src/executor/delete.rs index 29ad02d3..08d1d8f1 100644 --- a/src/executor/delete.rs +++ b/src/executor/delete.rs @@ -52,7 +52,7 @@ impl Deleter { } /// Execute a DELETE operation - pub fn exec<'a, C>(self, db: &'a C) -> impl Future> + '_ + pub fn exec(self, db: &C) -> impl Future> + '_ where C: ConnectionTrait, { diff --git a/src/executor/insert.rs b/src/executor/insert.rs index 3c7b69c8..51b82623 100644 --- a/src/executor/insert.rs +++ b/src/executor/insert.rs @@ -128,7 +128,7 @@ where Some(value_tuple) => FromValueTuple::from_value_tuple(value_tuple), None => match last_insert_id_opt { Some(last_insert_id) => last_insert_id, - None => return Err(DbErr::Exec("Fail to unpack last_insert_id".to_owned())), + None => return Err(DbErr::UnpackInsertId), }, }; Ok(InsertResult { last_insert_id }) @@ -168,6 +168,8 @@ where }; match found { Some(model) => Ok(model), - None => Err(DbErr::Exec("Failed to find inserted item".to_owned())), + None => Err(DbErr::RecordNotFound( + "Failed to find inserted item".to_owned(), + )), } } diff --git a/src/executor/paginator.rs b/src/executor/paginator.rs index d3f3e8b5..fe5b9272 100644 --- a/src/executor/paginator.rs +++ b/src/executor/paginator.rs @@ -251,7 +251,7 @@ where { type Selector = S; fn paginate(self, db: &'db C, page_size: u64) -> Paginator<'db, C, S> { - let sql = &self.stmt.sql[7..]; + let sql = &self.stmt.sql.trim()[7..]; let mut query = SelectStatement::new(); query.expr(if let Some(values) = self.stmt.values { Expr::cust_with_values(sql, values.0) @@ -306,6 +306,7 @@ mod tests { use crate::{DatabaseConnection, DbBackend, MockDatabase, Transaction}; use futures::TryStreamExt; use once_cell::sync::Lazy; + use pretty_assertions::assert_eq; use sea_query::{Alias, Expr, SelectStatement, Value}; static RAW_STMT: Lazy = Lazy::new(|| { @@ -726,4 +727,45 @@ mod tests { assert_eq!(db.into_transaction_log(), Transaction::wrap(stmts)); Ok(()) } + + #[smol_potat::test] + async fn into_stream_raw_leading_spaces() -> Result<(), DbErr> { + let (db, pages) = setup(); + + let raw_stmt = Statement::from_sql_and_values( + DbBackend::Postgres, + r#" SELECT "fruit"."id", "fruit"."name", "fruit"."cake_id" FROM "fruit" "#, + vec![], + ); + + let mut fruit_stream = fruit::Entity::find() + .from_raw_sql(raw_stmt.clone()) + .paginate(&db, 2) + .into_stream(); + + assert_eq!(fruit_stream.try_next().await?, Some(pages[0].clone())); + assert_eq!(fruit_stream.try_next().await?, Some(pages[1].clone())); + assert_eq!(fruit_stream.try_next().await?, None); + + drop(fruit_stream); + + let mut select = SelectStatement::new() + .exprs(vec![ + Expr::tbl(fruit::Entity, fruit::Column::Id), + Expr::tbl(fruit::Entity, fruit::Column::Name), + Expr::tbl(fruit::Entity, fruit::Column::CakeId), + ]) + .from(fruit::Entity) + .to_owned(); + + let query_builder = db.get_database_backend(); + let stmts = vec![ + query_builder.build(select.clone().offset(0).limit(2)), + query_builder.build(select.clone().offset(2).limit(2)), + query_builder.build(select.offset(4).limit(2)), + ]; + + assert_eq!(db.into_transaction_log(), Transaction::wrap(stmts)); + Ok(()) + } } diff --git a/src/executor/query.rs b/src/executor/query.rs index 06361a45..3b53542b 100644 --- a/src/executor/query.rs +++ b/src/executor/query.rs @@ -41,7 +41,7 @@ impl From for DbErr { match e { TryGetError::DbErr(e) => e, TryGetError::Null(s) => { - DbErr::Query(format!("error occurred while decoding {}: Null", s)) + DbErr::Type(format!("A null value was encountered while decoding {}", s)) } } } @@ -376,12 +376,13 @@ impl TryGetable for Decimal { let val: Option = row .try_get(column.as_str()) .map_err(|e| TryGetError::DbErr(crate::sqlx_error_to_query_err(e)))?; - use rust_decimal::prelude::FromPrimitive; match val { - Some(v) => Decimal::from_f64(v).ok_or_else(|| { - TryGetError::DbErr(DbErr::Query( - "Failed to convert f64 into Decimal".to_owned(), - )) + Some(v) => Decimal::try_from(v).map_err(|e| { + TryGetError::DbErr(DbErr::TryIntoErr { + from: "f64", + into: "Decimal", + source: Box::new(e), + }) }), None => Err(TryGetError::Null(column)), } @@ -626,7 +627,7 @@ where fn try_get_many_with_slice_len_of(len: usize, cols: &[String]) -> Result<(), TryGetError> { if cols.len() < len { - Err(TryGetError::DbErr(DbErr::Query(format!( + Err(TryGetError::DbErr(DbErr::Type(format!( "Expect {} column names supplied but got slice of length {}", len, cols.len() @@ -708,10 +709,7 @@ macro_rules! try_from_u64_err { ( $type: ty ) => { impl TryFromU64 for $type { fn try_from_u64(_: u64) -> Result { - Err(DbErr::Exec(format!( - "{} cannot be converted from u64", - stringify!($type) - ))) + Err(DbErr::ConvertFromU64(stringify!($type))) } } }; @@ -722,10 +720,7 @@ macro_rules! try_from_u64_err { $( $gen_type: TryFromU64, )* { fn try_from_u64(_: u64) -> Result { - Err(DbErr::Exec(format!( - "{} cannot be converted from u64", - stringify!(($($gen_type,)*)) - ))) + Err(DbErr::ConvertFromU64(stringify!($($gen_type,)*))) } } }; @@ -743,12 +738,10 @@ macro_rules! try_from_u64_numeric { impl TryFromU64 for $type { fn try_from_u64(n: u64) -> Result { use std::convert::TryInto; - n.try_into().map_err(|_| { - DbErr::Exec(format!( - "fail to convert '{}' into '{}'", - n, - stringify!($type) - )) + n.try_into().map_err(|e| DbErr::TryIntoErr { + from: stringify!(u64), + into: stringify!($type), + source: Box::new(e), }) } } @@ -823,18 +816,22 @@ try_from_u64_err!(uuid::Uuid); #[cfg(test)] mod tests { use super::TryGetError; - use crate::error::DbErr; + use crate::error::*; #[test] fn from_try_get_error() { // TryGetError::DbErr - let expected = DbErr::Query("expected error message".to_owned()); - let try_get_error = TryGetError::DbErr(expected.clone()); - assert_eq!(DbErr::from(try_get_error), expected); + let try_get_error = TryGetError::DbErr(DbErr::Query(RuntimeErr::Internal( + "expected error message".to_owned(), + ))); + assert_eq!( + DbErr::from(try_get_error), + DbErr::Query(RuntimeErr::Internal("expected error message".to_owned())) + ); // TryGetError::Null let try_get_error = TryGetError::Null("column".to_owned()); - let expected = "error occurred while decoding column: Null".to_owned(); - assert_eq!(DbErr::from(try_get_error), DbErr::Query(expected)); + let expected = "A null value was encountered while decoding column".to_owned(); + assert_eq!(DbErr::from(try_get_error), DbErr::Type(expected)); } } diff --git a/src/executor/update.rs b/src/executor/update.rs index a7b343a1..c06606dc 100644 --- a/src/executor/update.rs +++ b/src/executor/update.rs @@ -64,7 +64,7 @@ impl Updater { } /// Execute an update operation - pub fn exec<'a, C>(self, db: &'a C) -> impl Future> + '_ + pub fn exec(self, db: &C) -> impl Future> + '_ where C: ConnectionTrait, { @@ -116,7 +116,7 @@ where Updater::new(query).check_record_exists().exec(db).await?; let primary_key_value = match model.get_primary_key_value() { Some(val) => FromValueTuple::from_value_tuple(val), - None => return Err(DbErr::Exec("Fail to get primary key from model".to_owned())), + None => return Err(DbErr::UpdateGetPrimeryKey), }; let found = ::find_by_id(primary_key_value) .one(db) @@ -124,7 +124,9 @@ where // If we cannot select the updated row from db by the cached primary key match found { Some(model) => Ok(model), - None => Err(DbErr::Exec("Failed to find inserted item".to_owned())), + None => Err(DbErr::RecordNotFound( + "Failed to find updated item".to_owned(), + )), } } } diff --git a/src/lib.rs b/src/lib.rs index 8011ea0b..540e05c9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,7 @@ //! //! + [Getting Started](https://www.sea-ql.org/SeaORM/docs/index) //! + [Step-by-step Tutorials](https://www.sea-ql.org/sea-orm-tutorial/) +//! + [Cookbook](https://www.sea-ql.org/sea-orm-cookbook/) //! + [Usage Example](https://github.com/SeaQL/sea-orm/tree/master/examples/basic) //! //! Integration examples @@ -259,7 +260,7 @@ //! ## Learn More //! //! 1. [Design](https://github.com/SeaQL/sea-orm/tree/master/DESIGN.md) -//! 1. [Architecture](https://github.com/SeaQL/sea-orm/tree/master/ARCHITECTURE.md) +//! 1. [Architecture](https://www.sea-ql.org/SeaORM/docs/internal-design/architecture/) //! 1. [Release Model](https://www.sea-ql.org/SeaORM/blog/2021-08-30-release-model) //! 1. [Change Log](https://github.com/SeaQL/sea-orm/tree/master/CHANGELOG.md) //! @@ -323,7 +324,7 @@ pub mod query; /// Holds types that defines the schemas of an Entity pub mod schema; #[doc(hidden)] -#[cfg(feature = "macros")] +#[cfg(all(feature = "macros", feature = "tests-cfg"))] pub mod tests_cfg; mod util; diff --git a/src/query/helper.rs b/src/query/helper.rs index 89dd61f1..556ac41b 100644 --- a/src/query/helper.rs +++ b/src/query/helper.rs @@ -8,6 +8,8 @@ use sea_query::{ }; pub use sea_query::{Condition, ConditionalStatement, DynIden, JoinType, Order, OrderedStatement}; +use sea_query::IntoColumnRef; + // LINT: when the column does not appear in tables selected from // LINT: when there is a group by clause, but some columns don't have aggregate functions // LINT: when the join table or column does not exists @@ -172,6 +174,61 @@ pub trait QuerySelect: Sized { self } + /// Add a DISTINCT expression + /// ``` + /// use sea_orm::{entity::*, query::*, tests_cfg::cake, DbBackend}; + /// struct Input { + /// name: Option, + /// } + /// let input = Input { + /// name: Some("cheese".to_owned()), + /// }; + /// assert_eq!( + /// cake::Entity::find() + /// .filter( + /// Condition::all().add_option(input.name.map(|n| cake::Column::Name.contains(&n))) + /// ) + /// .distinct() + /// .build(DbBackend::MySql) + /// .to_string(), + /// "SELECT DISTINCT `cake`.`id`, `cake`.`name` FROM `cake` WHERE `cake`.`name` LIKE '%cheese%'" + /// ); + /// ``` + fn distinct(mut self) -> Self { + self.query().distinct(); + self + } + + /// Add a DISTINCT ON expression + /// NOTE: this function is only supported by `sqlx-postgres` + /// ``` + /// use sea_orm::{entity::*, query::*, tests_cfg::cake, DbBackend}; + /// struct Input { + /// name: Option, + /// } + /// let input = Input { + /// name: Some("cheese".to_owned()), + /// }; + /// assert_eq!( + /// cake::Entity::find() + /// .filter( + /// Condition::all().add_option(input.name.map(|n| cake::Column::Name.contains(&n))) + /// ) + /// .distinct_on([cake::Column::Name]) + /// .build(DbBackend::Postgres) + /// .to_string(), + /// "SELECT DISTINCT ON (\"name\") \"cake\".\"id\", \"cake\".\"name\" FROM \"cake\" WHERE \"cake\".\"name\" LIKE '%cheese%'" + /// ); + /// ``` + fn distinct_on(mut self, cols: I) -> Self + where + T: IntoColumnRef, + I: IntoIterator, + { + self.query().distinct_on(cols); + self + } + #[doc(hidden)] fn join_join(mut self, join: JoinType, rel: RelationDef, via: Option) -> Self { if let Some(via) = via { diff --git a/src/tests_cfg/lunch_set.rs b/src/tests_cfg/lunch_set.rs index b063245e..c0665da5 100644 --- a/src/tests_cfg/lunch_set.rs +++ b/src/tests_cfg/lunch_set.rs @@ -11,13 +11,7 @@ pub struct Model { pub tea: Tea, } -#[derive(Copy, Clone, Debug, EnumIter)] +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} -impl RelationTrait for Relation { - fn def(&self) -> RelationDef { - panic!("No RelationDef") - } -} - impl ActiveModelBehavior for ActiveModel {} diff --git a/tests/common/features/custom_active_model.rs b/tests/common/features/custom_active_model.rs new file mode 100644 index 00000000..6b6e6977 --- /dev/null +++ b/tests/common/features/custom_active_model.rs @@ -0,0 +1,40 @@ +use super::sea_orm_active_enums::*; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue, IntoActiveValue}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[cfg_attr(feature = "sqlx-postgres", sea_orm(schema_name = "public"))] +#[sea_orm(table_name = "custom_active_model")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub weight: Option, + pub amount: Option, + pub category: Option, + pub color: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +#[derive(Clone, Debug, PartialEq, DeriveIntoActiveModel)] +pub struct CustomActiveModel { + pub weight: Option, + pub amount: Option>, + pub category: Option, + pub color: Option>, +} + +impl IntoActiveValue for Category { + fn into_active_value(self) -> ActiveValue { + ActiveValue::set(self) + } +} + +impl IntoActiveValue for Color { + fn into_active_value(self) -> ActiveValue { + ActiveValue::set(self) + } +} diff --git a/tests/common/features/mod.rs b/tests/common/features/mod.rs index 0b26b261..ce95af17 100644 --- a/tests/common/features/mod.rs +++ b/tests/common/features/mod.rs @@ -2,6 +2,7 @@ pub mod active_enum; pub mod active_enum_child; pub mod applog; pub mod byte_primary_key; +pub mod custom_active_model; pub mod insert_default; pub mod json_struct; pub mod json_vec; diff --git a/tests/crud/error.rs b/tests/crud/error.rs new file mode 100644 index 00000000..4828d9cd --- /dev/null +++ b/tests/crud/error.rs @@ -0,0 +1,56 @@ +pub use super::*; +use rust_decimal_macros::dec; +use sea_orm::error::*; +#[cfg(any( + feature = "sqlx-mysql", + feature = "sqlx-sqlite", + feature = "sqlx-postgres" +))] +use sqlx::Error; +use uuid::Uuid; + +pub async fn test_cake_error_sqlx(db: &DbConn) { + let mud_cake = cake::ActiveModel { + name: Set("Moldy Cake".to_owned()), + price: Set(dec!(10.25)), + gluten_free: Set(false), + serial: Set(Uuid::new_v4()), + bakery_id: Set(None), + ..Default::default() + }; + + let cake = mud_cake.save(db).await.expect("could not insert cake"); + + // if compiling without sqlx, this assignment will complain, + // but the whole test is useless in that case anyway. + #[allow(unused_variables)] + let error: DbErr = cake + .into_active_model() + .insert(db) + .await + .expect_err("inserting should fail due to duplicate primary key"); + + #[cfg(any(feature = "sqlx-mysql", feature = "sqlx-sqlite"))] + match error { + DbErr::Exec(RuntimeErr::SqlxError(error)) => match error { + Error::Database(e) => { + #[cfg(feature = "sqlx-mysql")] + assert_eq!(e.code().unwrap(), "23000"); + #[cfg(feature = "sqlx-sqlite")] + assert_eq!(e.code().unwrap(), "1555"); + } + _ => panic!("Unexpected sqlx-error kind"), + }, + _ => panic!("Unexpected Error kind"), + } + #[cfg(feature = "sqlx-postgres")] + match error { + DbErr::Query(RuntimeErr::SqlxError(error)) => match error { + Error::Database(e) => { + assert_eq!(e.code().unwrap(), "23505"); + } + _ => panic!("Unexpected sqlx-error kind"), + }, + _ => panic!("Unexpected Error kind"), + } +} diff --git a/tests/crud/mod.rs b/tests/crud/mod.rs index 916878c8..3a629d7a 100644 --- a/tests/crud/mod.rs +++ b/tests/crud/mod.rs @@ -3,6 +3,7 @@ pub mod create_cake; pub mod create_lineitem; pub mod create_order; pub mod deletes; +pub mod error; pub mod updates; pub use create_baker::*; @@ -10,6 +11,7 @@ pub use create_cake::*; pub use create_lineitem::*; pub use create_order::*; pub use deletes::*; +pub use error::*; pub use updates::*; pub use super::common::bakery_chain::*; diff --git a/tests/crud_tests.rs b/tests/crud_tests.rs index bc2a3c97..9f7ea404 100644 --- a/tests/crud_tests.rs +++ b/tests/crud_tests.rs @@ -35,5 +35,6 @@ pub async fn create_entities(db: &DatabaseConnection) { test_update_deleted_customer(db).await; test_delete_cake(db).await; + test_cake_error_sqlx(db).await; test_delete_bakery(db).await; } diff --git a/tests/cursor_tests.rs b/tests/cursor_tests.rs index 19790205..e6feef9f 100644 --- a/tests/cursor_tests.rs +++ b/tests/cursor_tests.rs @@ -2,7 +2,8 @@ pub mod common; pub use common::{features::*, setup::*, TestContext}; use pretty_assertions::assert_eq; -use sea_orm::entity::prelude::*; +use sea_orm::{entity::prelude::*, FromQueryResult}; +use serde_json::json; #[sea_orm_macros::test] #[cfg(any( @@ -199,5 +200,38 @@ pub async fn cursor_pagination(db: &DatabaseConnection) -> Result<(), DbErr> { vec![Model { id: 6 }, Model { id: 7 }] ); + // Fetch custom struct + + #[derive(FromQueryResult, Debug, PartialEq)] + struct Row { + id: i32, + } + + let mut cursor = cursor.into_model::(); + + assert_eq!( + cursor.first(2).all(db).await?, + vec![Row { id: 6 }, Row { id: 7 }] + ); + + assert_eq!( + cursor.first(3).all(db).await?, + vec![Row { id: 6 }, Row { id: 7 }] + ); + + // Fetch JSON value + + let mut cursor = cursor.into_json(); + + assert_eq!( + cursor.first(2).all(db).await?, + vec![json!({ "id": 6 }), json!({ "id": 7 })] + ); + + assert_eq!( + cursor.first(3).all(db).await?, + vec![json!({ "id": 6 }), json!({ "id": 7 })] + ); + Ok(()) } diff --git a/tests/transaction_tests.rs b/tests/transaction_tests.rs index 1059da8e..9faab72a 100644 --- a/tests/transaction_tests.rs +++ b/tests/transaction_tests.rs @@ -508,7 +508,9 @@ pub async fn transaction_nested() { assert_eq!(bakeries.len(), 4); if true { - Err(DbErr::Query("Force Rollback!".to_owned())) + Err(DbErr::Query(RuntimeErr::Internal( + "Force Rollback!".to_owned(), + ))) } else { Ok(()) } @@ -633,7 +635,9 @@ pub async fn transaction_nested() { assert_eq!(bakeries.len(), 7); if true { - Err(DbErr::Query("Force Rollback!".to_owned())) + Err(DbErr::Query(RuntimeErr::Internal( + "Force Rollback!".to_owned(), + ))) } else { Ok(()) } @@ -652,7 +656,9 @@ pub async fn transaction_nested() { assert_eq!(bakeries.len(), 6); if true { - Err(DbErr::Query("Force Rollback!".to_owned())) + Err(DbErr::Query(RuntimeErr::Internal( + "Force Rollback!".to_owned(), + ))) } else { Ok(()) } diff --git a/tests/type_tests.rs b/tests/type_tests.rs new file mode 100644 index 00000000..0c936130 --- /dev/null +++ b/tests/type_tests.rs @@ -0,0 +1,66 @@ +pub mod common; + +use sea_orm::{IntoActiveValue, TryFromU64, TryGetable, Value}; + +/* + +When supporting a new type in SeaORM we should implement the following traits for it: + - `IntoActiveValue`, given that it implemented `Into` already + - `TryGetable` + - `TryFromU64` + +Also, we need to update `impl FromQueryResult for JsonValue` at `src/query/json.rs` +to correctly serialize the type as `serde_json::Value`. + +*/ + +pub fn it_impl_into_active_value, V: Into>() {} + +pub fn it_impl_try_getable() {} + +pub fn it_impl_try_from_u64() {} + +#[allow(unused_macros)] +macro_rules! it_impl_traits { + ( $ty: ty ) => { + it_impl_into_active_value::<$ty, $ty>(); + it_impl_into_active_value::, Option<$ty>>(); + it_impl_into_active_value::>, Option<$ty>>(); + + it_impl_try_getable::<$ty>(); + it_impl_try_getable::>(); + + it_impl_try_from_u64::<$ty>(); + }; +} + +#[sea_orm_macros::test] +#[cfg(feature = "sqlx-dep")] +fn main() { + it_impl_traits!(i8); + it_impl_traits!(i16); + it_impl_traits!(i32); + it_impl_traits!(i64); + it_impl_traits!(u8); + it_impl_traits!(u16); + it_impl_traits!(u32); + it_impl_traits!(u64); + it_impl_traits!(bool); + it_impl_traits!(f32); + it_impl_traits!(f64); + it_impl_traits!(Vec); + it_impl_traits!(String); + it_impl_traits!(serde_json::Value); + it_impl_traits!(chrono::NaiveDate); + it_impl_traits!(chrono::NaiveTime); + it_impl_traits!(chrono::NaiveDateTime); + it_impl_traits!(chrono::DateTime); + it_impl_traits!(chrono::DateTime); + it_impl_traits!(chrono::DateTime); + it_impl_traits!(time::Date); + it_impl_traits!(time::Time); + it_impl_traits!(time::PrimitiveDateTime); + it_impl_traits!(time::OffsetDateTime); + it_impl_traits!(rust_decimal::Decimal); + it_impl_traits!(uuid::Uuid); +}