mirror of
				https://github.com/Phundrak/georm.git
				synced 2025-11-04 01:11:10 +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"
 | 
			
		||||
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]]
 | 
			
		||||
name = "anstream"
 | 
			
		||||
version = "0.6.19"
 | 
			
		||||
@ -145,6 +160,12 @@ dependencies = [
 | 
			
		||||
 "generic-array",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "bumpalo"
 | 
			
		||||
version = "3.18.1"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee"
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "byteorder"
 | 
			
		||||
version = "1.5.0"
 | 
			
		||||
@ -157,12 +178,36 @@ version = "1.10.1"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "cc"
 | 
			
		||||
version = "1.2.26"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "956a5e21988b87f372569b66183b78babf23ebc2e744b733e4350a752c4dafac"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "shlex",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "cfg-if"
 | 
			
		||||
version = "1.0.0"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
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]]
 | 
			
		||||
name = "clap"
 | 
			
		||||
version = "4.5.39"
 | 
			
		||||
@ -224,6 +269,12 @@ version = "0.9.6"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "core-foundation-sys"
 | 
			
		||||
version = "0.8.7"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "cpufeatures"
 | 
			
		||||
version = "0.2.17"
 | 
			
		||||
@ -552,6 +603,7 @@ dependencies = [
 | 
			
		||||
name = "georm"
 | 
			
		||||
version = "0.1.1"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "chrono",
 | 
			
		||||
 "georm-macros",
 | 
			
		||||
 "rand 0.9.1",
 | 
			
		||||
 "sqlx",
 | 
			
		||||
@ -673,6 +725,30 @@ dependencies = [
 | 
			
		||||
 "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]]
 | 
			
		||||
name = "icu_collections"
 | 
			
		||||
version = "2.0.0"
 | 
			
		||||
@ -825,6 +901,16 @@ version = "1.0.15"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
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]]
 | 
			
		||||
name = "lazy_static"
 | 
			
		||||
version = "1.5.0"
 | 
			
		||||
@ -1232,6 +1318,12 @@ version = "0.1.24"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "rustversion"
 | 
			
		||||
version = "1.0.21"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d"
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "ryu"
 | 
			
		||||
version = "1.0.20"
 | 
			
		||||
@ -1310,6 +1402,12 @@ dependencies = [
 | 
			
		||||
 "digest",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "shlex"
 | 
			
		||||
version = "1.3.0"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "signal-hook"
 | 
			
		||||
version = "0.3.18"
 | 
			
		||||
@ -1418,6 +1516,7 @@ checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "base64",
 | 
			
		||||
 "bytes",
 | 
			
		||||
 "chrono",
 | 
			
		||||
 "crc",
 | 
			
		||||
 "crossbeam-queue",
 | 
			
		||||
 "either",
 | 
			
		||||
@ -1474,7 +1573,9 @@ dependencies = [
 | 
			
		||||
 "serde_json",
 | 
			
		||||
 "sha2",
 | 
			
		||||
 "sqlx-core",
 | 
			
		||||
 "sqlx-mysql",
 | 
			
		||||
 "sqlx-postgres",
 | 
			
		||||
 "sqlx-sqlite",
 | 
			
		||||
 "syn",
 | 
			
		||||
 "tokio",
 | 
			
		||||
 "url",
 | 
			
		||||
@ -1491,6 +1592,7 @@ dependencies = [
 | 
			
		||||
 "bitflags 2.9.1",
 | 
			
		||||
 "byteorder",
 | 
			
		||||
 "bytes",
 | 
			
		||||
 "chrono",
 | 
			
		||||
 "crc",
 | 
			
		||||
 "digest",
 | 
			
		||||
 "dotenvy",
 | 
			
		||||
@ -1511,6 +1613,7 @@ dependencies = [
 | 
			
		||||
 "percent-encoding",
 | 
			
		||||
 "rand 0.8.5",
 | 
			
		||||
 "rsa",
 | 
			
		||||
 "serde",
 | 
			
		||||
 "sha1",
 | 
			
		||||
 "sha2",
 | 
			
		||||
 "smallvec",
 | 
			
		||||
@ -1531,6 +1634,7 @@ dependencies = [
 | 
			
		||||
 "base64",
 | 
			
		||||
 "bitflags 2.9.1",
 | 
			
		||||
 "byteorder",
 | 
			
		||||
 "chrono",
 | 
			
		||||
 "crc",
 | 
			
		||||
 "dotenvy",
 | 
			
		||||
 "etcetera",
 | 
			
		||||
@ -1565,6 +1669,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "atoi",
 | 
			
		||||
 "chrono",
 | 
			
		||||
 "flume",
 | 
			
		||||
 "futures-channel",
 | 
			
		||||
 "futures-core",
 | 
			
		||||
@ -1574,6 +1679,7 @@ dependencies = [
 | 
			
		||||
 "libsqlite3-sys",
 | 
			
		||||
 "log",
 | 
			
		||||
 "percent-encoding",
 | 
			
		||||
 "serde",
 | 
			
		||||
 "serde_urlencoded",
 | 
			
		||||
 "sqlx-core",
 | 
			
		||||
 "thiserror",
 | 
			
		||||
@ -1883,6 +1989,64 @@ version = "0.1.0"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
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]]
 | 
			
		||||
name = "whoami"
 | 
			
		||||
version = "1.6.0"
 | 
			
		||||
@ -1915,6 +2079,65 @@ version = "0.4.0"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
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]]
 | 
			
		||||
name = "windows-sys"
 | 
			
		||||
version = "0.48.0"
 | 
			
		||||
 | 
			
		||||
@ -39,8 +39,14 @@ sqlx = { workspace = true }
 | 
			
		||||
georm-macros = { workspace = true }
 | 
			
		||||
 | 
			
		||||
[dev-dependencies]
 | 
			
		||||
chrono = { version = "0.4", features = ["serde"] }
 | 
			
		||||
rand = "0.9"
 | 
			
		||||
 | 
			
		||||
[dev-dependencies.sqlx]
 | 
			
		||||
version = "0.8.6"
 | 
			
		||||
default-features = false
 | 
			
		||||
features = ["postgres", "runtime-tokio", "macros", "migrate", "chrono"]
 | 
			
		||||
 | 
			
		||||
[workspace.lints.rust]
 | 
			
		||||
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
 | 
			
		||||
- **Simple API**: Intuitive derive macros for common operations
 | 
			
		||||
- **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
 | 
			
		||||
- **PostgreSQL Native**: Optimized for PostgreSQL features and data types
 | 
			
		||||
 | 
			
		||||
@ -148,6 +149,38 @@ async fn example(pool: &PgPool) -> sqlx::Result<()> {
 | 
			
		||||
 | 
			
		||||
## 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
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
### 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
 | 
			
		||||
- **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
 | 
			
		||||
- **Composite Primary Keys**: Multi-field primary key support
 | 
			
		||||
- **Soft Delete**: Optional soft delete with `deleted_at` timestamps
 | 
			
		||||
 | 
			
		||||
### 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! {
 | 
			
		||||
        #[derive(Debug, Clone)]
 | 
			
		||||
        #vis struct #defaultable_struct_name {
 | 
			
		||||
            #(#defaultable_fields),*
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -31,14 +31,14 @@ pub struct 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 {
 | 
			
		||||
            name: other.name.clone(),
 | 
			
		||||
            entity: other.entity.clone(),
 | 
			
		||||
            link: other.link.clone(),
 | 
			
		||||
            local: Identifier {
 | 
			
		||||
                table: local_table.to_string(),
 | 
			
		||||
                id: local_id,
 | 
			
		||||
                id: local_id.to_string(),
 | 
			
		||||
            },
 | 
			
		||||
            remote: Identifier {
 | 
			
		||||
                table: other.table.clone(),
 | 
			
		||||
 | 
			
		||||
@ -1,14 +1,13 @@
 | 
			
		||||
use ir::GeormField;
 | 
			
		||||
use quote::quote;
 | 
			
		||||
 | 
			
		||||
mod composite_keys;
 | 
			
		||||
mod defaultable_struct;
 | 
			
		||||
mod ir;
 | 
			
		||||
mod relationships;
 | 
			
		||||
mod trait_implementation;
 | 
			
		||||
 | 
			
		||||
fn extract_georm_field_attrs(
 | 
			
		||||
    ast: &mut syn::DeriveInput,
 | 
			
		||||
) -> deluxe::Result<(Vec<GeormField>, GeormField)> {
 | 
			
		||||
fn extract_georm_field_attrs(ast: &mut syn::DeriveInput) -> deluxe::Result<Vec<GeormField>> {
 | 
			
		||||
    let syn::Data::Struct(s) = &mut ast.data else {
 | 
			
		||||
        return Err(syn::Error::new_spanned(
 | 
			
		||||
            ast,
 | 
			
		||||
@ -26,23 +25,13 @@ fn extract_georm_field_attrs(
 | 
			
		||||
        .into_iter()
 | 
			
		||||
        .filter(|field| field.id)
 | 
			
		||||
        .collect();
 | 
			
		||||
    match identifiers.len() {
 | 
			
		||||
        0 => Err(syn::Error::new_spanned(
 | 
			
		||||
    if identifiers.is_empty() {
 | 
			
		||||
        Err(syn::Error::new_spanned(
 | 
			
		||||
            ast,
 | 
			
		||||
            "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 struct_attrs: ir::GeormStructAttributes =
 | 
			
		||||
        deluxe::extract_attributes(&mut ast).expect("Could not extract attributes from struct");
 | 
			
		||||
    let (fields, id) = 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 fields = extract_georm_field_attrs(&mut ast)?;
 | 
			
		||||
    let defaultable_struct =
 | 
			
		||||
        defaultable_struct::derive_defaultable_struct(&ast, &struct_attrs, &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! {
 | 
			
		||||
        #id_struct
 | 
			
		||||
        #defaultable_struct
 | 
			
		||||
        #relationships
 | 
			
		||||
        #trait_impl
 | 
			
		||||
        #defaultable_struct
 | 
			
		||||
        #from_row_impl
 | 
			
		||||
    };
 | 
			
		||||
    Ok(code)
 | 
			
		||||
 | 
			
		||||
@ -2,6 +2,7 @@ use std::str::FromStr;
 | 
			
		||||
 | 
			
		||||
use crate::georm::ir::m2m_relationship::M2MRelationshipComplete;
 | 
			
		||||
 | 
			
		||||
use super::composite_keys::IdType;
 | 
			
		||||
use super::ir::GeormField;
 | 
			
		||||
use proc_macro2::TokenStream;
 | 
			
		||||
use quote::quote;
 | 
			
		||||
@ -28,8 +29,24 @@ pub fn derive_relationships(
 | 
			
		||||
    ast: &syn::DeriveInput,
 | 
			
		||||
    struct_attrs: &super::ir::GeormStructAttributes,
 | 
			
		||||
    fields: &[GeormField],
 | 
			
		||||
    id: &GeormField,
 | 
			
		||||
    id: &IdType,
 | 
			
		||||
) -> 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 one_to_one_local = derive(fields);
 | 
			
		||||
    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
 | 
			
		||||
        .many_to_many
 | 
			
		||||
        .iter()
 | 
			
		||||
        .map(|v| M2MRelationshipComplete::new(v, &struct_attrs.table, id.ident.to_string()))
 | 
			
		||||
        .map(|v| M2MRelationshipComplete::new(v, &struct_attrs.table, &id))
 | 
			
		||||
        .collect();
 | 
			
		||||
    let many_to_many = derive(&many_to_many);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,3 +1,4 @@
 | 
			
		||||
use super::composite_keys::IdType;
 | 
			
		||||
use super::ir::GeormField;
 | 
			
		||||
use quote::quote;
 | 
			
		||||
 | 
			
		||||
@ -10,14 +11,38 @@ fn generate_find_all_query(table: &str) -> proc_macro2::TokenStream {
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn generate_find_query(table: &str, id: &GeormField) -> proc_macro2::TokenStream {
 | 
			
		||||
    let find_string = format!("SELECT * FROM {table} WHERE {} = $1", id.ident);
 | 
			
		||||
    let ty = &id.ty;
 | 
			
		||||
    quote! {
 | 
			
		||||
        async fn find(pool: &::sqlx::PgPool, id: &#ty) -> ::sqlx::Result<Option<Self>> {
 | 
			
		||||
            ::sqlx::query_as!(Self, #find_string, id)
 | 
			
		||||
                .fetch_optional(pool)
 | 
			
		||||
                .await
 | 
			
		||||
fn generate_find_query(table: &str, id: &IdType) -> proc_macro2::TokenStream {
 | 
			
		||||
    match id {
 | 
			
		||||
        IdType::Simple {
 | 
			
		||||
            field_name,
 | 
			
		||||
            field_type,
 | 
			
		||||
        } => {
 | 
			
		||||
            let find_string = format!("SELECT * FROM {table} WHERE {} = $1", field_name);
 | 
			
		||||
            quote! {
 | 
			
		||||
                async fn find(pool: &::sqlx::PgPool, id: &#field_type) -> ::sqlx::Result<Option<Self>> {
 | 
			
		||||
                    ::sqlx::query_as!(Self, #find_string, id)
 | 
			
		||||
                    .fetch_optional(pool)
 | 
			
		||||
                    .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
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -50,28 +75,42 @@ fn generate_create_query(table: &str, fields: &[GeormField]) -> proc_macro2::Tok
 | 
			
		||||
fn generate_update_query(
 | 
			
		||||
    table: &str,
 | 
			
		||||
    fields: &[GeormField],
 | 
			
		||||
    id: &GeormField,
 | 
			
		||||
    id: &IdType,
 | 
			
		||||
) -> proc_macro2::TokenStream {
 | 
			
		||||
    let mut fields: Vec<&GeormField> = fields.iter().filter(|f| !f.id).collect();
 | 
			
		||||
    let update_columns = fields
 | 
			
		||||
    let non_id_fields: Vec<syn::Ident> = fields
 | 
			
		||||
        .iter()
 | 
			
		||||
        .filter_map(|f| if f.id { None } else { Some(f.ident.clone()) })
 | 
			
		||||
        .collect();
 | 
			
		||||
    let update_columns = non_id_fields
 | 
			
		||||
        .iter()
 | 
			
		||||
        .enumerate()
 | 
			
		||||
        .map(|(i, &field)| format!("{} = ${}", field.ident, i + 1))
 | 
			
		||||
        .map(|(i, field)| format!("{} = ${}", field, i + 1))
 | 
			
		||||
        .collect::<Vec<String>>()
 | 
			
		||||
        .join(", ");
 | 
			
		||||
    let update_string = format!(
 | 
			
		||||
        "UPDATE {table} SET {update_columns} WHERE {} = ${} RETURNING *",
 | 
			
		||||
        id.ident,
 | 
			
		||||
        fields.len() + 1
 | 
			
		||||
    );
 | 
			
		||||
    fields.push(id);
 | 
			
		||||
    let field_idents: Vec<_> = fields.iter().map(|f| f.ident.clone()).collect();
 | 
			
		||||
    let mut all_fields = non_id_fields.clone();
 | 
			
		||||
    let where_clause = match id {
 | 
			
		||||
        IdType::Simple { field_name, .. } => {
 | 
			
		||||
            let where_clause = format!("{} = ${}", field_name, non_id_fields.len() + 1);
 | 
			
		||||
            all_fields.push(field_name.clone());
 | 
			
		||||
            where_clause
 | 
			
		||||
        }
 | 
			
		||||
        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! {
 | 
			
		||||
        async fn update(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Self> {
 | 
			
		||||
            ::sqlx::query_as!(
 | 
			
		||||
                Self,
 | 
			
		||||
                #update_string,
 | 
			
		||||
                #(self.#field_idents),*
 | 
			
		||||
                Self, #update_string, #(self.#all_fields),*
 | 
			
		||||
            )
 | 
			
		||||
            .fetch_one(pool)
 | 
			
		||||
            .await
 | 
			
		||||
@ -79,12 +118,31 @@ fn generate_update_query(
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn generate_delete_query(table: &str, id: &GeormField) -> proc_macro2::TokenStream {
 | 
			
		||||
    let delete_string = format!("DELETE FROM {table} WHERE {} = $1", id.ident);
 | 
			
		||||
    let ty = &id.ty;
 | 
			
		||||
fn generate_delete_query(table: &str, id: &IdType) -> proc_macro2::TokenStream {
 | 
			
		||||
    let where_clause = match id {
 | 
			
		||||
        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! {
 | 
			
		||||
        async fn delete_by_id(pool: &::sqlx::PgPool, id: &#ty) -> ::sqlx::Result<u64> {
 | 
			
		||||
            let rows_affected = ::sqlx::query!(#delete_string, id)
 | 
			
		||||
        async fn delete_by_id(pool: &::sqlx::PgPool, id: &#id_type) -> ::sqlx::Result<u64> {
 | 
			
		||||
            let rows_affected = ::sqlx::query!(#delete_string, #query_args)
 | 
			
		||||
                .execute(pool)
 | 
			
		||||
                .await?
 | 
			
		||||
                .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> {
 | 
			
		||||
            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(
 | 
			
		||||
    table: &str,
 | 
			
		||||
    fields: &[GeormField],
 | 
			
		||||
    id: &GeormField,
 | 
			
		||||
    id: &IdType,
 | 
			
		||||
) -> proc_macro2::TokenStream {
 | 
			
		||||
    let inputs: Vec<String> = (1..=fields.len()).map(|num| format!("${num}")).collect();
 | 
			
		||||
    let columns = fields
 | 
			
		||||
@ -109,6 +167,16 @@ fn generate_upsert_query(
 | 
			
		||||
        .collect::<Vec<String>>()
 | 
			
		||||
        .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
 | 
			
		||||
    let update_assignments = fields
 | 
			
		||||
        .iter()
 | 
			
		||||
@ -120,7 +188,7 @@ fn generate_upsert_query(
 | 
			
		||||
    let upsert_string = format!(
 | 
			
		||||
        "INSERT INTO {table} ({columns}) VALUES ({}) ON CONFLICT ({}) DO UPDATE SET {update_assignments} RETURNING *",
 | 
			
		||||
        inputs.join(", "),
 | 
			
		||||
        id.ident
 | 
			
		||||
        primary_key
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    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 {
 | 
			
		||||
    let ident = &id.ident;
 | 
			
		||||
    let ty = &id.ty;
 | 
			
		||||
    quote! {
 | 
			
		||||
        fn get_id(&self) -> &#ty {
 | 
			
		||||
            &self.#ident
 | 
			
		||||
fn generate_get_id(id: &IdType) -> proc_macro2::TokenStream {
 | 
			
		||||
    match id {
 | 
			
		||||
        IdType::Simple {
 | 
			
		||||
            field_name,
 | 
			
		||||
            field_type,
 | 
			
		||||
        } => {
 | 
			
		||||
            quote! {
 | 
			
		||||
                fn get_id(&self) -> #field_type {
 | 
			
		||||
                    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,
 | 
			
		||||
    table: &str,
 | 
			
		||||
    fields: &[GeormField],
 | 
			
		||||
    id: &GeormField,
 | 
			
		||||
    id: &IdType,
 | 
			
		||||
) -> 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
 | 
			
		||||
    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;
 | 
			
		||||
 | 
			
		||||
    /// 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<Option<T>>` types.
 | 
			
		||||
//! - **Field visibility is preserved**: The generated defaultable struct maintains
 | 
			
		||||
//!   the same field visibility (`pub`, `pub(crate)`, private) as the original struct.
 | 
			
		||||
//! - **ID fields can be defaultable**: It's common to mark ID fields as defaultable
 | 
			
		||||
//!   when they are auto-generated serials in PostgreSQL.
 | 
			
		||||
//!   the same field visibility (`pub`, `pub(crate)`, private) as the original
 | 
			
		||||
//!   struct.
 | 
			
		||||
//! - **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
 | 
			
		||||
//!   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
 | 
			
		||||
//! ### Database
 | 
			
		||||
//!
 | 
			
		||||
@ -346,9 +398,9 @@
 | 
			
		||||
//! ## Identifiers
 | 
			
		||||
//!
 | 
			
		||||
//! 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
 | 
			
		||||
//! optionals are only supported in one-to-one relationships when explicitly
 | 
			
		||||
//! marked as nullables.
 | 
			
		||||
//! be simple types recognized by SQLx or composite keys (multiple fields marked
 | 
			
		||||
//! with `#[georm(id)]`). Single primary keys cannot be arrays, and optionals are
 | 
			
		||||
//! only supported in one-to-one relationships when explicitly marked as nullables.
 | 
			
		||||
 | 
			
		||||
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,
 | 
			
		||||
    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 all_authors = Author::find_all(&pool).await?;
 | 
			
		||||
    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?;
 | 
			
		||||
    assert_eq!(1, result);
 | 
			
		||||
    let all_authors = Author::find_all(&pool).await?;
 | 
			
		||||
    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(())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user