mirror of
https://github.com/Phundrak/georm.git
synced 2025-06-25 09:24:57 +00:00
feat: implement preliminary composite primary key support
Add support for entities with composite primary keys using multiple #[georm(id)] fields. Automatically generates {EntityName}Id structs for type-safe composite key handling. Features: - Multi-field primary key detection and ID struct generation - Full CRUD operations (find, create, update, delete, create_or_update) - Proper SQL generation with AND clauses for composite keys - Updated documNtation in README and lib.rs Note: Relationships not yet supported for composite key entities
This commit is contained in:
parent
190c4d7b1d
commit
19284665e6
223
Cargo.lock
generated
223
Cargo.lock
generated
@ -23,6 +23,21 @@ 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 = "android-tzdata"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android_system_properties"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstream"
|
name = "anstream"
|
||||||
version = "0.6.19"
|
version = "0.6.19"
|
||||||
@ -145,6 +160,12 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bumpalo"
|
||||||
|
version = "3.18.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder"
|
name = "byteorder"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@ -157,12 +178,36 @@ version = "1.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.2.26"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "956a5e21988b87f372569b66183b78babf23ebc2e744b733e4350a752c4dafac"
|
||||||
|
dependencies = [
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.0"
|
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 = "chrono"
|
||||||
|
version = "0.4.41"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
|
||||||
|
dependencies = [
|
||||||
|
"android-tzdata",
|
||||||
|
"iana-time-zone",
|
||||||
|
"js-sys",
|
||||||
|
"num-traits",
|
||||||
|
"serde",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.5.39"
|
version = "4.5.39"
|
||||||
@ -224,6 +269,12 @@ version = "0.9.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation-sys"
|
||||||
|
version = "0.8.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@ -552,6 +603,7 @@ dependencies = [
|
|||||||
name = "georm"
|
name = "georm"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
"georm-macros",
|
"georm-macros",
|
||||||
"rand 0.9.1",
|
"rand 0.9.1",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
@ -673,6 +725,30 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone"
|
||||||
|
version = "0.1.63"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
|
||||||
|
dependencies = [
|
||||||
|
"android_system_properties",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"iana-time-zone-haiku",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone-haiku"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_collections"
|
name = "icu_collections"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
@ -825,6 +901,16 @@ version = "1.0.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "js-sys"
|
||||||
|
version = "0.3.77"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@ -1232,6 +1318,12 @@ version = "0.1.24"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
|
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.20"
|
version = "1.0.20"
|
||||||
@ -1310,6 +1402,12 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signal-hook"
|
name = "signal-hook"
|
||||||
version = "0.3.18"
|
version = "0.3.18"
|
||||||
@ -1418,6 +1516,7 @@ checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"crc",
|
"crc",
|
||||||
"crossbeam-queue",
|
"crossbeam-queue",
|
||||||
"either",
|
"either",
|
||||||
@ -1474,7 +1573,9 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
|
"sqlx-mysql",
|
||||||
"sqlx-postgres",
|
"sqlx-postgres",
|
||||||
|
"sqlx-sqlite",
|
||||||
"syn",
|
"syn",
|
||||||
"tokio",
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
@ -1491,6 +1592,7 @@ dependencies = [
|
|||||||
"bitflags 2.9.1",
|
"bitflags 2.9.1",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"crc",
|
"crc",
|
||||||
"digest",
|
"digest",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
@ -1511,6 +1613,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"rsa",
|
"rsa",
|
||||||
|
"serde",
|
||||||
"sha1",
|
"sha1",
|
||||||
"sha2",
|
"sha2",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
@ -1531,6 +1634,7 @@ dependencies = [
|
|||||||
"base64",
|
"base64",
|
||||||
"bitflags 2.9.1",
|
"bitflags 2.9.1",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
|
"chrono",
|
||||||
"crc",
|
"crc",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"etcetera",
|
"etcetera",
|
||||||
@ -1565,6 +1669,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
|
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
|
"chrono",
|
||||||
"flume",
|
"flume",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@ -1574,6 +1679,7 @@ dependencies = [
|
|||||||
"libsqlite3-sys",
|
"libsqlite3-sys",
|
||||||
"log",
|
"log",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
|
"serde",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
@ -1883,6 +1989,64 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen"
|
||||||
|
version = "0.2.100"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"once_cell",
|
||||||
|
"rustversion",
|
||||||
|
"wasm-bindgen-macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-backend"
|
||||||
|
version = "0.2.100"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"log",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro"
|
||||||
|
version = "0.2.100"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"wasm-bindgen-macro-support",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro-support"
|
||||||
|
version = "0.2.100"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"wasm-bindgen-backend",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-shared"
|
||||||
|
version = "0.2.100"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "whoami"
|
name = "whoami"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
@ -1915,6 +2079,65 @@ version = "0.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||||
|
dependencies = [
|
||||||
|
"windows-implement",
|
||||||
|
"windows-interface",
|
||||||
|
"windows-link",
|
||||||
|
"windows-result",
|
||||||
|
"windows-strings",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.60.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.59.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-strings"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.48.0"
|
version = "0.48.0"
|
||||||
|
@ -39,8 +39,14 @@ sqlx = { workspace = true }
|
|||||||
georm-macros = { workspace = true }
|
georm-macros = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
|
|
||||||
|
[dev-dependencies.sqlx]
|
||||||
|
version = "0.8.6"
|
||||||
|
default-features = false
|
||||||
|
features = ["postgres", "runtime-tokio", "macros", "migrate", "chrono"]
|
||||||
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
|
||||||
|
35
README.md
35
README.md
@ -41,6 +41,7 @@ Georm is a lightweight, opinionated Object-Relational Mapping (ORM) library buil
|
|||||||
- **Zero Runtime Cost**: No reflection or runtime query building
|
- **Zero Runtime Cost**: No reflection or runtime query building
|
||||||
- **Simple API**: Intuitive derive macros for common operations
|
- **Simple API**: Intuitive derive macros for common operations
|
||||||
- **Relationship Support**: One-to-one, one-to-many, and many-to-many relationships
|
- **Relationship Support**: One-to-one, one-to-many, and many-to-many relationships
|
||||||
|
- **Composite Primary Keys**: Support for multi-field primary keys
|
||||||
- **Defaultable Fields**: Easy entity creation with database defaults and auto-generated values
|
- **Defaultable Fields**: Easy entity creation with database defaults and auto-generated values
|
||||||
- **PostgreSQL Native**: Optimized for PostgreSQL features and data types
|
- **PostgreSQL Native**: Optimized for PostgreSQL features and data types
|
||||||
|
|
||||||
@ -148,6 +149,38 @@ async fn example(pool: &PgPool) -> sqlx::Result<()> {
|
|||||||
|
|
||||||
## Advanced Features
|
## Advanced Features
|
||||||
|
|
||||||
|
### Composite Primary Keys
|
||||||
|
|
||||||
|
Georm supports composite primary keys by marking multiple fields with `#[georm(id)]`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Georm)]
|
||||||
|
#[georm(table = "user_roles")]
|
||||||
|
pub struct UserRole {
|
||||||
|
#[georm(id)]
|
||||||
|
pub user_id: i32,
|
||||||
|
#[georm(id)]
|
||||||
|
pub role_id: i32,
|
||||||
|
pub assigned_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This automatically generates a composite ID struct:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Generated automatically
|
||||||
|
pub struct UserRoleId {
|
||||||
|
pub user_id: i32,
|
||||||
|
pub role_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
let id = UserRoleId { user_id: 1, role_id: 2 };
|
||||||
|
let user_role = UserRole::find(pool, &id).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Relationships are not yet supported for entities with composite primary keys.
|
||||||
|
|
||||||
### Defaultable Fields
|
### Defaultable Fields
|
||||||
|
|
||||||
For fields with database defaults or auto-generated values, use the `defaultable` attribute:
|
For fields with database defaults or auto-generated values, use the `defaultable` attribute:
|
||||||
@ -534,10 +567,10 @@ cargo run help # For a list of all available actions
|
|||||||
- **Transaction Support**: Comprehensive transaction handling with atomic operations
|
- **Transaction Support**: Comprehensive transaction handling with atomic operations
|
||||||
|
|
||||||
### Medium Priority
|
### Medium Priority
|
||||||
|
- **Composite Key Relationships**: Add relationship support (one-to-one, one-to-many, many-to-many) for entities with composite primary keys
|
||||||
- **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
|
- **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
|
|
||||||
- **Soft Delete**: Optional soft delete with `deleted_at` timestamps
|
- **Soft Delete**: Optional soft delete with `deleted_at` timestamps
|
||||||
|
|
||||||
### Lower Priority
|
### Lower Priority
|
||||||
|
87
georm-macros/src/georm/composite_keys.rs
Normal file
87
georm-macros/src/georm/composite_keys.rs
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
use super::ir::GeormField;
|
||||||
|
use quote::quote;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum IdType {
|
||||||
|
Simple {
|
||||||
|
field_name: syn::Ident,
|
||||||
|
field_type: syn::Type,
|
||||||
|
},
|
||||||
|
Composite {
|
||||||
|
fields: Vec<IdField>,
|
||||||
|
field_type: syn::Ident,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct IdField {
|
||||||
|
pub name: syn::Ident,
|
||||||
|
pub ty: syn::Type,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_to_code(field: &GeormField) -> proc_macro2::TokenStream {
|
||||||
|
let ident = field.ident.clone();
|
||||||
|
let ty = field.ty.clone();
|
||||||
|
quote! {
|
||||||
|
pub #ident: #ty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_struct(
|
||||||
|
ast: &syn::DeriveInput,
|
||||||
|
fields: &[GeormField],
|
||||||
|
) -> (syn::Ident, proc_macro2::TokenStream) {
|
||||||
|
let struct_name = &ast.ident;
|
||||||
|
let id_struct_name = quote::format_ident!("{struct_name}Id");
|
||||||
|
let vis = &ast.vis;
|
||||||
|
let fields: Vec<proc_macro2::TokenStream> = fields
|
||||||
|
.iter()
|
||||||
|
.filter_map(|field| {
|
||||||
|
if field.id {
|
||||||
|
Some(field_to_code(field))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let code = quote! {
|
||||||
|
#vis struct #id_struct_name {
|
||||||
|
#(#fields),*
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(id_struct_name, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_primary_key(
|
||||||
|
ast: &syn::DeriveInput,
|
||||||
|
fields: &[GeormField],
|
||||||
|
) -> (IdType, proc_macro2::TokenStream) {
|
||||||
|
let georm_id_fields: Vec<&GeormField> = fields.iter().filter(|field| field.id).collect();
|
||||||
|
let id_fields: Vec<IdField> = georm_id_fields
|
||||||
|
.iter()
|
||||||
|
.map(|field| IdField {
|
||||||
|
name: field.ident.clone(),
|
||||||
|
ty: field.ty.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
match id_fields.len() {
|
||||||
|
0 => panic!("No ID field found"),
|
||||||
|
1 => (
|
||||||
|
IdType::Simple {
|
||||||
|
field_name: id_fields[0].name.clone(),
|
||||||
|
field_type: id_fields[0].ty.clone(),
|
||||||
|
},
|
||||||
|
quote! {},
|
||||||
|
),
|
||||||
|
_ => {
|
||||||
|
let (struct_name, struct_code) = generate_struct(ast, fields);
|
||||||
|
(
|
||||||
|
IdType::Composite {
|
||||||
|
fields: id_fields.clone(),
|
||||||
|
field_type: struct_name,
|
||||||
|
},
|
||||||
|
struct_code,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -138,7 +138,6 @@ pub fn derive_defaultable_struct(
|
|||||||
);
|
);
|
||||||
|
|
||||||
quote! {
|
quote! {
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
#vis struct #defaultable_struct_name {
|
#vis struct #defaultable_struct_name {
|
||||||
#(#defaultable_fields),*
|
#(#defaultable_fields),*
|
||||||
}
|
}
|
||||||
|
@ -31,14 +31,14 @@ pub struct M2MRelationshipComplete {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl M2MRelationshipComplete {
|
impl M2MRelationshipComplete {
|
||||||
pub fn new(other: &M2MRelationship, local_table: &String, local_id: String) -> Self {
|
pub fn new(other: &M2MRelationship, local_table: &String, local_id: &String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: other.name.clone(),
|
name: other.name.clone(),
|
||||||
entity: other.entity.clone(),
|
entity: other.entity.clone(),
|
||||||
link: other.link.clone(),
|
link: other.link.clone(),
|
||||||
local: Identifier {
|
local: Identifier {
|
||||||
table: local_table.to_string(),
|
table: local_table.to_string(),
|
||||||
id: local_id,
|
id: local_id.to_string(),
|
||||||
},
|
},
|
||||||
remote: Identifier {
|
remote: Identifier {
|
||||||
table: other.table.clone(),
|
table: other.table.clone(),
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
use ir::GeormField;
|
use ir::GeormField;
|
||||||
use quote::quote;
|
use quote::quote;
|
||||||
|
|
||||||
|
mod composite_keys;
|
||||||
mod defaultable_struct;
|
mod defaultable_struct;
|
||||||
mod ir;
|
mod ir;
|
||||||
mod relationships;
|
mod relationships;
|
||||||
mod trait_implementation;
|
mod trait_implementation;
|
||||||
|
|
||||||
fn extract_georm_field_attrs(
|
fn extract_georm_field_attrs(ast: &mut syn::DeriveInput) -> deluxe::Result<Vec<GeormField>> {
|
||||||
ast: &mut syn::DeriveInput,
|
|
||||||
) -> deluxe::Result<(Vec<GeormField>, GeormField)> {
|
|
||||||
let syn::Data::Struct(s) = &mut ast.data else {
|
let syn::Data::Struct(s) = &mut ast.data else {
|
||||||
return Err(syn::Error::new_spanned(
|
return Err(syn::Error::new_spanned(
|
||||||
ast,
|
ast,
|
||||||
@ -26,23 +25,13 @@ fn extract_georm_field_attrs(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|field| field.id)
|
.filter(|field| field.id)
|
||||||
.collect();
|
.collect();
|
||||||
match identifiers.len() {
|
if identifiers.is_empty() {
|
||||||
0 => Err(syn::Error::new_spanned(
|
Err(syn::Error::new_spanned(
|
||||||
ast,
|
ast,
|
||||||
"Struct {name} must have one identifier",
|
"Struct {name} must have one identifier",
|
||||||
)),
|
|
||||||
1 => Ok((fields, identifiers.first().unwrap().clone())),
|
|
||||||
_ => {
|
|
||||||
let id1 = identifiers.first().unwrap();
|
|
||||||
let id2 = identifiers.get(1).unwrap();
|
|
||||||
Err(syn::Error::new_spanned(
|
|
||||||
id2.field.clone(),
|
|
||||||
format!(
|
|
||||||
"Field {} cannot be an identifier, {} already is one.\nOnly one identifier is supported.",
|
|
||||||
id1.ident, id2.ident
|
|
||||||
),
|
|
||||||
))
|
))
|
||||||
}
|
} else {
|
||||||
|
Ok(fields)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,16 +41,23 @@ pub fn georm_derive_macro2(
|
|||||||
let mut ast: syn::DeriveInput = syn::parse2(item).expect("Failed to parse input");
|
let mut ast: syn::DeriveInput = syn::parse2(item).expect("Failed to parse input");
|
||||||
let struct_attrs: ir::GeormStructAttributes =
|
let struct_attrs: ir::GeormStructAttributes =
|
||||||
deluxe::extract_attributes(&mut ast).expect("Could not extract attributes from struct");
|
deluxe::extract_attributes(&mut ast).expect("Could not extract attributes from struct");
|
||||||
let (fields, id) = extract_georm_field_attrs(&mut ast)?;
|
let fields = extract_georm_field_attrs(&mut ast)?;
|
||||||
let relationships = relationships::derive_relationships(&ast, &struct_attrs, &fields, &id);
|
|
||||||
let trait_impl = trait_implementation::derive_trait(&ast, &struct_attrs.table, &fields, &id);
|
|
||||||
let defaultable_struct =
|
let defaultable_struct =
|
||||||
defaultable_struct::derive_defaultable_struct(&ast, &struct_attrs, &fields);
|
defaultable_struct::derive_defaultable_struct(&ast, &struct_attrs, &fields);
|
||||||
let from_row_impl = generate_from_row_impl(&ast, &fields);
|
let from_row_impl = generate_from_row_impl(&ast, &fields);
|
||||||
|
|
||||||
|
let (identifier, id_struct) = composite_keys::create_primary_key(&ast, &fields);
|
||||||
|
|
||||||
|
let relationships =
|
||||||
|
relationships::derive_relationships(&ast, &struct_attrs, &fields, &identifier);
|
||||||
|
let trait_impl =
|
||||||
|
trait_implementation::derive_trait(&ast, &struct_attrs.table, &fields, &identifier);
|
||||||
|
|
||||||
let code = quote! {
|
let code = quote! {
|
||||||
|
#id_struct
|
||||||
|
#defaultable_struct
|
||||||
#relationships
|
#relationships
|
||||||
#trait_impl
|
#trait_impl
|
||||||
#defaultable_struct
|
|
||||||
#from_row_impl
|
#from_row_impl
|
||||||
};
|
};
|
||||||
Ok(code)
|
Ok(code)
|
||||||
|
@ -2,6 +2,7 @@ use std::str::FromStr;
|
|||||||
|
|
||||||
use crate::georm::ir::m2m_relationship::M2MRelationshipComplete;
|
use crate::georm::ir::m2m_relationship::M2MRelationshipComplete;
|
||||||
|
|
||||||
|
use super::composite_keys::IdType;
|
||||||
use super::ir::GeormField;
|
use super::ir::GeormField;
|
||||||
use proc_macro2::TokenStream;
|
use proc_macro2::TokenStream;
|
||||||
use quote::quote;
|
use quote::quote;
|
||||||
@ -28,8 +29,24 @@ pub fn derive_relationships(
|
|||||||
ast: &syn::DeriveInput,
|
ast: &syn::DeriveInput,
|
||||||
struct_attrs: &super::ir::GeormStructAttributes,
|
struct_attrs: &super::ir::GeormStructAttributes,
|
||||||
fields: &[GeormField],
|
fields: &[GeormField],
|
||||||
id: &GeormField,
|
id: &IdType,
|
||||||
) -> TokenStream {
|
) -> TokenStream {
|
||||||
|
let id = match id {
|
||||||
|
IdType::Simple {
|
||||||
|
field_name,
|
||||||
|
field_type: _,
|
||||||
|
} => field_name.to_string(),
|
||||||
|
IdType::Composite {
|
||||||
|
fields: _,
|
||||||
|
field_type: _,
|
||||||
|
} => {
|
||||||
|
eprintln!(
|
||||||
|
"Warning: entity {}: Relationships are not supported for entities with composite primary keys yet",
|
||||||
|
ast.ident
|
||||||
|
);
|
||||||
|
return quote! {};
|
||||||
|
}
|
||||||
|
};
|
||||||
let struct_name = &ast.ident;
|
let struct_name = &ast.ident;
|
||||||
let one_to_one_local = derive(fields);
|
let one_to_one_local = derive(fields);
|
||||||
let one_to_one_remote = derive(&struct_attrs.one_to_one);
|
let one_to_one_remote = derive(&struct_attrs.one_to_one);
|
||||||
@ -37,7 +54,7 @@ pub fn derive_relationships(
|
|||||||
let many_to_many: Vec<M2MRelationshipComplete> = struct_attrs
|
let many_to_many: Vec<M2MRelationshipComplete> = struct_attrs
|
||||||
.many_to_many
|
.many_to_many
|
||||||
.iter()
|
.iter()
|
||||||
.map(|v| M2MRelationshipComplete::new(v, &struct_attrs.table, id.ident.to_string()))
|
.map(|v| M2MRelationshipComplete::new(v, &struct_attrs.table, &id))
|
||||||
.collect();
|
.collect();
|
||||||
let many_to_many = derive(&many_to_many);
|
let many_to_many = derive(&many_to_many);
|
||||||
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
use super::composite_keys::IdType;
|
||||||
use super::ir::GeormField;
|
use super::ir::GeormField;
|
||||||
use quote::quote;
|
use quote::quote;
|
||||||
|
|
||||||
@ -10,16 +11,40 @@ fn generate_find_all_query(table: &str) -> proc_macro2::TokenStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_find_query(table: &str, id: &GeormField) -> proc_macro2::TokenStream {
|
fn generate_find_query(table: &str, id: &IdType) -> proc_macro2::TokenStream {
|
||||||
let find_string = format!("SELECT * FROM {table} WHERE {} = $1", id.ident);
|
match id {
|
||||||
let ty = &id.ty;
|
IdType::Simple {
|
||||||
|
field_name,
|
||||||
|
field_type,
|
||||||
|
} => {
|
||||||
|
let find_string = format!("SELECT * FROM {table} WHERE {} = $1", field_name);
|
||||||
quote! {
|
quote! {
|
||||||
async fn find(pool: &::sqlx::PgPool, id: &#ty) -> ::sqlx::Result<Option<Self>> {
|
async fn find(pool: &::sqlx::PgPool, id: &#field_type) -> ::sqlx::Result<Option<Self>> {
|
||||||
::sqlx::query_as!(Self, #find_string, id)
|
::sqlx::query_as!(Self, #find_string, id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
IdType::Composite { fields, field_type } => {
|
||||||
|
let id_match_string = fields
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, field)| format!("{} = ${}", field.name, i + 1))
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(" AND ");
|
||||||
|
let id_members: Vec<syn::Ident> =
|
||||||
|
fields.iter().map(|field| field.name.clone()).collect();
|
||||||
|
let find_string = format!("SELECT * FROM {table} WHERE {id_match_string}");
|
||||||
|
quote! {
|
||||||
|
async fn find(pool: &::sqlx::PgPool, id: &#field_type) -> ::sqlx::Result<Option<Self>> {
|
||||||
|
::sqlx::query_as!(Self, #find_string, #(id.#id_members),*)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_create_query(table: &str, fields: &[GeormField]) -> proc_macro2::TokenStream {
|
fn generate_create_query(table: &str, fields: &[GeormField]) -> proc_macro2::TokenStream {
|
||||||
@ -50,28 +75,42 @@ fn generate_create_query(table: &str, fields: &[GeormField]) -> proc_macro2::Tok
|
|||||||
fn generate_update_query(
|
fn generate_update_query(
|
||||||
table: &str,
|
table: &str,
|
||||||
fields: &[GeormField],
|
fields: &[GeormField],
|
||||||
id: &GeormField,
|
id: &IdType,
|
||||||
) -> proc_macro2::TokenStream {
|
) -> proc_macro2::TokenStream {
|
||||||
let mut fields: Vec<&GeormField> = fields.iter().filter(|f| !f.id).collect();
|
let non_id_fields: Vec<syn::Ident> = fields
|
||||||
let update_columns = fields
|
.iter()
|
||||||
|
.filter_map(|f| if f.id { None } else { Some(f.ident.clone()) })
|
||||||
|
.collect();
|
||||||
|
let update_columns = non_id_fields
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, &field)| format!("{} = ${}", field.ident, i + 1))
|
.map(|(i, field)| format!("{} = ${}", field, i + 1))
|
||||||
.collect::<Vec<String>>()
|
.collect::<Vec<String>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
let update_string = format!(
|
let mut all_fields = non_id_fields.clone();
|
||||||
"UPDATE {table} SET {update_columns} WHERE {} = ${} RETURNING *",
|
let where_clause = match id {
|
||||||
id.ident,
|
IdType::Simple { field_name, .. } => {
|
||||||
fields.len() + 1
|
let where_clause = format!("{} = ${}", field_name, non_id_fields.len() + 1);
|
||||||
);
|
all_fields.push(field_name.clone());
|
||||||
fields.push(id);
|
where_clause
|
||||||
let field_idents: Vec<_> = fields.iter().map(|f| f.ident.clone()).collect();
|
}
|
||||||
|
IdType::Composite { fields, .. } => fields
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, field)| {
|
||||||
|
let where_clause = format!("{} = ${}", field.name, non_id_fields.len() + i + 1);
|
||||||
|
all_fields.push(field.name.clone());
|
||||||
|
where_clause
|
||||||
|
})
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(" AND "),
|
||||||
|
};
|
||||||
|
let update_string =
|
||||||
|
format!("UPDATE {table} SET {update_columns} WHERE {where_clause} RETURNING *");
|
||||||
quote! {
|
quote! {
|
||||||
async fn update(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Self> {
|
async fn update(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Self> {
|
||||||
::sqlx::query_as!(
|
::sqlx::query_as!(
|
||||||
Self,
|
Self, #update_string, #(self.#all_fields),*
|
||||||
#update_string,
|
|
||||||
#(self.#field_idents),*
|
|
||||||
)
|
)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await
|
.await
|
||||||
@ -79,12 +118,31 @@ fn generate_update_query(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_delete_query(table: &str, id: &GeormField) -> proc_macro2::TokenStream {
|
fn generate_delete_query(table: &str, id: &IdType) -> proc_macro2::TokenStream {
|
||||||
let delete_string = format!("DELETE FROM {table} WHERE {} = $1", id.ident);
|
let where_clause = match id {
|
||||||
let ty = &id.ty;
|
IdType::Simple { field_name, .. } => format!("{} = $1", field_name),
|
||||||
|
IdType::Composite { fields, .. } => fields
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, field)| format!("{} = ${}", field.name, i + 1))
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(" AND "),
|
||||||
|
};
|
||||||
|
let query_args = match id {
|
||||||
|
IdType::Simple { .. } => quote! { id },
|
||||||
|
IdType::Composite { fields, .. } => {
|
||||||
|
let fields: Vec<syn::Ident> = fields.iter().map(|f| f.name.clone()).collect();
|
||||||
|
quote! { #(id.#fields), * }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let id_type = match id {
|
||||||
|
IdType::Simple { field_type, .. } => quote! { #field_type },
|
||||||
|
IdType::Composite { field_type, .. } => quote! { #field_type },
|
||||||
|
};
|
||||||
|
let delete_string = format!("DELETE FROM {table} WHERE {where_clause}");
|
||||||
quote! {
|
quote! {
|
||||||
async fn delete_by_id(pool: &::sqlx::PgPool, id: &#ty) -> ::sqlx::Result<u64> {
|
async fn delete_by_id(pool: &::sqlx::PgPool, id: &#id_type) -> ::sqlx::Result<u64> {
|
||||||
let rows_affected = ::sqlx::query!(#delete_string, id)
|
let rows_affected = ::sqlx::query!(#delete_string, #query_args)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?
|
.await?
|
||||||
.rows_affected();
|
.rows_affected();
|
||||||
@ -92,7 +150,7 @@ fn generate_delete_query(table: &str, id: &GeormField) -> proc_macro2::TokenStre
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<u64> {
|
async fn delete(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<u64> {
|
||||||
Self::delete_by_id(pool, self.get_id()).await
|
Self::delete_by_id(pool, &self.get_id()).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -100,7 +158,7 @@ fn generate_delete_query(table: &str, id: &GeormField) -> proc_macro2::TokenStre
|
|||||||
fn generate_upsert_query(
|
fn generate_upsert_query(
|
||||||
table: &str,
|
table: &str,
|
||||||
fields: &[GeormField],
|
fields: &[GeormField],
|
||||||
id: &GeormField,
|
id: &IdType,
|
||||||
) -> proc_macro2::TokenStream {
|
) -> proc_macro2::TokenStream {
|
||||||
let inputs: Vec<String> = (1..=fields.len()).map(|num| format!("${num}")).collect();
|
let inputs: Vec<String> = (1..=fields.len()).map(|num| format!("${num}")).collect();
|
||||||
let columns = fields
|
let columns = fields
|
||||||
@ -109,6 +167,16 @@ fn generate_upsert_query(
|
|||||||
.collect::<Vec<String>>()
|
.collect::<Vec<String>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
|
let primary_key: proc_macro2::TokenStream = match id {
|
||||||
|
IdType::Simple { field_name, .. } => quote! {#field_name},
|
||||||
|
IdType::Composite { fields, .. } => {
|
||||||
|
let field_names: Vec<syn::Ident> = fields.iter().map(|f| f.name.clone()).collect();
|
||||||
|
quote! {
|
||||||
|
#(#field_names),*
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// For ON CONFLICT DO UPDATE, exclude the ID field from updates
|
// For ON CONFLICT DO UPDATE, exclude the ID field from updates
|
||||||
let update_assignments = fields
|
let update_assignments = fields
|
||||||
.iter()
|
.iter()
|
||||||
@ -120,7 +188,7 @@ fn generate_upsert_query(
|
|||||||
let upsert_string = format!(
|
let upsert_string = format!(
|
||||||
"INSERT INTO {table} ({columns}) VALUES ({}) ON CONFLICT ({}) DO UPDATE SET {update_assignments} RETURNING *",
|
"INSERT INTO {table} ({columns}) VALUES ({}) ON CONFLICT ({}) DO UPDATE SET {update_assignments} RETURNING *",
|
||||||
inputs.join(", "),
|
inputs.join(", "),
|
||||||
id.ident
|
primary_key
|
||||||
);
|
);
|
||||||
|
|
||||||
let field_idents: Vec<syn::Ident> = fields.iter().map(|f| f.ident.clone()).collect();
|
let field_idents: Vec<syn::Ident> = fields.iter().map(|f| f.ident.clone()).collect();
|
||||||
@ -138,12 +206,27 @@ fn generate_upsert_query(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_get_id(id: &GeormField) -> proc_macro2::TokenStream {
|
fn generate_get_id(id: &IdType) -> proc_macro2::TokenStream {
|
||||||
let ident = &id.ident;
|
match id {
|
||||||
let ty = &id.ty;
|
IdType::Simple {
|
||||||
|
field_name,
|
||||||
|
field_type,
|
||||||
|
} => {
|
||||||
quote! {
|
quote! {
|
||||||
fn get_id(&self) -> &#ty {
|
fn get_id(&self) -> #field_type {
|
||||||
&self.#ident
|
self.#field_name.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IdType::Composite { fields, field_type } => {
|
||||||
|
let field_names: Vec<syn::Ident> = fields.iter().map(|f| f.name.clone()).collect();
|
||||||
|
quote! {
|
||||||
|
fn get_id(&self) -> #field_type {
|
||||||
|
#field_type {
|
||||||
|
#(#field_names: self.#field_names),*
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -152,9 +235,12 @@ pub fn derive_trait(
|
|||||||
ast: &syn::DeriveInput,
|
ast: &syn::DeriveInput,
|
||||||
table: &str,
|
table: &str,
|
||||||
fields: &[GeormField],
|
fields: &[GeormField],
|
||||||
id: &GeormField,
|
id: &IdType,
|
||||||
) -> proc_macro2::TokenStream {
|
) -> proc_macro2::TokenStream {
|
||||||
let ty = &id.ty;
|
let ty = match id {
|
||||||
|
IdType::Simple { field_type, .. } => quote! {#field_type},
|
||||||
|
IdType::Composite { field_type, .. } => quote! {#field_type},
|
||||||
|
};
|
||||||
|
|
||||||
// define impl variables
|
// define impl variables
|
||||||
let ident = &ast.ident;
|
let ident = &ast.ident;
|
||||||
|
2
migrations/20250609181248_composite-key.down.sql
Normal file
2
migrations/20250609181248_composite-key.down.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
-- Add down migration script here
|
||||||
|
DROP TABLE IF EXISTS UserRoles;
|
7
migrations/20250609181248_composite-key.up.sql
Normal file
7
migrations/20250609181248_composite-key.up.sql
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
-- Add up migration script here
|
||||||
|
CREATE TABLE UserRoles (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
role_id INTEGER NOT NULL,
|
||||||
|
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_id, role_id)
|
||||||
|
);
|
@ -79,5 +79,5 @@ pub trait Georm<Id> {
|
|||||||
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send;
|
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send;
|
||||||
|
|
||||||
/// Returns the identifier of the entity.
|
/// Returns the identifier of the entity.
|
||||||
fn get_id(&self) -> &Id;
|
fn get_id(&self) -> Id;
|
||||||
}
|
}
|
||||||
|
64
src/lib.rs
64
src/lib.rs
@ -331,12 +331,64 @@
|
|||||||
//! `Option<T>`, you cannot mark it with `#[georm(defaultable)]`. This prevents
|
//! `Option<T>`, you cannot mark it with `#[georm(defaultable)]`. This prevents
|
||||||
//! `Option<Option<T>>` types.
|
//! `Option<Option<T>>` types.
|
||||||
//! - **Field visibility is preserved**: The generated defaultable struct maintains
|
//! - **Field visibility is preserved**: The generated defaultable struct maintains
|
||||||
//! the same field visibility (`pub`, `pub(crate)`, private) as the original struct.
|
//! the same field visibility (`pub`, `pub(crate)`, private) as the original
|
||||||
//! - **ID fields can be defaultable**: It's common to mark ID fields as defaultable
|
//! struct.
|
||||||
//! when they are auto-generated serials in PostgreSQL.
|
//! - **ID fields can be defaultable**: It's common to mark ID fields as
|
||||||
|
//! defaultable when they are auto-generated serials in PostgreSQL.
|
||||||
//! - **Only generates when needed**: The defaultable struct is only generated if
|
//! - **Only generates when needed**: The defaultable struct is only generated if
|
||||||
//! at least one field is marked as defaultable.
|
//! at least one field is marked as defaultable.
|
||||||
//!
|
//!
|
||||||
|
//! ## Composite Primary Keys
|
||||||
|
//!
|
||||||
|
//! Georm supports composite primary keys by marking multiple fields with
|
||||||
|
//! `#[georm(id)]`:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! #[derive(sqlx::FromRow, Georm)]
|
||||||
|
//! #[georm(table = "user_roles")]
|
||||||
|
//! pub struct UserRole {
|
||||||
|
//! #[georm(id)]
|
||||||
|
//! user_id: i32,
|
||||||
|
//! #[georm(id)]
|
||||||
|
//! role_id: i32,
|
||||||
|
//! assigned_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! When multiple fields are marked as ID fields, Georm automatically generates a
|
||||||
|
//! composite ID struct:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! // Generated automatically by the macro
|
||||||
|
//! pub struct UserRoleId {
|
||||||
|
//! pub user_id: i32,
|
||||||
|
//! pub role_id: i32,
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! This allows you to use the generated ID struct with all Georm methods:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! // Find by composite key
|
||||||
|
//! let id = UserRoleId { user_id: 1, role_id: 2 };
|
||||||
|
//! let user_role = UserRole::find(&pool, &id).await?;
|
||||||
|
//!
|
||||||
|
//! // Delete by composite key
|
||||||
|
//! UserRole::delete_by_id(&pool, &id).await?;
|
||||||
|
//!
|
||||||
|
//! // Get composite ID from instance
|
||||||
|
//! let user_role = UserRole { user_id: 1, role_id: 2, assigned_at: chrono::Utc::now() };
|
||||||
|
//! let id = user_role.get_id(); // Returns UserRoleId
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ### Composite Key Limitations
|
||||||
|
//!
|
||||||
|
//! - **Relationships not supported**: Entities with composite primary keys cannot
|
||||||
|
//! yet define relationships (one-to-one, one-to-many, many-to-many) as those
|
||||||
|
//! features require single-field primary keys.
|
||||||
|
//! - **ID struct naming**: The generated ID struct follows the pattern
|
||||||
|
//! `{EntityName}Id`.
|
||||||
|
//!
|
||||||
//! ## Limitations
|
//! ## Limitations
|
||||||
//! ### Database
|
//! ### Database
|
||||||
//!
|
//!
|
||||||
@ -346,9 +398,9 @@
|
|||||||
//! ## Identifiers
|
//! ## Identifiers
|
||||||
//!
|
//!
|
||||||
//! Identifiers, or primary keys from the point of view of the database, may
|
//! Identifiers, or primary keys from the point of view of the database, may
|
||||||
//! only be simple types recognized by SQLx. They also cannot be arrays, and
|
//! be simple types recognized by SQLx or composite keys (multiple fields marked
|
||||||
//! optionals are only supported in one-to-one relationships when explicitly
|
//! with `#[georm(id)]`). Single primary keys cannot be arrays, and optionals are
|
||||||
//! marked as nullables.
|
//! only supported in one-to-one relationships when explicitly marked as nullables.
|
||||||
|
|
||||||
pub use georm_macros::Georm;
|
pub use georm_macros::Georm;
|
||||||
|
|
||||||
|
112
tests/composite_key.rs
Normal file
112
tests/composite_key.rs
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
use georm::Georm;
|
||||||
|
|
||||||
|
mod models;
|
||||||
|
use models::{UserRole, UserRoleId};
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("composite_key"))]
|
||||||
|
async fn composite_key_find(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
// This will test the find query generation bug
|
||||||
|
let id = models::UserRoleId {
|
||||||
|
user_id: 1,
|
||||||
|
role_id: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = UserRole::find(&pool, &id).await?;
|
||||||
|
assert!(result.is_some());
|
||||||
|
|
||||||
|
let user_role = result.unwrap();
|
||||||
|
assert_eq!(1, user_role.user_id);
|
||||||
|
assert_eq!(1, user_role.role_id);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn composite_key_get_id() {
|
||||||
|
let user_role = UserRole {
|
||||||
|
user_id: 1,
|
||||||
|
role_id: 1,
|
||||||
|
assigned_at: chrono::Local::now().into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// This will test the get_id implementation bug
|
||||||
|
let id = user_role.get_id();
|
||||||
|
assert_eq!(1, id.user_id);
|
||||||
|
assert_eq!(1, id.role_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("composite_key"))]
|
||||||
|
async fn composite_key_create_or_update(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let new_user_role = UserRole {
|
||||||
|
user_id: 5,
|
||||||
|
role_id: 2,
|
||||||
|
assigned_at: chrono::Local::now().into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// This will test the upsert query generation bug
|
||||||
|
let result = new_user_role.create_or_update(&pool).await?;
|
||||||
|
assert_eq!(5, result.user_id);
|
||||||
|
assert_eq!(2, result.role_id);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("composite_key"))]
|
||||||
|
async fn composite_key_delete(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let id = models::UserRoleId {
|
||||||
|
user_id: 1,
|
||||||
|
role_id: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let rows_affected = UserRole::delete_by_id(&pool, &id).await?;
|
||||||
|
assert_eq!(1, rows_affected);
|
||||||
|
|
||||||
|
// Verify it's deleted
|
||||||
|
let result = UserRole::find(&pool, &id).await?;
|
||||||
|
assert!(result.is_none());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("composite_key"))]
|
||||||
|
async fn composite_key_find_all(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let all_user_roles = UserRole::find_all(&pool).await?;
|
||||||
|
assert_eq!(4, all_user_roles.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("composite_key"))]
|
||||||
|
async fn composite_key_create(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let new_user_role = UserRole {
|
||||||
|
user_id: 10,
|
||||||
|
role_id: 5,
|
||||||
|
assigned_at: chrono::Local::now().into(),
|
||||||
|
};
|
||||||
|
let result = new_user_role.create(&pool).await?;
|
||||||
|
assert_eq!(new_user_role.user_id, result.user_id);
|
||||||
|
assert_eq!(new_user_role.role_id, result.role_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("composite_key"))]
|
||||||
|
async fn composite_key_update(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let mut user_role = UserRole::find(
|
||||||
|
&pool,
|
||||||
|
&UserRoleId {
|
||||||
|
user_id: 1,
|
||||||
|
role_id: 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.unwrap();
|
||||||
|
let now: chrono::DateTime<chrono::Utc> = chrono::Local::now().into();
|
||||||
|
user_role.assigned_at = now;
|
||||||
|
let updated = user_role.update(&pool).await?;
|
||||||
|
assert_eq!(
|
||||||
|
now.timestamp_millis(),
|
||||||
|
updated.assigned_at.timestamp_millis()
|
||||||
|
);
|
||||||
|
assert_eq!(1, updated.user_id);
|
||||||
|
assert_eq!(1, updated.role_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
6
tests/fixtures/composite_key.sql
vendored
Normal file
6
tests/fixtures/composite_key.sql
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
INSERT INTO UserRoles (user_id, role_id, assigned_at)
|
||||||
|
VALUES
|
||||||
|
(1, 1, '2024-01-01 10:00:00+00:00'),
|
||||||
|
(1, 2, '2024-01-02 11:00:00+00:00'),
|
||||||
|
(2, 1, '2024-01-03 12:00:00+00:00'),
|
||||||
|
(3, 3, '2024-01-04 13:00:00+00:00');
|
@ -94,3 +94,14 @@ pub struct Genre {
|
|||||||
id: i32,
|
id: i32,
|
||||||
name: String,
|
name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Georm, PartialEq, Eq, Default)]
|
||||||
|
#[georm(table = "UserRoles")]
|
||||||
|
pub struct UserRole {
|
||||||
|
#[georm(id)]
|
||||||
|
pub user_id: i32,
|
||||||
|
#[georm(id)]
|
||||||
|
pub role_id: i32,
|
||||||
|
#[georm(defaultable)]
|
||||||
|
pub assigned_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
@ -143,12 +143,12 @@ async fn delete_by_id_should_delete_only_one_entry(pool: sqlx::PgPool) -> sqlx::
|
|||||||
let id = 2;
|
let id = 2;
|
||||||
let all_authors = Author::find_all(&pool).await?;
|
let all_authors = Author::find_all(&pool).await?;
|
||||||
assert_eq!(3, all_authors.len());
|
assert_eq!(3, all_authors.len());
|
||||||
assert!(all_authors.iter().any(|author| author.get_id() == &id));
|
assert!(all_authors.iter().any(|author| author.get_id() == id));
|
||||||
let result = Author::delete_by_id(&pool, &id).await?;
|
let result = Author::delete_by_id(&pool, &id).await?;
|
||||||
assert_eq!(1, result);
|
assert_eq!(1, result);
|
||||||
let all_authors = Author::find_all(&pool).await?;
|
let all_authors = Author::find_all(&pool).await?;
|
||||||
assert_eq!(2, all_authors.len());
|
assert_eq!(2, all_authors.len());
|
||||||
assert!(all_authors.iter().all(|author| author.get_id() != &id));
|
assert!(all_authors.iter().all(|author| author.get_id() != id));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user