feat(examples): add PostgreSQL example with user relationship
All checks were successful
CI / tests (push) Successful in 5m39s
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:
parent
8ffa8eb3ac
commit
c9dd2242e7
@ -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
4
.gitignore
vendored
@ -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
329
Cargo.lock
generated
@ -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]]
|
||||||
|
@ -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"
|
||||||
|
27
README.md
27
README.md
@ -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
|
||||||
|
14
examples/postgres/users-comments-and-followers/Cargo.toml
Normal file
14
examples/postgres/users-comments-and-followers/Cargo.toml
Normal 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"] }
|
@ -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(())
|
||||||
|
}
|
@ -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(())
|
||||||
|
}
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
113
examples/postgres/users-comments-and-followers/src/cli/users.rs
Normal file
113
examples/postgres/users-comments-and-followers/src/cli/users.rs
Normal 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(())
|
||||||
|
}
|
15
examples/postgres/users-comments-and-followers/src/errors.rs
Normal file
15
examples/postgres/users-comments-and-followers/src/errors.rs
Normal 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),
|
||||||
|
}
|
20
examples/postgres/users-comments-and-followers/src/main.rs
Normal file
20
examples/postgres/users-comments-and-followers/src/main.rs
Normal 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}"),
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
mod users;
|
||||||
|
pub use users::*;
|
||||||
|
mod profiles;
|
||||||
|
pub use profiles::*;
|
||||||
|
mod comments;
|
||||||
|
pub use comments::*;
|
||||||
|
mod followers;
|
||||||
|
pub use followers::*;
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
@ -50,23 +50,27 @@ 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();
|
||||||
let mut bind_checks = Vec::new();
|
let mut bind_checks = Vec::new();
|
||||||
|
|
||||||
for field in &defaultable_fields {
|
for field in &defaultable_fields {
|
||||||
let field_name = field.ident.to_string();
|
let field_name = field.ident.to_string();
|
||||||
let field_ident = &field.ident;
|
let field_ident = &field.ident;
|
||||||
|
|
||||||
field_checks.push(quote! {
|
field_checks.push(quote! {
|
||||||
if self.#field_ident.is_some() {
|
if self.#field_ident.is_some() {
|
||||||
dynamic_fields.push(#field_name);
|
dynamic_fields.push(#field_name);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
bind_checks.push(quote! {
|
bind_checks.push(quote! {
|
||||||
if let Some(ref value) = self.#field_ident {
|
if let Some(ref value) = self.#field_ident {
|
||||||
query_builder = query_builder.bind(value);
|
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 {
|
impl ::georm::Defaultable<#id_type, #struct_name> for #defaultable_struct_name {
|
||||||
async fn create(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<#struct_name> {
|
async fn create(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<#struct_name> {
|
||||||
let mut dynamic_fields = Vec::new();
|
let mut dynamic_fields = Vec::new();
|
||||||
|
|
||||||
#(#field_checks)*
|
#(#field_checks)*
|
||||||
|
|
||||||
let mut all_fields = vec![#(#static_field_names),*];
|
let mut all_fields = vec![#(#static_field_names),*];
|
||||||
all_fields.extend(dynamic_fields);
|
all_fields.extend(dynamic_fields);
|
||||||
|
|
||||||
let placeholders: Vec<String> = (1..=all_fields.len())
|
let placeholders: Vec<String> = (1..=all_fields.len())
|
||||||
.map(|i| format!("${}", i))
|
.map(|i| format!("${}", i))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let query = format!(
|
let query = format!(
|
||||||
"INSERT INTO {} ({}) VALUES ({}) RETURNING *",
|
"INSERT INTO {} ({}) VALUES ({}) RETURNING *",
|
||||||
#table,
|
#table,
|
||||||
all_fields.join(", "),
|
all_fields.join(", "),
|
||||||
placeholders.join(", ")
|
placeholders.join(", ")
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut query_builder = ::sqlx::query_as::<_, #struct_name>(&query);
|
let mut query_builder = ::sqlx::query_as::<_, #struct_name>(&query);
|
||||||
|
|
||||||
// Bind non-defaultable fields first
|
// Bind non-defaultable fields first
|
||||||
#(query_builder = query_builder.bind(&self.#static_field_idents);)*
|
#(query_builder = query_builder.bind(&self.#static_field_idents);)*
|
||||||
|
|
||||||
// Then bind defaultable fields that have values
|
// Then bind defaultable fields that have values
|
||||||
#(#bind_checks)*
|
#(#bind_checks)*
|
||||||
|
|
||||||
query_builder.fetch_one(pool).await
|
query_builder.fetch_one(pool).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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(
|
||||||
"Field {} cannot be an identifier, {} already is one.\nOnly one identifier is supported.",
|
id2.field.clone(),
|
||||||
id1.ident, id2.ident
|
format!(
|
||||||
)))
|
"Field {} cannot be an identifier, {} already is one.\nOnly one identifier is supported.",
|
||||||
|
id1.ident, id2.ident
|
||||||
|
),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,4 @@
|
|||||||
|
DROP TABLE IF EXISTS Followers;
|
||||||
|
DROP TABLE IF EXISTS Comments;
|
||||||
|
DROP TABLE IF EXISTS Profiles;
|
||||||
|
DROP TABLE IF EXISTS Users;
|
@ -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)
|
||||||
|
);
|
@ -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"));
|
||||||
@ -181,9 +181,9 @@ mod defaultable_tests {
|
|||||||
async fn test_multiple_defaultable_fields_mixed(pool: PgPool) {
|
async fn test_multiple_defaultable_fields_mixed(pool: PgPool) {
|
||||||
// Test with some defaultable fields set and others None
|
// Test with some defaultable fields set and others None
|
||||||
let multi_default = MultiDefaultableDefault {
|
let multi_default = MultiDefaultableDefault {
|
||||||
id: None, // Let database generate
|
id: None, // Let database generate
|
||||||
name: Some("Explicit Name".to_string()), // Explicit value
|
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();
|
let created = multi_default.create(&pool).await.unwrap();
|
||||||
@ -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());
|
||||||
@ -291,7 +299,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) {
|
||||||
@ -306,12 +313,12 @@ mod defaultable_tests {
|
|||||||
// Since we can't directly inspect the generated SQL from the macro,
|
// Since we can't directly inspect the generated SQL from the macro,
|
||||||
// we test the behavior indirectly by ensuring all fields are included
|
// we test the behavior indirectly by ensuring all fields are included
|
||||||
let created = author_default.create(&pool).await.unwrap();
|
let created = author_default.create(&pool).await.unwrap();
|
||||||
|
|
||||||
// Verify all fields were properly inserted
|
// Verify all fields were properly inserted
|
||||||
assert_eq!(created.id, 100);
|
assert_eq!(created.id, 100);
|
||||||
assert_eq!(created.name, "Test Name");
|
assert_eq!(created.name, "Test Name");
|
||||||
assert_eq!(created.biography_id, Some(1));
|
assert_eq!(created.biography_id, Some(1));
|
||||||
|
|
||||||
// Verify the record exists in database with all expected values
|
// Verify the record exists in database with all expected values
|
||||||
let found: TestAuthor = sqlx::query_as!(
|
let found: TestAuthor = sqlx::query_as!(
|
||||||
TestAuthor,
|
TestAuthor,
|
||||||
@ -321,7 +328,7 @@ mod defaultable_tests {
|
|||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(found.id, 100);
|
assert_eq!(found.id, 100);
|
||||||
assert_eq!(found.name, "Test Name");
|
assert_eq!(found.name, "Test Name");
|
||||||
assert_eq!(found.biography_id, Some(1));
|
assert_eq!(found.biography_id, Some(1));
|
||||||
@ -337,12 +344,12 @@ mod defaultable_tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let created = author_default.create(&pool).await.unwrap();
|
let created = author_default.create(&pool).await.unwrap();
|
||||||
|
|
||||||
// ID should be auto-generated (not explicitly set)
|
// ID should be auto-generated (not explicitly set)
|
||||||
assert!(created.id > 0);
|
assert!(created.id > 0);
|
||||||
assert_eq!(created.name, "Auto ID Test");
|
assert_eq!(created.name, "Auto ID Test");
|
||||||
assert_eq!(created.biography_id, None);
|
assert_eq!(created.biography_id, None);
|
||||||
|
|
||||||
// Verify the generated ID is actually from database auto-increment
|
// Verify the generated ID is actually from database auto-increment
|
||||||
// by checking it's different from any manually set values
|
// by checking it's different from any manually set values
|
||||||
assert_ne!(created.id, 100); // Different from previous test
|
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) {
|
async fn test_sql_generation_mixed_defaultable_fields(pool: PgPool) {
|
||||||
// Test SQL with multiple defaultable fields where some are None
|
// Test SQL with multiple defaultable fields where some are None
|
||||||
let multi_default = MultiDefaultableDefault {
|
let multi_default = MultiDefaultableDefault {
|
||||||
id: None, // Should be excluded
|
id: None, // Should be excluded
|
||||||
name: Some("Explicit Name".to_string()), // Should be included
|
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();
|
let created = multi_default.create(&pool).await.unwrap();
|
||||||
|
|
||||||
// Verify the mixed field inclusion worked correctly
|
// Verify the mixed field inclusion worked correctly
|
||||||
assert!(created.id > 0); // Auto-generated
|
assert!(created.id > 0); // Auto-generated
|
||||||
assert_eq!(created.name, "Explicit Name"); // Explicitly set
|
assert_eq!(created.name, "Explicit Name"); // Explicitly set
|
||||||
@ -369,21 +376,21 @@ mod defaultable_tests {
|
|||||||
async fn test_placeholder_ordering_consistency(pool: PgPool) {
|
async fn test_placeholder_ordering_consistency(pool: PgPool) {
|
||||||
// Test that placeholders are ordered correctly when fields are dynamically included
|
// Test that placeholders are ordered correctly when fields are dynamically included
|
||||||
// Create multiple records with different field combinations
|
// Create multiple records with different field combinations
|
||||||
|
|
||||||
// First: only non-defaultable fields
|
// First: only non-defaultable fields
|
||||||
let record1 = MultiDefaultableDefault {
|
let record1 = MultiDefaultableDefault {
|
||||||
id: None,
|
id: None,
|
||||||
name: None,
|
name: None,
|
||||||
biography_id: Some(1),
|
biography_id: Some(1),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Second: all fields explicit
|
// Second: all fields explicit
|
||||||
let record2 = MultiDefaultableDefault {
|
let record2 = MultiDefaultableDefault {
|
||||||
id: Some(201),
|
id: Some(201),
|
||||||
name: Some("Full Record".to_string()),
|
name: Some("Full Record".to_string()),
|
||||||
biography_id: Some(1),
|
biography_id: Some(1),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Third: mixed combination
|
// Third: mixed combination
|
||||||
let record3 = MultiDefaultableDefault {
|
let record3 = MultiDefaultableDefault {
|
||||||
id: None,
|
id: None,
|
||||||
@ -395,23 +402,23 @@ mod defaultable_tests {
|
|||||||
let result1 = record1.create(&pool).await;
|
let result1 = record1.create(&pool).await;
|
||||||
let result2 = record2.create(&pool).await;
|
let result2 = record2.create(&pool).await;
|
||||||
let result3 = record3.create(&pool).await;
|
let result3 = record3.create(&pool).await;
|
||||||
|
|
||||||
// Handle record1 based on whether name has a database default
|
// Handle record1 based on whether name has a database default
|
||||||
match result1 {
|
match result1 {
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let created2 = result2.unwrap();
|
let created2 = result2.unwrap();
|
||||||
assert_eq!(created2.id, 201);
|
assert_eq!(created2.id, 201);
|
||||||
assert_eq!(created2.name, "Full Record");
|
assert_eq!(created2.name, "Full Record");
|
||||||
assert_eq!(created2.biography_id, Some(1));
|
assert_eq!(created2.biography_id, Some(1));
|
||||||
|
|
||||||
let created3 = result3.unwrap();
|
let created3 = result3.unwrap();
|
||||||
assert!(created3.id > 0);
|
assert!(created3.id > 0);
|
||||||
assert_eq!(created3.name, "Mixed Record");
|
assert_eq!(created3.name, "Mixed Record");
|
||||||
@ -422,32 +429,32 @@ mod defaultable_tests {
|
|||||||
async fn test_field_inclusion_logic(pool: PgPool) {
|
async fn test_field_inclusion_logic(pool: PgPool) {
|
||||||
// Test that the field inclusion logic works correctly
|
// Test that the field inclusion logic works correctly
|
||||||
// by creating records that should result in different SQL queries
|
// by creating records that should result in different SQL queries
|
||||||
|
|
||||||
let minimal = TestAuthorDefault {
|
let minimal = TestAuthorDefault {
|
||||||
id: None,
|
id: None,
|
||||||
name: "Minimal".to_string(),
|
name: "Minimal".to_string(),
|
||||||
biography_id: None,
|
biography_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let maximal = TestAuthorDefault {
|
let maximal = TestAuthorDefault {
|
||||||
id: Some(300),
|
id: Some(300),
|
||||||
name: "Maximal".to_string(),
|
name: "Maximal".to_string(),
|
||||||
biography_id: Some(1),
|
biography_id: Some(1),
|
||||||
};
|
};
|
||||||
|
|
||||||
let created_minimal = minimal.create(&pool).await.unwrap();
|
let created_minimal = minimal.create(&pool).await.unwrap();
|
||||||
let created_maximal = maximal.create(&pool).await.unwrap();
|
let created_maximal = maximal.create(&pool).await.unwrap();
|
||||||
|
|
||||||
// Minimal should have auto-generated ID, explicit name, NULL biography_id
|
// Minimal should have auto-generated ID, explicit name, NULL biography_id
|
||||||
assert!(created_minimal.id > 0);
|
assert!(created_minimal.id > 0);
|
||||||
assert_eq!(created_minimal.name, "Minimal");
|
assert_eq!(created_minimal.name, "Minimal");
|
||||||
assert_eq!(created_minimal.biography_id, None);
|
assert_eq!(created_minimal.biography_id, None);
|
||||||
|
|
||||||
// Maximal should have all explicit values
|
// Maximal should have all explicit values
|
||||||
assert_eq!(created_maximal.id, 300);
|
assert_eq!(created_maximal.id, 300);
|
||||||
assert_eq!(created_maximal.name, "Maximal");
|
assert_eq!(created_maximal.name, "Maximal");
|
||||||
assert_eq!(created_maximal.biography_id, Some(1));
|
assert_eq!(created_maximal.biography_id, Some(1));
|
||||||
|
|
||||||
// Verify they are different records
|
// Verify they are different records
|
||||||
assert_ne!(created_minimal.id, created_maximal.id);
|
assert_ne!(created_minimal.id, created_maximal.id);
|
||||||
}
|
}
|
||||||
@ -460,14 +467,14 @@ mod defaultable_tests {
|
|||||||
name: "Return Test".to_string(),
|
name: "Return Test".to_string(),
|
||||||
biography_id: None,
|
biography_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let created = author_default.create(&pool).await.unwrap();
|
let created = author_default.create(&pool).await.unwrap();
|
||||||
|
|
||||||
// Verify RETURNING clause populated all fields correctly
|
// Verify RETURNING clause populated all fields correctly
|
||||||
assert!(created.id > 0); // Database-generated ID returned
|
assert!(created.id > 0); // Database-generated ID returned
|
||||||
assert_eq!(created.name, "Return Test"); // Explicit value returned
|
assert_eq!(created.name, "Return Test"); // Explicit value returned
|
||||||
assert_eq!(created.biography_id, None); // NULL value returned correctly
|
assert_eq!(created.biography_id, None); // NULL value returned correctly
|
||||||
|
|
||||||
// Double-check by querying the database directly
|
// Double-check by querying the database directly
|
||||||
let verified: TestAuthor = sqlx::query_as!(
|
let verified: TestAuthor = sqlx::query_as!(
|
||||||
TestAuthor,
|
TestAuthor,
|
||||||
@ -477,7 +484,7 @@ mod defaultable_tests {
|
|||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(verified.id, created.id);
|
assert_eq!(verified.id, created.id);
|
||||||
assert_eq!(verified.name, created.name);
|
assert_eq!(verified.name, created.name);
|
||||||
assert_eq!(verified.biography_id, created.biography_id);
|
assert_eq!(verified.biography_id, created.biography_id);
|
||||||
@ -487,30 +494,30 @@ mod defaultable_tests {
|
|||||||
async fn test_query_parameter_binding_order(pool: PgPool) {
|
async fn test_query_parameter_binding_order(pool: PgPool) {
|
||||||
// Test that query parameters are bound in the correct order
|
// Test that query parameters are bound in the correct order
|
||||||
// This is critical for the dynamic SQL generation
|
// This is critical for the dynamic SQL generation
|
||||||
|
|
||||||
// Create a record where the parameter order matters
|
// Create a record where the parameter order matters
|
||||||
let test_record = MultiDefaultableDefault {
|
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)
|
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();
|
let created = test_record.create(&pool).await.unwrap();
|
||||||
|
|
||||||
// Verify all parameters were bound correctly
|
// Verify all parameters were bound correctly
|
||||||
assert_eq!(created.id, 400);
|
assert_eq!(created.id, 400);
|
||||||
assert_eq!(created.name, "Param Order Test");
|
assert_eq!(created.name, "Param Order Test");
|
||||||
assert_eq!(created.biography_id, Some(1));
|
assert_eq!(created.biography_id, Some(1));
|
||||||
|
|
||||||
// Test with different parameter inclusion order
|
// Test with different parameter inclusion order
|
||||||
let test_record2 = MultiDefaultableDefault {
|
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
|
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();
|
let created2 = test_record2.create(&pool).await.unwrap();
|
||||||
|
|
||||||
assert!(created2.id > 0); // Auto-generated
|
assert!(created2.id > 0); // Auto-generated
|
||||||
assert_eq!(created2.name, "No ID Test");
|
assert_eq!(created2.name, "No ID Test");
|
||||||
assert_eq!(created2.biography_id, Some(1));
|
assert_eq!(created2.biography_id, Some(1));
|
||||||
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user