feat(examples): add PostgreSQL example with user relationship
All checks were successful
CI / tests (push) Successful in 5m39s

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
This commit is contained in:
Lucien Cartier-Tilet 2025-06-05 23:56:15 +02:00
parent 8ffa8eb3ac
commit c9dd2242e7
Signed by: phundrak
SSH Key Fingerprint: SHA256:CE0HPsbW3L2YiJETx1zYZ2muMptaAqTN2g3498KrMkc
23 changed files with 1199 additions and 85 deletions

View File

@ -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")))))))))))

4
.gitignore vendored
View File

@ -1,7 +1,8 @@
.direnv
.env .env
/coverage /coverage
/target /target
/.sqls
/examples/target
# Devenv # Devenv
.devenv* .devenv*
@ -17,6 +18,7 @@ devenv.local.nix
*~ *~
\#*\# \#*\#
.\#* .\#*
.dir-locals.el
# Vim files # Vim files
*.swp *.swp

329
Cargo.lock generated
View File

@ -23,6 +23,56 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 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]] [[package]]
name = "arrayvec" name = "arrayvec"
version = "0.7.6" version = "0.7.6"
@ -71,6 +121,12 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.9.1" version = "2.9.1"
@ -107,6 +163,52 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 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]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
version = "2.5.0" version = "2.5.0"
@ -161,6 +263,31 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 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]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.6" version = "0.1.6"
@ -193,7 +320,7 @@ dependencies = [
"arrayvec", "arrayvec",
"proc-macro2", "proc-macro2",
"quote", "quote",
"strsim", "strsim 0.10.0",
"syn", "syn",
] ]
@ -252,6 +379,12 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "dyn-clone"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005"
[[package]] [[package]]
name = "either" name = "either"
version = "1.15.0" version = "1.15.0"
@ -387,6 +520,24 @@ dependencies = [
"slab", "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]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.7" version = "0.14.7"
@ -416,6 +567,18 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "georm-users-comments-and-followers"
version = "0.1.1"
dependencies = [
"clap",
"georm",
"inquire",
"sqlx",
"thiserror",
"tokio",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.16" version = "0.2.16"
@ -447,9 +610,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.3" version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
dependencies = [ dependencies = [
"allocator-api2", "allocator-api2",
"equivalent", "equivalent",
@ -633,6 +796,29 @@ dependencies = [
"hashbrown", "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]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.15" version = "1.0.15"
@ -717,6 +903,18 @@ dependencies = [
"adler2", "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]] [[package]]
name = "mio" name = "mio"
version = "1.0.4" version = "1.0.4"
@ -728,6 +926,15 @@ dependencies = [
"windows-sys 0.59.0", "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]] [[package]]
name = "num-bigint-dig" name = "num-bigint-dig"
version = "0.8.4" version = "0.8.4"
@ -790,6 +997,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
[[package]] [[package]]
name = "parking" name = "parking"
version = "2.2.1" version = "2.2.1"
@ -990,7 +1203,7 @@ version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af" checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.9.1",
] ]
[[package]] [[package]]
@ -1097,6 +1310,36 @@ dependencies = [
"digest", "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]] [[package]]
name = "signature" name = "signature"
version = "2.2.0" version = "2.2.0"
@ -1245,7 +1488,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64", "base64",
"bitflags", "bitflags 2.9.1",
"byteorder", "byteorder",
"bytes", "bytes",
"crc", "crc",
@ -1286,7 +1529,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64", "base64",
"bitflags", "bitflags 2.9.1",
"byteorder", "byteorder",
"crc", "crc",
"dotenvy", "dotenvy",
@ -1361,6 +1604,12 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@ -1409,6 +1658,16 @@ dependencies = [
"syn", "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]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.1" version = "0.8.1"
@ -1443,12 +1702,26 @@ dependencies = [
"backtrace", "backtrace",
"bytes", "bytes",
"libc", "libc",
"mio", "mio 1.0.4",
"parking_lot",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry",
"socket2", "socket2",
"tokio-macros",
"windows-sys 0.52.0", "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]] [[package]]
name = "tokio-stream" name = "tokio-stream"
version = "0.1.17" version = "0.1.17"
@ -1542,6 +1815,18 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" 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]] [[package]]
name = "url" name = "url"
version = "2.5.4" version = "2.5.4"
@ -1559,6 +1844,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"
@ -1602,6 +1893,28 @@ dependencies = [
"wasite", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.48.0" version = "0.48.0"
@ -1765,7 +2078,7 @@ version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.9.1",
] ]
[[package]] [[package]]

View File

@ -1,5 +1,9 @@
[workspace] [workspace]
members = [".", "georm-macros"] members = [
".",
"georm-macros",
"examples/postgres/*"
]
[workspace.package] [workspace.package]
version = "0.1.1" version = "0.1.1"

View File

@ -491,6 +491,32 @@ Georm is designed for zero runtime overhead:
- **Minimal allocations**: Efficient use of owned vs borrowed data - **Minimal allocations**: Efficient use of owned vs borrowed data
- **SQLx integration**: Leverages SQLx's optimized PostgreSQL driver - **SQLx integration**: Leverages SQLx's optimized PostgreSQL driver
## Examples
### Comprehensive Example
For an example showcasing user management, comments, and follower relationships, see the example in `examples/postgres/users-comments-and-followers/`. This example demonstrates:
- User management 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 ## Comparison
| Feature | Georm | SeaORM | Diesel | | Feature | Georm | SeaORM | Diesel |
@ -509,6 +535,7 @@ Georm is designed for zero runtime overhead:
### Medium Priority ### Medium Priority
- **Multi-Database Support**: MySQL and SQLite support with feature flags - **Multi-Database Support**: MySQL and SQLite support with feature flags
- **Field-Based Queries**: Generate `find_by_{field_name}` methods that return `Vec<T>` for regular fields or `Option<T>` for unique fields
- **Relationship Optimization**: Eager loading and N+1 query prevention - **Relationship Optimization**: Eager loading and N+1 query prevention
- **Composite Primary Keys**: Multi-field primary key support - **Composite Primary Keys**: Multi-field primary key support
- **Soft Delete**: Optional soft delete with `deleted_at` timestamps - **Soft Delete**: Optional soft delete with `deleted_at` timestamps

View File

@ -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"] }

View File

@ -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<String>,
username: Option<String>,
},
Remove {
id: Option<i32>,
},
RemoveFromUser {
username: Option<String>,
},
ListFromUser {
username: Option<String>,
},
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<String>,
text: Option<String>,
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<i32>, 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<String>, 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<String, Comment> = 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<String>, 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(())
}

View File

@ -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<String>,
followed: Option<String>,
},
Unfollow {
follower: Option<String>,
},
ListFollowers {
user: Option<String>,
},
ListFollowed {
user: Option<String>,
},
}
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<String>,
followed: Option<String>,
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<String>, 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<String, User> = 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<String>, 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<String>, 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(())
}

View File

@ -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,
}
}
}

View File

@ -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<String> },
Remove { id: Option<i32> },
UpdateProfile { id: Option<i32> },
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<String>, 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<i32>, 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<i32>, 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(())
}

View File

@ -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),
}

View File

@ -0,0 +1,20 @@
mod cli;
mod errors;
mod models;
use clap::Parser;
use cli::{Cli, Executable};
type Result<T> = std::result::Result<T, errors::UserInputError>;
#[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}"),
}
}

View File

@ -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<Self> {
let comments: HashMap<String, Self> = 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
)
}
}

View File

@ -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,
}

View File

@ -0,0 +1,8 @@
mod users;
pub use users::*;
mod profiles;
pub use profiles::*;
mod comments;
pub use comments::*;
mod followers;
pub use followers::*;

View File

@ -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<String>,
pub display_name: Option<String>,
}
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<Self> {
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<String>,
bio: Option<String>,
pool: &sqlx::PgPool,
) -> Result<Self> {
self.display_name = display_name;
self.bio = bio;
self.update(pool)
.await
.map_err(UserInputError::DatabaseError)
}
}

View File

@ -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<Self> {
let users: HashMap<String, Self> = 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<i32>,
prompt: &str,
pool: &sqlx::PgPool,
) -> Result<Self> {
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<Self> {
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<Option<Self>> {
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<Self> {
let user = UserDefault::from(username);
user.create(pool)
.await
.map_err(UserInputError::DatabaseError)
}
pub async fn remove_interactive(id: Option<i32>, pool: &sqlx::PgPool) -> Result<Self> {
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<i32>, 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))
}
}

View File

@ -50,8 +50,12 @@ fn generate_defaultable_trait_impl(
let defaultable_fields: Vec<_> = fields.iter().filter(|f| f.defaultable).collect(); let defaultable_fields: Vec<_> = fields.iter().filter(|f| f.defaultable).collect();
// Build static parts for non-defaultable fields // Build static parts for non-defaultable fields
let static_field_names: Vec<String> = non_defaultable_fields.iter().map(|f| f.ident.to_string()).collect(); let static_field_names: Vec<String> = non_defaultable_fields
let static_field_idents: Vec<&syn::Ident> = non_defaultable_fields.iter().map(|f| &f.ident).collect(); .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 // Generate field checks for defaultable fields
let mut field_checks = Vec::new(); let mut field_checks = Vec::new();

View File

@ -35,10 +35,13 @@ fn extract_georm_field_attrs(
_ => { _ => {
let id1 = identifiers.first().unwrap(); let id1 = identifiers.first().unwrap();
let id2 = identifiers.get(1).unwrap(); let id2 = identifiers.get(1).unwrap();
Err(syn::Error::new_spanned(id2.field.clone(), format!( Err(syn::Error::new_spanned(
id2.field.clone(),
format!(
"Field {} cannot be an identifier, {} already is one.\nOnly one identifier is supported.", "Field {} cannot be an identifier, {} already is one.\nOnly one identifier is supported.",
id1.ident, id2.ident id1.ident, id2.ident
))) ),
))
} }
} }
} }

View File

@ -0,0 +1,4 @@
DROP TABLE IF EXISTS Followers;
DROP TABLE IF EXISTS Comments;
DROP TABLE IF EXISTS Profiles;
DROP TABLE IF EXISTS Users;

View File

@ -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)
);

View File

@ -169,7 +169,7 @@ mod defaultable_tests {
Ok(created) => { Ok(created) => {
assert!(created.id > 0); assert!(created.id > 0);
// If successful, name should have some default value // If successful, name should have some default value
}, }
Err(e) => { Err(e) => {
// Expected if no database default for name column // Expected if no database default for name column
assert!(e.to_string().contains("null") || e.to_string().contains("NOT NULL")); assert!(e.to_string().contains("null") || e.to_string().contains("NOT NULL"));
@ -233,7 +233,11 @@ mod defaultable_tests {
let error = result2.unwrap_err(); let error = result2.unwrap_err();
let error_str = error.to_string(); 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"))] #[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
@ -254,11 +258,15 @@ mod defaultable_tests {
// No foreign key constraint - this is valid behavior // No foreign key constraint - this is valid behavior
assert!(created.id > 0); assert!(created.id > 0);
assert_eq!(created.biography_id, Some(99999)); assert_eq!(created.biography_id, Some(99999));
}, }
Err(e) => { Err(e) => {
// Foreign key constraint violation // Foreign key constraint violation
let error_str = e.to_string(); 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) => { Ok(created) => {
assert!(created.id > 0); assert!(created.id > 0);
assert_eq!(created.name.len(), 10000); assert_eq!(created.name.len(), 10000);
}, }
Err(e) => { Err(e) => {
// Some kind of database limit hit // Some kind of database limit hit
assert!(!e.to_string().is_empty()); assert!(!e.to_string().is_empty());
@ -292,7 +300,6 @@ mod defaultable_tests {
mod sql_validation_tests { mod sql_validation_tests {
use super::*; use super::*;
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))] #[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
async fn test_sql_generation_no_defaultable_fields(pool: PgPool) { async fn test_sql_generation_no_defaultable_fields(pool: PgPool) {
// Test SQL generation when no defaultable fields have None values // Test SQL generation when no defaultable fields have None values
@ -401,7 +408,7 @@ mod defaultable_tests {
Ok(created1) => { Ok(created1) => {
assert!(created1.id > 0); assert!(created1.id > 0);
assert_eq!(created1.biography_id, Some(1)); assert_eq!(created1.biography_id, Some(1));
}, }
Err(_) => { Err(_) => {
// Expected if name field has no database default // Expected if name field has no database default
} }

View File

@ -60,7 +60,10 @@ async fn create_fails_if_already_exists(pool: sqlx::PgPool) -> sqlx::Result<()>
let result = author.create(&pool).await; let result = author.create(&pool).await;
assert!(result.is_err()); assert!(result.is_err());
let error = result.err().unwrap(); 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(()) Ok(())
} }