From 7569faa06518a7367b57c6f2638fd4d0d4a8a25c Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Thu, 5 Jun 2025 23:56:15 +0200 Subject: [PATCH] feat(examples): add PostgreSQL example with user relationship Adds an example demonstrating user, comment, and follower relationship including: - User management with profiles - Comments (not really useful, just for showcasing) - Follower/follozing relationships - Ineractive CLI interface with CRUD operations - Database migrations for the example schema --- .dir-locals.el | 14 - .gitignore | 4 +- Cargo.lock | 329 +++++++++++++++++- Cargo.toml | 6 +- README.md | 27 ++ .../users-comments-and-followers/Cargo.toml | 14 + .../src/cli/comments.rs | 129 +++++++ .../src/cli/followers.rs | 134 +++++++ .../src/cli/mod.rs | 40 +++ .../src/cli/users.rs | 113 ++++++ .../src/errors.rs | 15 + .../users-comments-and-followers/src/main.rs | 20 ++ .../src/models/comments.rs | 43 +++ .../src/models/followers.rs | 21 ++ .../src/models/mod.rs | 8 + .../src/models/profiles.rs | 66 ++++ .../src/models/users.rs | 128 +++++++ georm-macros/src/georm/defaultable_struct.rs | 30 +- georm-macros/src/georm/mod.rs | 11 +- ...5127_users-comments-and-followers.down.sql | 4 + ...215127_users-comments-and-followers.up.sql | 30 ++ tests/defaultable_struct.rs | 93 ++--- tests/simple_struct.rs | 5 +- 23 files changed, 1199 insertions(+), 85 deletions(-) delete mode 100644 .dir-locals.el create mode 100644 examples/postgres/users-comments-and-followers/Cargo.toml create mode 100644 examples/postgres/users-comments-and-followers/src/cli/comments.rs create mode 100644 examples/postgres/users-comments-and-followers/src/cli/followers.rs create mode 100644 examples/postgres/users-comments-and-followers/src/cli/mod.rs create mode 100644 examples/postgres/users-comments-and-followers/src/cli/users.rs create mode 100644 examples/postgres/users-comments-and-followers/src/errors.rs create mode 100644 examples/postgres/users-comments-and-followers/src/main.rs create mode 100644 examples/postgres/users-comments-and-followers/src/models/comments.rs create mode 100644 examples/postgres/users-comments-and-followers/src/models/followers.rs create mode 100644 examples/postgres/users-comments-and-followers/src/models/mod.rs create mode 100644 examples/postgres/users-comments-and-followers/src/models/profiles.rs create mode 100644 examples/postgres/users-comments-and-followers/src/models/users.rs create mode 100644 migrations/20250605215127_users-comments-and-followers.down.sql create mode 100644 migrations/20250605215127_users-comments-and-followers.up.sql diff --git a/.dir-locals.el b/.dir-locals.el deleted file mode 100644 index bb7a4a8..0000000 --- a/.dir-locals.el +++ /dev/null @@ -1,14 +0,0 @@ -;;; Directory Local Variables -*- no-byte-compile: t -*- -;;; For more information see (info "(emacs) Directory Variables") - -((rustic-mode . ((fill-column . 80))) - (sql-mode . ((eval . (progn - (setq-local lsp-sqls-connections - `(((driver . "postgresql") - (dataSourceName \, - (format "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable" - (getenv "DB_HOST") - (getenv "DB_PORT") - (getenv "DB_USER") - (getenv "DB_PASSWORD") - (getenv "DB_NAME"))))))))))) diff --git a/.gitignore b/.gitignore index 0d0bbb0..461c3f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ -.direnv .env /coverage /target +/.sqls +/examples/target # Devenv .devenv* @@ -17,6 +18,7 @@ devenv.local.nix *~ \#*\# .\#* +.dir-locals.el # Vim files *.swp diff --git a/Cargo.lock b/Cargo.lock index 7d48af9..e5c09cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,56 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "anstream" +version = "0.6.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.59.0", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -71,6 +121,12 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.9.1" @@ -107,6 +163,52 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "clap" +version = "4.5.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd60e63e9be68e5fb56422e397cf9baddded06dae1d2e523401542383bc72a9f" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89cc6392a1f72bbeb820d71f32108f61fdaf18bc526e1d23954168a67759ef51" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim 0.11.1", +] + +[[package]] +name = "clap_derive" +version = "4.5.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -161,6 +263,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -193,7 +320,7 @@ dependencies = [ "arrayvec", "proc-macro2", "quote", - "strsim", + "strsim 0.10.0", "syn", ] @@ -252,6 +379,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dyn-clone" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" + [[package]] name = "either" version = "1.15.0" @@ -387,6 +520,24 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -416,6 +567,18 @@ dependencies = [ "syn", ] +[[package]] +name = "georm-users-comments-and-followers" +version = "0.1.1" +dependencies = [ + "clap", + "georm", + "inquire", + "sqlx", + "thiserror", + "tokio", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -447,9 +610,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "hashbrown" -version = "0.15.3" +version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" dependencies = [ "allocator-api2", "equivalent", @@ -633,6 +796,29 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inquire" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" +dependencies = [ + "bitflags 2.9.1", + "crossterm", + "dyn-clone", + "fuzzy-matcher", + "fxhash", + "newline-converter", + "once_cell", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + [[package]] name = "itoa" version = "1.0.15" @@ -717,6 +903,18 @@ dependencies = [ "adler2", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.0.4" @@ -728,6 +926,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "newline-converter" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "num-bigint-dig" version = "0.8.4" @@ -790,6 +997,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + [[package]] name = "parking" version = "2.2.1" @@ -990,7 +1203,7 @@ version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af" dependencies = [ - "bitflags", + "bitflags 2.9.1", ] [[package]] @@ -1097,6 +1310,36 @@ dependencies = [ "digest", ] +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +dependencies = [ + "libc", + "mio 0.8.11", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +dependencies = [ + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -1245,7 +1488,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64", - "bitflags", + "bitflags 2.9.1", "byteorder", "bytes", "crc", @@ -1286,7 +1529,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64", - "bitflags", + "bitflags 2.9.1", "byteorder", "crc", "dotenvy", @@ -1361,6 +1604,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -1409,6 +1658,16 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -1443,12 +1702,26 @@ dependencies = [ "backtrace", "bytes", "libc", - "mio", + "mio 1.0.4", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.52.0", ] +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio-stream" version = "0.1.17" @@ -1542,6 +1815,18 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "url" version = "2.5.4" @@ -1559,6 +1844,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "vcpkg" version = "0.2.15" @@ -1602,6 +1893,28 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-sys" version = "0.48.0" @@ -1765,7 +2078,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags", + "bitflags 2.9.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8a07013..7d3caf7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,9 @@ [workspace] -members = [".", "georm-macros"] +members = [ + ".", + "georm-macros", + "examples/postgres/*" +] [workspace.package] version = "0.1.1" diff --git a/README.md b/README.md index 4795794..d5e44a9 100644 --- a/README.md +++ b/README.md @@ -491,6 +491,32 @@ Georm is designed for zero runtime overhead: - **Minimal allocations**: Efficient use of owned vs borrowed data - **SQLx integration**: Leverages SQLx's optimized PostgreSQL driver +## Examples + +### Comprehensive Example + +For a complete, real-world example showcasing user management, comments, and follower relationships, see the comprehensive example in `examples/postgres/users-comments-and-followers/`. This example demonstrates: + +- User registration and profile management +- Comment system with user associations +- Follower/following relationships (many-to-many) +- Interactive CLI interface with CRUD operations +- Database migrations and schema setup + +To run the example: + +```bash +# Set up your database +export DATABASE_URL="postgres://username:password@localhost/georm_example" + +# Run migrations +cargo sqlx migrate run + +# Run the example +cd examples/postgres/users-comments-and-followers +cargo run help # For a list of all available actions +``` + ## Comparison | Feature | Georm | SeaORM | Diesel | @@ -509,6 +535,7 @@ Georm is designed for zero runtime overhead: ### Medium Priority - **Multi-Database Support**: MySQL and SQLite support with feature flags +- **Field-Based Queries**: Generate `find_by_{field_name}` methods that return `Vec` for regular fields or `Option` for unique fields - **Relationship Optimization**: Eager loading and N+1 query prevention - **Composite Primary Keys**: Multi-field primary key support - **Soft Delete**: Optional soft delete with `deleted_at` timestamps diff --git a/examples/postgres/users-comments-and-followers/Cargo.toml b/examples/postgres/users-comments-and-followers/Cargo.toml new file mode 100644 index 0000000..b2f183b --- /dev/null +++ b/examples/postgres/users-comments-and-followers/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "georm-users-comments-and-followers" +workspace = "../../../" +publish = false +version.workspace = true +edition.workspace = true + +[dependencies] +georm = { path = "../../.." } +sqlx = { workspace = true } +clap = { version = "4.4", features = ["derive"] } +inquire = "0.7.5" +thiserror = "2.0.11" +tokio = { version = "1.43.0", features = ["full"] } \ No newline at end of file diff --git a/examples/postgres/users-comments-and-followers/src/cli/comments.rs b/examples/postgres/users-comments-and-followers/src/cli/comments.rs new file mode 100644 index 0000000..0f7bad9 --- /dev/null +++ b/examples/postgres/users-comments-and-followers/src/cli/comments.rs @@ -0,0 +1,129 @@ +use super::{Executable, Result}; +use crate::{ + errors::UserInputError, + models::{Comment, CommentDefault, User}, +}; +use clap::{Args, Subcommand}; +use georm::{Defaultable, Georm}; +use std::collections::HashMap; + +#[derive(Debug, Args, Clone)] +pub struct CommentArgs { + #[command(subcommand)] + pub command: CommentCommand, +} + +impl Executable for CommentArgs { + async fn execute(&self, pool: &sqlx::PgPool) -> Result { + self.command.execute(pool).await + } +} + +#[derive(Debug, Clone, Subcommand)] +pub enum CommentCommand { + Create { + text: Option, + username: Option, + }, + Remove { + id: Option, + }, + RemoveFromUser { + username: Option, + }, + ListFromUser { + username: Option, + }, + List, +} + +impl Executable for CommentCommand { + async fn execute(&self, pool: &sqlx::PgPool) -> Result { + match self { + CommentCommand::Create { text, username } => { + create_comment(username.clone(), text.clone(), pool).await + } + CommentCommand::Remove { id } => remove_comment(*id, pool).await, + CommentCommand::RemoveFromUser { username } => { + remove_user_comment(username.clone(), pool).await + } + CommentCommand::ListFromUser { username } => { + list_user_comments(username.clone(), pool).await + } + CommentCommand::List => list_comments(pool).await, + } + } +} + +async fn create_comment( + username: Option, + text: Option, + pool: &sqlx::PgPool, +) -> Result { + let prompt = "Who is creating the comment?"; + let user = User::get_user_by_username_or_select(username.as_deref(), prompt, pool).await?; + let content = match text { + Some(text) => text, + None => inquire::Text::new("Content of the comment:") + .prompt() + .map_err(UserInputError::InquireError)?, + }; + let comment = CommentDefault { + author_id: user.id, + content, + id: None, + }; + let comment = comment.create(pool).await?; + println!("Successfuly created comment:\n{comment}"); + Ok(()) +} + +async fn remove_comment(id: Option, pool: &sqlx::PgPool) -> Result { + let prompt = "Select the comment to remove:"; + let comment = match id { + Some(id) => Comment::find(pool, &id) + .await + .map_err(UserInputError::DatabaseError)? + .ok_or(UserInputError::CommentDoesNotExist)?, + None => Comment::select_comment(prompt, pool).await?, + }; + comment.delete(pool).await?; + Ok(()) +} + +async fn remove_user_comment(username: Option, pool: &sqlx::PgPool) -> Result { + let prompt = "Select user whose comment you want to delete:"; + let user = User::get_user_by_username_or_select(username.as_deref(), prompt, pool).await?; + let comments: HashMap = user + .get_comments(pool) + .await? + .into_iter() + .map(|comment| (comment.content.clone(), comment)) + .collect(); + let selected_comment_content = + inquire::Select::new(prompt, comments.clone().into_keys().collect()) + .prompt() + .map_err(UserInputError::InquireError)?; + let comment: &Comment = comments.get(&selected_comment_content).unwrap(); + comment.delete(pool).await?; + Ok(()) +} + +async fn list_user_comments(username: Option, pool: &sqlx::PgPool) -> Result { + let prompt = "User whose comment you want to list:"; + let user = User::get_user_by_username_or_select(username.as_deref(), prompt, pool).await?; + println!("List of comments from user:\n"); + for comment in user.get_comments(pool).await? { + println!("{comment}\n"); + } + Ok(()) +} + +async fn list_comments(pool: &sqlx::PgPool) -> Result { + let comments = Comment::find_all(pool).await?; + println!("List of all comments:\n"); + for comment in comments { + println!("{comment}\n") + } + Ok(()) +} diff --git a/examples/postgres/users-comments-and-followers/src/cli/followers.rs b/examples/postgres/users-comments-and-followers/src/cli/followers.rs new file mode 100644 index 0000000..d23ea19 --- /dev/null +++ b/examples/postgres/users-comments-and-followers/src/cli/followers.rs @@ -0,0 +1,134 @@ +use super::{Executable, Result}; +use crate::models::{FollowerDefault, User}; +use clap::{Args, Subcommand}; +use georm::Defaultable; +use std::collections::HashMap; + +#[derive(Debug, Args, Clone)] +pub struct FollowersArgs { + #[command(subcommand)] + pub command: FollowersCommand, +} + +impl Executable for FollowersArgs { + async fn execute(&self, pool: &sqlx::PgPool) -> Result { + self.command.execute(pool).await + } +} + +#[derive(Debug, Clone, Subcommand)] +pub enum FollowersCommand { + Follow { + follower: Option, + followed: Option, + }, + Unfollow { + follower: Option, + }, + ListFollowers { + user: Option, + }, + ListFollowed { + user: Option, + }, +} + +impl Executable for FollowersCommand { + async fn execute(&self, pool: &sqlx::PgPool) -> Result { + match self { + FollowersCommand::Follow { follower, followed } => { + follow_user(follower.clone(), followed.clone(), pool).await + } + FollowersCommand::Unfollow { follower } => unfollow_user(follower.clone(), pool).await, + FollowersCommand::ListFollowers { user } => { + list_user_followers(user.clone(), pool).await + } + FollowersCommand::ListFollowed { user } => list_user_followed(user.clone(), pool).await, + } + } +} + +async fn follow_user( + follower: Option, + followed: Option, + pool: &sqlx::PgPool, +) -> Result { + let follower = User::get_user_by_username_or_select( + follower.as_deref(), + "Select who will be following someone:", + pool, + ) + .await?; + let followed = User::get_user_by_username_or_select( + followed.as_deref(), + "Select who will be followed:", + pool, + ) + .await?; + let follow = FollowerDefault { + id: None, + follower: follower.id, + followed: followed.id, + }; + follow.create(pool).await?; + println!("User {follower} now follows {followed}"); + Ok(()) +} + +async fn unfollow_user(follower: Option, pool: &sqlx::PgPool) -> Result { + let follower = + User::get_user_by_username_or_select(follower.as_deref(), "Select who is following", pool) + .await?; + let followed_list: HashMap = follower + .get_followed(pool) + .await? + .iter() + .map(|person| (person.username.clone(), person.clone())) + .collect(); + let followed = inquire::Select::new( + "Who to unfollow?", + followed_list.clone().into_keys().collect(), + ) + .prompt() + .unwrap(); + let followed = followed_list.get(&followed).unwrap(); + sqlx::query!( + "DELETE FROM Followers WHERE follower = $1 AND followed = $2", + follower.id, + followed.id + ) + .execute(pool) + .await?; + println!("User {follower} unfollowed {followed}"); + Ok(()) +} + +async fn list_user_followers(user: Option, pool: &sqlx::PgPool) -> Result { + let user = User::get_user_by_username_or_select( + user.as_deref(), + "Whose followers do you want to display?", + pool, + ) + .await?; + println!("List of followers of {user}:\n"); + user.get_followers(pool) + .await? + .iter() + .for_each(|person| println!("{person}")); + Ok(()) +} + +async fn list_user_followed(user: Option, pool: &sqlx::PgPool) -> Result { + let user = User::get_user_by_username_or_select( + user.as_deref(), + "Whose follows do you want to display?", + pool, + ) + .await?; + println!("List of people followed by {user}:\n"); + user.get_followed(pool) + .await? + .iter() + .for_each(|person| println!("{person}")); + Ok(()) +} diff --git a/examples/postgres/users-comments-and-followers/src/cli/mod.rs b/examples/postgres/users-comments-and-followers/src/cli/mod.rs new file mode 100644 index 0000000..11dc9c3 --- /dev/null +++ b/examples/postgres/users-comments-and-followers/src/cli/mod.rs @@ -0,0 +1,40 @@ +use clap::{Parser, Subcommand}; + +mod comments; +mod followers; +mod users; + +type Result = crate::Result<()>; + +pub trait Executable { + async fn execute(&self, pool: &sqlx::PgPool) -> Result; +} + +#[derive(Debug, Clone, Parser)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +impl Executable for Cli { + async fn execute(&self, pool: &sqlx::PgPool) -> Result { + self.command.execute(pool).await + } +} + +#[derive(Debug, Clone, Subcommand)] +pub enum Commands { + Users(users::UserArgs), + Followers(followers::FollowersArgs), + Comments(comments::CommentArgs), +} + +impl Executable for Commands { + async fn execute(&self, pool: &sqlx::PgPool) -> Result { + match self { + Commands::Users(user_args) => user_args.execute(pool).await, + Commands::Followers(followers_args) => followers_args.execute(pool).await, + Commands::Comments(comment_args) => comment_args.execute(pool).await, + } + } +} diff --git a/examples/postgres/users-comments-and-followers/src/cli/users.rs b/examples/postgres/users-comments-and-followers/src/cli/users.rs new file mode 100644 index 0000000..347cbe3 --- /dev/null +++ b/examples/postgres/users-comments-and-followers/src/cli/users.rs @@ -0,0 +1,113 @@ +use super::{Executable, Result}; +use crate::{errors::UserInputError, models::User}; +use clap::{Args, Subcommand}; +use georm::Georm; +use inquire::{max_length, min_length, required}; + +#[derive(Debug, Args, Clone)] +pub struct UserArgs { + #[command(subcommand)] + pub command: UserCommand, +} + +impl Executable for UserArgs { + async fn execute(&self, pool: &sqlx::PgPool) -> Result { + self.command.execute(pool).await + } +} + +#[derive(Debug, Clone, Subcommand)] +pub enum UserCommand { + Add { username: Option }, + Remove { id: Option }, + UpdateProfile { id: Option }, + List, +} + +impl Executable for UserCommand { + async fn execute(&self, pool: &sqlx::PgPool) -> Result { + match self { + UserCommand::Add { username } => add_user(username.clone(), pool).await, + UserCommand::Remove { id } => remove_user(*id, pool).await, + UserCommand::UpdateProfile { id } => update_profile(*id, pool).await, + UserCommand::List => list_all(pool).await, + } + } +} + +async fn add_user(username: Option, pool: &sqlx::PgPool) -> Result { + let username = match username { + Some(username) => username, + None => inquire::Text::new("Enter a username:") + .prompt() + .map_err(|_| UserInputError::InputRequired)?, + }; + let user = User::try_new(&username, pool).await?; + println!("The user {user} has been created!"); + Ok(()) +} + +async fn remove_user(id: Option, pool: &sqlx::PgPool) -> Result { + let user = User::remove_interactive(id, pool).await?; + println!("Removed user {user} from database"); + Ok(()) +} + +async fn update_profile(id: Option, pool: &sqlx::PgPool) -> Result { + let (user, mut profile) = User::update_profile(id, pool).await?; + let update_display_name = inquire::Confirm::new( + format!( + "Your current display name is \"{}\", do you want to update it?", + profile.get_display_name() + ) + .as_str(), + ) + .with_default(false) + .prompt() + .map_err(UserInputError::InquireError)?; + let display_name = if update_display_name { + Some( + inquire::Text::new("New display name:") + .with_help_message("Your display name should not exceed 100 characters") + .with_validator(min_length!(3)) + .with_validator(max_length!(100)) + .with_validator(required!()) + .prompt() + .map_err(UserInputError::InquireError)?, + ) + } else { + Some(profile.get_display_name()) + }; + let update_bio = inquire::Confirm::new( + format!( + "Your current bio is:\n===\n{}\n===\nDo you want to update it?", + profile.get_bio() + ) + .as_str(), + ) + .with_default(false) + .prompt() + .map_err(UserInputError::InquireError)?; + let bio = if update_bio { + Some( + inquire::Text::new("New bio:") + .with_validator(min_length!(0)) + .prompt() + .map_err(UserInputError::InquireError)?, + ) + } else { + Some(profile.get_bio()) + }; + let profile = profile.update_interactive(display_name, bio, pool).await?; + println!("Profile of {user} updated:\n{profile}"); + Ok(()) +} + +async fn list_all(pool: &sqlx::PgPool) -> Result { + let users = User::find_all(pool).await?; + println!("List of users:\n"); + for user in users { + println!("{user}"); + } + Ok(()) +} diff --git a/examples/postgres/users-comments-and-followers/src/errors.rs b/examples/postgres/users-comments-and-followers/src/errors.rs new file mode 100644 index 0000000..ab71bf6 --- /dev/null +++ b/examples/postgres/users-comments-and-followers/src/errors.rs @@ -0,0 +1,15 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum UserInputError { + #[error("Input required")] + InputRequired, + #[error("User ID does not exist")] + UserDoesNotExist, + #[error("Comment does not exist")] + CommentDoesNotExist, + #[error("Unexpected error, please try again")] + InquireError(#[from] inquire::error::InquireError), + #[error("Error from database: {0}")] + DatabaseError(#[from] sqlx::Error), +} diff --git a/examples/postgres/users-comments-and-followers/src/main.rs b/examples/postgres/users-comments-and-followers/src/main.rs new file mode 100644 index 0000000..ef8cf5a --- /dev/null +++ b/examples/postgres/users-comments-and-followers/src/main.rs @@ -0,0 +1,20 @@ +mod cli; +mod errors; +mod models; + +use clap::Parser; +use cli::{Cli, Executable}; + +type Result = std::result::Result; + +#[tokio::main] +async fn main() { + let args = Cli::parse(); + let url = std::env::var("DATABASE_URL").expect("Environment variable DATABASE_URL must be set"); + let pool = + sqlx::PgPool::connect_lazy(url.as_str()).expect("Failed to create database connection"); + match args.command.execute(&pool).await { + Ok(_) => {} + Err(e) => eprintln!("Error: {e}"), + } +} diff --git a/examples/postgres/users-comments-and-followers/src/models/comments.rs b/examples/postgres/users-comments-and-followers/src/models/comments.rs new file mode 100644 index 0000000..3965205 --- /dev/null +++ b/examples/postgres/users-comments-and-followers/src/models/comments.rs @@ -0,0 +1,43 @@ +use super::User; +use crate::{Result, errors::UserInputError}; +use georm::Georm; +use std::collections::HashMap; + +#[derive(Debug, Georm, Clone)] +#[georm(table = "Comments")] +pub struct Comment { + #[georm(id, defaultable)] + pub id: i32, + #[georm(relation = { + entity = User, + table = "Users", + name = "author" + })] + pub author_id: i32, + pub content: String, +} + +impl Comment { + pub async fn select_comment(prompt: &str, pool: &sqlx::PgPool) -> Result { + let comments: HashMap = Self::find_all(pool) + .await? + .into_iter() + .map(|comment| (comment.content.clone(), comment)) + .collect(); + let comment_content = inquire::Select::new(prompt, comments.clone().into_keys().collect()) + .prompt() + .map_err(UserInputError::InquireError)?; + let comment: &Self = comments.get(&comment_content).unwrap(); + Ok(comment.clone()) + } +} + +impl std::fmt::Display for Comment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Comment:\nID:\t{}\nAuthor:\t{}\nContent:\t{}", + self.id, self.author_id, self.content + ) + } +} diff --git a/examples/postgres/users-comments-and-followers/src/models/followers.rs b/examples/postgres/users-comments-and-followers/src/models/followers.rs new file mode 100644 index 0000000..2b5bbbd --- /dev/null +++ b/examples/postgres/users-comments-and-followers/src/models/followers.rs @@ -0,0 +1,21 @@ +use super::User; +use georm::Georm; + +#[derive(Debug, Clone, Georm)] +#[georm(table = "Followers")] +pub struct Follower { + #[georm(id, defaultable)] + pub id: i32, + #[georm(relation = { + entity = User, + table = "Users", + name = "followed" + })] + pub followed: i32, + #[georm(relation = { + entity = User, + table = "Users", + name = "follower" + })] + pub follower: i32, +} diff --git a/examples/postgres/users-comments-and-followers/src/models/mod.rs b/examples/postgres/users-comments-and-followers/src/models/mod.rs new file mode 100644 index 0000000..fc5b74c --- /dev/null +++ b/examples/postgres/users-comments-and-followers/src/models/mod.rs @@ -0,0 +1,8 @@ +mod users; +pub use users::*; +mod profiles; +pub use profiles::*; +mod comments; +pub use comments::*; +mod followers; +pub use followers::*; diff --git a/examples/postgres/users-comments-and-followers/src/models/profiles.rs b/examples/postgres/users-comments-and-followers/src/models/profiles.rs new file mode 100644 index 0000000..a0562aa --- /dev/null +++ b/examples/postgres/users-comments-and-followers/src/models/profiles.rs @@ -0,0 +1,66 @@ +use super::User; +use crate::{Result, errors::UserInputError}; +use georm::{Defaultable, Georm}; + +#[derive(Debug, Georm, Default)] +#[georm(table = "Profiles")] +pub struct Profile { + #[georm(id, defaultable)] + pub id: i32, + #[georm(relation = { + entity = User, + table = "Users", + name = "user", + nullable = false + })] + pub user_id: i32, + pub bio: Option, + pub display_name: Option, +} + +impl std::fmt::Display for Profile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Display Name:\t{}\nBiography:\n{}\n", + self.get_display_name(), + self.get_bio() + ) + } +} + +impl Profile { + pub fn get_display_name(&self) -> String { + self.display_name.clone().unwrap_or_default() + } + + pub fn get_bio(&self) -> String { + self.bio.clone().unwrap_or_default() + } + + pub async fn try_new(user_id: i32, pool: &sqlx::PgPool) -> Result { + let profile = ProfileDefault { + user_id, + id: None, + bio: None, + display_name: None, + }; + profile + .create(pool) + .await + .map_err(UserInputError::DatabaseError) + } + + pub async fn update_interactive( + &mut self, + display_name: Option, + bio: Option, + pool: &sqlx::PgPool, + ) -> Result { + self.display_name = display_name; + self.bio = bio; + self.update(pool) + .await + .map_err(UserInputError::DatabaseError) + } +} diff --git a/examples/postgres/users-comments-and-followers/src/models/users.rs b/examples/postgres/users-comments-and-followers/src/models/users.rs new file mode 100644 index 0000000..5c695af --- /dev/null +++ b/examples/postgres/users-comments-and-followers/src/models/users.rs @@ -0,0 +1,128 @@ +use std::collections::HashMap; + +use crate::{Result, errors::UserInputError}; +use georm::{Defaultable, Georm}; + +use super::{Comment, Profile}; + +#[derive(Debug, Georm, Clone)] +#[georm( + table = "Users", + one_to_one = [{ + name = "profile", remote_id = "user_id", table = "Profiles", entity = Profile + }], + one_to_many = [{ + name = "comments", remote_id = "author_id", table = "Comments", entity = Comment + }], + many_to_many = [{ + name = "followers", + table = "Users", + entity = User, + link = { table = "Followers", from = "followed", to = "follower" } + }, +{ + name = "followed", + table = "Users", + entity = User, + link = { table = "Followers", from = "follower", to = "followed" } + } + ] +)] +pub struct User { + #[georm(id, defaultable)] + pub id: i32, + pub username: String, +} + +impl std::fmt::Display for User { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} (ID: {})", self.username, self.id) + } +} + +impl From<&str> for UserDefault { + fn from(value: &str) -> Self { + Self { + id: None, + username: value.to_string(), + } + } +} + +impl User { + async fn select_user(prompt: &str, pool: &sqlx::PgPool) -> Result { + let users: HashMap = Self::find_all(pool) + .await? + .into_iter() + .map(|user| (user.username.clone(), user)) + .collect(); + let username = inquire::Select::new(prompt, users.clone().into_keys().collect()) + .prompt() + .map_err(UserInputError::InquireError)?; + let user: &Self = users.get(&username).unwrap(); + Ok(user.clone()) + } + + pub async fn get_user_by_id_or_select( + id: Option, + prompt: &str, + pool: &sqlx::PgPool, + ) -> Result { + let user = match id { + Some(id) => Self::find(pool, &id) + .await? + .ok_or(UserInputError::UserDoesNotExist)?, + None => Self::select_user(prompt, pool).await?, + }; + Ok(user) + } + + pub async fn get_user_by_username_or_select( + username: Option<&str>, + prompt: &str, + pool: &sqlx::PgPool, + ) -> Result { + let user = match username { + Some(username) => Self::find_by_username(username, pool) + .await? + .ok_or(UserInputError::UserDoesNotExist)?, + None => Self::select_user(prompt, pool).await?, + }; + Ok(user) + } + + pub async fn find_by_username(username: &str, pool: &sqlx::PgPool) -> Result> { + sqlx::query_as!( + Self, + "SELECT * FROM Users u WHERE u.username = $1", + username + ) + .fetch_optional(pool) + .await + .map_err(UserInputError::DatabaseError) + } + + pub async fn try_new(username: &str, pool: &sqlx::PgPool) -> Result { + let user = UserDefault::from(username); + user.create(pool) + .await + .map_err(UserInputError::DatabaseError) + } + + pub async fn remove_interactive(id: Option, pool: &sqlx::PgPool) -> Result { + let prompt = "Select a user to delete:"; + let user = Self::get_user_by_id_or_select(id, prompt, pool).await?; + let _ = user.clone().delete(pool).await?; + Ok(user) + } + + pub async fn update_profile(id: Option, pool: &sqlx::PgPool) -> Result<(User, Profile)> { + let prompt = "Select the user whose profile you want to update"; + let user = Self::get_user_by_id_or_select(id, prompt, pool).await?; + let profile = match user.get_profile(pool).await? { + Some(profile) => profile, + None => Profile::try_new(user.id, pool).await?, + }; + Ok((user, profile)) + } +} diff --git a/georm-macros/src/georm/defaultable_struct.rs b/georm-macros/src/georm/defaultable_struct.rs index e4cc7f6..9beedac 100644 --- a/georm-macros/src/georm/defaultable_struct.rs +++ b/georm-macros/src/georm/defaultable_struct.rs @@ -50,23 +50,27 @@ fn generate_defaultable_trait_impl( let defaultable_fields: Vec<_> = fields.iter().filter(|f| f.defaultable).collect(); // Build static parts for non-defaultable fields - let static_field_names: Vec = non_defaultable_fields.iter().map(|f| f.ident.to_string()).collect(); - let static_field_idents: Vec<&syn::Ident> = non_defaultable_fields.iter().map(|f| &f.ident).collect(); + let static_field_names: Vec = non_defaultable_fields + .iter() + .map(|f| f.ident.to_string()) + .collect(); + let static_field_idents: Vec<&syn::Ident> = + non_defaultable_fields.iter().map(|f| &f.ident).collect(); // Generate field checks for defaultable fields let mut field_checks = Vec::new(); let mut bind_checks = Vec::new(); - + for field in &defaultable_fields { let field_name = field.ident.to_string(); let field_ident = &field.ident; - + field_checks.push(quote! { if self.#field_ident.is_some() { dynamic_fields.push(#field_name); } }); - + bind_checks.push(quote! { if let Some(ref value) = self.#field_ident { query_builder = query_builder.bind(value); @@ -78,31 +82,31 @@ fn generate_defaultable_trait_impl( impl ::georm::Defaultable<#id_type, #struct_name> for #defaultable_struct_name { async fn create(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<#struct_name> { let mut dynamic_fields = Vec::new(); - + #(#field_checks)* - + let mut all_fields = vec![#(#static_field_names),*]; all_fields.extend(dynamic_fields); - + let placeholders: Vec = (1..=all_fields.len()) .map(|i| format!("${}", i)) .collect(); - + let query = format!( "INSERT INTO {} ({}) VALUES ({}) RETURNING *", #table, all_fields.join(", "), placeholders.join(", ") ); - + let mut query_builder = ::sqlx::query_as::<_, #struct_name>(&query); - + // Bind non-defaultable fields first #(query_builder = query_builder.bind(&self.#static_field_idents);)* - + // Then bind defaultable fields that have values #(#bind_checks)* - + query_builder.fetch_one(pool).await } } diff --git a/georm-macros/src/georm/mod.rs b/georm-macros/src/georm/mod.rs index 3dab110..805b71d 100644 --- a/georm-macros/src/georm/mod.rs +++ b/georm-macros/src/georm/mod.rs @@ -35,10 +35,13 @@ fn extract_georm_field_attrs( _ => { let id1 = identifiers.first().unwrap(); let id2 = identifiers.get(1).unwrap(); - Err(syn::Error::new_spanned(id2.field.clone(), format!( - "Field {} cannot be an identifier, {} already is one.\nOnly one identifier is supported.", - id1.ident, id2.ident - ))) + Err(syn::Error::new_spanned( + id2.field.clone(), + format!( + "Field {} cannot be an identifier, {} already is one.\nOnly one identifier is supported.", + id1.ident, id2.ident + ), + )) } } } diff --git a/migrations/20250605215127_users-comments-and-followers.down.sql b/migrations/20250605215127_users-comments-and-followers.down.sql new file mode 100644 index 0000000..4a21c89 --- /dev/null +++ b/migrations/20250605215127_users-comments-and-followers.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS Followers; +DROP TABLE IF EXISTS Comments; +DROP TABLE IF EXISTS Profiles; +DROP TABLE IF EXISTS Users; diff --git a/migrations/20250605215127_users-comments-and-followers.up.sql b/migrations/20250605215127_users-comments-and-followers.up.sql new file mode 100644 index 0000000..b91f8f9 --- /dev/null +++ b/migrations/20250605215127_users-comments-and-followers.up.sql @@ -0,0 +1,30 @@ +-- Add migration script here +CREATE TABLE Users ( + id SERIAL PRIMARY KEY, + username VARCHAR(100) UNIQUE NOT NULL +); + +CREATE TABLE Profiles ( + id SERIAL PRIMARY KEY, + user_id INT UNIQUE NOT NULL, + bio TEXT, + display_name VARCHAR(100), + FOREIGN KEY (user_id) REFERENCES Users(id) +); + +CREATE TABLE Comments ( + id SERIAL PRIMARY KEY, + author_id INT NOT NULL, + content TEXT NOT NULL, + FOREIGN KEY (author_id) REFERENCES Users(id) +); + +CREATE TABLE Followers ( + id SERIAL PRIMARY KEY, + followed INT NOT NULL, + follower INT NOT NULL, + FOREIGN KEY (followed) REFERENCES Users(id) ON DELETE CASCADE, + FOREIGN KEY (follower) REFERENCES Users(id) ON DELETE CASCADE, + CHECK (followed != follower), + UNIQUE (followed, follower) +); diff --git a/tests/defaultable_struct.rs b/tests/defaultable_struct.rs index 4dc9076..8585f57 100644 --- a/tests/defaultable_struct.rs +++ b/tests/defaultable_struct.rs @@ -169,7 +169,7 @@ mod defaultable_tests { Ok(created) => { assert!(created.id > 0); // If successful, name should have some default value - }, + } Err(e) => { // Expected if no database default for name column assert!(e.to_string().contains("null") || e.to_string().contains("NOT NULL")); @@ -181,9 +181,9 @@ mod defaultable_tests { async fn test_multiple_defaultable_fields_mixed(pool: PgPool) { // Test with some defaultable fields set and others None let multi_default = MultiDefaultableDefault { - id: None, // Let database generate + id: None, // Let database generate name: Some("Explicit Name".to_string()), // Explicit value - biography_id: Some(1), // Reference existing biography + biography_id: Some(1), // Reference existing biography }; let created = multi_default.create(&pool).await.unwrap(); @@ -233,7 +233,11 @@ mod defaultable_tests { let error = result2.unwrap_err(); let error_str = error.to_string(); - assert!(error_str.contains("duplicate") || error_str.contains("unique") || error_str.contains("UNIQUE")); + assert!( + error_str.contains("duplicate") + || error_str.contains("unique") + || error_str.contains("UNIQUE") + ); } #[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))] @@ -254,11 +258,15 @@ mod defaultable_tests { // No foreign key constraint - this is valid behavior assert!(created.id > 0); assert_eq!(created.biography_id, Some(99999)); - }, + } Err(e) => { // Foreign key constraint violation let error_str = e.to_string(); - assert!(error_str.contains("foreign") || error_str.contains("constraint") || error_str.contains("violates")); + assert!( + error_str.contains("foreign") + || error_str.contains("constraint") + || error_str.contains("violates") + ); } } } @@ -281,7 +289,7 @@ mod defaultable_tests { Ok(created) => { assert!(created.id > 0); assert_eq!(created.name.len(), 10000); - }, + } Err(e) => { // Some kind of database limit hit assert!(!e.to_string().is_empty()); @@ -291,7 +299,6 @@ mod defaultable_tests { mod sql_validation_tests { use super::*; - #[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))] async fn test_sql_generation_no_defaultable_fields(pool: PgPool) { @@ -306,12 +313,12 @@ mod defaultable_tests { // Since we can't directly inspect the generated SQL from the macro, // we test the behavior indirectly by ensuring all fields are included let created = author_default.create(&pool).await.unwrap(); - + // Verify all fields were properly inserted assert_eq!(created.id, 100); assert_eq!(created.name, "Test Name"); assert_eq!(created.biography_id, Some(1)); - + // Verify the record exists in database with all expected values let found: TestAuthor = sqlx::query_as!( TestAuthor, @@ -321,7 +328,7 @@ mod defaultable_tests { .fetch_one(&pool) .await .unwrap(); - + assert_eq!(found.id, 100); assert_eq!(found.name, "Test Name"); assert_eq!(found.biography_id, Some(1)); @@ -337,12 +344,12 @@ mod defaultable_tests { }; let created = author_default.create(&pool).await.unwrap(); - + // ID should be auto-generated (not explicitly set) assert!(created.id > 0); assert_eq!(created.name, "Auto ID Test"); assert_eq!(created.biography_id, None); - + // Verify the generated ID is actually from database auto-increment // by checking it's different from any manually set values assert_ne!(created.id, 100); // Different from previous test @@ -352,13 +359,13 @@ mod defaultable_tests { async fn test_sql_generation_mixed_defaultable_fields(pool: PgPool) { // Test SQL with multiple defaultable fields where some are None let multi_default = MultiDefaultableDefault { - id: None, // Should be excluded + id: None, // Should be excluded name: Some("Explicit Name".to_string()), // Should be included - biography_id: Some(1), // Should be included + biography_id: Some(1), // Should be included }; let created = multi_default.create(&pool).await.unwrap(); - + // Verify the mixed field inclusion worked correctly assert!(created.id > 0); // Auto-generated assert_eq!(created.name, "Explicit Name"); // Explicitly set @@ -369,21 +376,21 @@ mod defaultable_tests { async fn test_placeholder_ordering_consistency(pool: PgPool) { // Test that placeholders are ordered correctly when fields are dynamically included // Create multiple records with different field combinations - + // First: only non-defaultable fields let record1 = MultiDefaultableDefault { id: None, name: None, biography_id: Some(1), }; - + // Second: all fields explicit let record2 = MultiDefaultableDefault { id: Some(201), name: Some("Full Record".to_string()), biography_id: Some(1), }; - + // Third: mixed combination let record3 = MultiDefaultableDefault { id: None, @@ -395,23 +402,23 @@ mod defaultable_tests { let result1 = record1.create(&pool).await; let result2 = record2.create(&pool).await; let result3 = record3.create(&pool).await; - + // Handle record1 based on whether name has a database default match result1 { Ok(created1) => { assert!(created1.id > 0); assert_eq!(created1.biography_id, Some(1)); - }, + } Err(_) => { // Expected if name field has no database default } } - + let created2 = result2.unwrap(); assert_eq!(created2.id, 201); assert_eq!(created2.name, "Full Record"); assert_eq!(created2.biography_id, Some(1)); - + let created3 = result3.unwrap(); assert!(created3.id > 0); assert_eq!(created3.name, "Mixed Record"); @@ -422,32 +429,32 @@ mod defaultable_tests { async fn test_field_inclusion_logic(pool: PgPool) { // Test that the field inclusion logic works correctly // by creating records that should result in different SQL queries - + let minimal = TestAuthorDefault { id: None, name: "Minimal".to_string(), biography_id: None, }; - + let maximal = TestAuthorDefault { id: Some(300), name: "Maximal".to_string(), biography_id: Some(1), }; - + let created_minimal = minimal.create(&pool).await.unwrap(); let created_maximal = maximal.create(&pool).await.unwrap(); - + // Minimal should have auto-generated ID, explicit name, NULL biography_id assert!(created_minimal.id > 0); assert_eq!(created_minimal.name, "Minimal"); assert_eq!(created_minimal.biography_id, None); - + // Maximal should have all explicit values assert_eq!(created_maximal.id, 300); assert_eq!(created_maximal.name, "Maximal"); assert_eq!(created_maximal.biography_id, Some(1)); - + // Verify they are different records assert_ne!(created_minimal.id, created_maximal.id); } @@ -460,14 +467,14 @@ mod defaultable_tests { name: "Return Test".to_string(), biography_id: None, }; - + let created = author_default.create(&pool).await.unwrap(); - + // Verify RETURNING clause populated all fields correctly assert!(created.id > 0); // Database-generated ID returned assert_eq!(created.name, "Return Test"); // Explicit value returned assert_eq!(created.biography_id, None); // NULL value returned correctly - + // Double-check by querying the database directly let verified: TestAuthor = sqlx::query_as!( TestAuthor, @@ -477,7 +484,7 @@ mod defaultable_tests { .fetch_one(&pool) .await .unwrap(); - + assert_eq!(verified.id, created.id); assert_eq!(verified.name, created.name); assert_eq!(verified.biography_id, created.biography_id); @@ -487,30 +494,30 @@ mod defaultable_tests { async fn test_query_parameter_binding_order(pool: PgPool) { // Test that query parameters are bound in the correct order // This is critical for the dynamic SQL generation - + // Create a record where the parameter order matters let test_record = MultiDefaultableDefault { - id: Some(400), // This should be bound first (if included) + id: Some(400), // This should be bound first (if included) name: Some("Param Order Test".to_string()), // This should be bound second (if included) - biography_id: Some(1), // This should be bound last + biography_id: Some(1), // This should be bound last }; - + let created = test_record.create(&pool).await.unwrap(); - + // Verify all parameters were bound correctly assert_eq!(created.id, 400); assert_eq!(created.name, "Param Order Test"); assert_eq!(created.biography_id, Some(1)); - + // Test with different parameter inclusion order let test_record2 = MultiDefaultableDefault { - id: None, // Excluded - should not affect parameter order + id: None, // Excluded - should not affect parameter order name: Some("No ID Test".to_string()), // Should be bound first now - biography_id: Some(1), // Should be bound second now + biography_id: Some(1), // Should be bound second now }; - + let created2 = test_record2.create(&pool).await.unwrap(); - + assert!(created2.id > 0); // Auto-generated assert_eq!(created2.name, "No ID Test"); assert_eq!(created2.biography_id, Some(1)); diff --git a/tests/simple_struct.rs b/tests/simple_struct.rs index 182eb94..bdb208f 100644 --- a/tests/simple_struct.rs +++ b/tests/simple_struct.rs @@ -60,7 +60,10 @@ async fn create_fails_if_already_exists(pool: sqlx::PgPool) -> sqlx::Result<()> let result = author.create(&pool).await; assert!(result.is_err()); let error = result.err().unwrap(); - assert_eq!("error returned from database: duplicate key value violates unique constraint \"authors_pkey\"", error.to_string()); + assert_eq!( + "error returned from database: duplicate key value violates unique constraint \"authors_pkey\"", + error.to_string() + ); Ok(()) }