Compare commits
7 Commits
96ac2aa979
...
d82f9fe2f4
Author | SHA1 | Date | |
---|---|---|---|
d82f9fe2f4 | |||
59eb96b9c8 | |||
f7cdcb1563 | |||
003af71107 | |||
b70b4b7a81 | |||
86e29fa2dc | |||
bca0619f30 |
60
.github/ISSUE_TEMPLATE/BUG-REPORT.yml
vendored
Normal file
60
.github/ISSUE_TEMPLATE/BUG-REPORT.yml
vendored
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: File a bug report
|
||||||
|
title: "[Bug]: "
|
||||||
|
labels: ["bug/unconfirmed"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to fill out this bug report!
|
||||||
|
- type: textarea
|
||||||
|
id: expected-behaviour
|
||||||
|
attributes:
|
||||||
|
label: Expected behaviour
|
||||||
|
description: How do you expect Georm to behave?
|
||||||
|
value: "Something should happen"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: what-happened
|
||||||
|
attributes:
|
||||||
|
label: Actual behaviour
|
||||||
|
description: How does the actual behaviour differ from the expected behaviour?
|
||||||
|
value: "Something else happened"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: package-version
|
||||||
|
attributes:
|
||||||
|
label: Georm version
|
||||||
|
description: What version of Georm are you using?
|
||||||
|
options:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
- something else (please specify)
|
||||||
|
- type: dropdown
|
||||||
|
id: source
|
||||||
|
attributes:
|
||||||
|
label: Library source
|
||||||
|
description: From which source did you get the backend?
|
||||||
|
options:
|
||||||
|
- Crates.io
|
||||||
|
- Git version
|
||||||
|
- something else (please specify)
|
||||||
|
- type: textarea
|
||||||
|
id: rust-version
|
||||||
|
attributes:
|
||||||
|
label: Rust version
|
||||||
|
description: Which version of Rust did you use to build Georm?
|
||||||
|
value: "Rust 1.y.z"
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Relevant code or log output
|
||||||
|
description: Please copy and pase any relevant code or log output. This will be automatically formatted into code, so no need for backticks
|
||||||
|
render: text
|
||||||
|
- type: textarea
|
||||||
|
id: other-info
|
||||||
|
attributes:
|
||||||
|
label: Other relevant information
|
||||||
|
description: Please provide any other information which could be relevant to the issue (PostgreSQL version? Upstream bug?)
|
33
.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml
vendored
Normal file
33
.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml
vendored
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Request a new feature
|
||||||
|
title: "[Feature Request]: "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to request a new feature!
|
||||||
|
- type: textarea
|
||||||
|
id: feature-description
|
||||||
|
attributes:
|
||||||
|
label: New feature
|
||||||
|
description: Description of the new feature
|
||||||
|
value: "New automatic method should do thing"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: feature-reason
|
||||||
|
attributes:
|
||||||
|
label: Why this new feature
|
||||||
|
description: Describe why this new feature should be added to Georm
|
||||||
|
value: "I use that workaround very often, this new method will simplify it"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: ideas-implementation
|
||||||
|
attributes:
|
||||||
|
label: Implementation ideas and additional thoughts
|
||||||
|
description: Do you have an idea on how to implement it?
|
||||||
|
value: "It could be implemented doing foo, bar, and baz"
|
||||||
|
validations:
|
||||||
|
required: false
|
47
.github/workflows/ci.yaml
vendored
Normal file
47
.github/workflows/ci.yaml
vendored
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
name: CI
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://georm:georm@postgres:5432/georm
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: ${{ github.ref != 'ref/heads/master' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:js-latest
|
||||||
|
options: --security-opt seccomp=unconfined
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_PASSWORD: georm
|
||||||
|
POSTGRES_USER: georm
|
||||||
|
POSTGRES_DB: georm
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 10s
|
||||||
|
--health-retries 5
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install Nix
|
||||||
|
uses: cachix/install-nix-action@v27
|
||||||
|
with:
|
||||||
|
nix_path: nixpkgs=channel:nixos-unstable
|
||||||
|
- name: Migrate database
|
||||||
|
run: nix develop --command -- just migrate
|
||||||
|
- name: Formatting check
|
||||||
|
run: nix develop --command -- just format-check
|
||||||
|
- name: Lint
|
||||||
|
run: nix develop --command -- just lint
|
||||||
|
- name: Audit
|
||||||
|
run: nix develop --command -- just audit
|
||||||
|
- name: Tests
|
||||||
|
run: nix develop --command -- just test
|
@ -1,7 +0,0 @@
|
|||||||
[all]
|
|
||||||
out = ["Xml"]
|
|
||||||
target-dir = "coverage"
|
|
||||||
output-dir = "coverage"
|
|
||||||
fail-under = 40
|
|
||||||
exclude-files = ["target/*"]
|
|
||||||
run-types = ["AllTargets"]
|
|
@ -1,8 +0,0 @@
|
|||||||
[all]
|
|
||||||
out = ["Html", "Lcov"]
|
|
||||||
skip-clean = true
|
|
||||||
target-dir = "coverage"
|
|
||||||
output-dir = "coverage"
|
|
||||||
fail-under = 40
|
|
||||||
exclude-files = ["target/*"]
|
|
||||||
run-types = ["AllTargets"]
|
|
110
Cargo.lock
generated
110
Cargo.lock
generated
@ -418,6 +418,7 @@ name = "georm"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"georm-macros",
|
"georm-macros",
|
||||||
|
"rand 0.9.0",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -439,7 +440,19 @@ checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi 0.11.0+wasi-snapshot-preview1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi 0.13.3+wasi-0.2.2",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -765,7 +778,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
|
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi 0.11.0+wasi-snapshot-preview1",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -781,7 +794,7 @@ dependencies = [
|
|||||||
"num-integer",
|
"num-integer",
|
||||||
"num-iter",
|
"num-iter",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@ -920,7 +933,7 @@ version = "0.2.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04"
|
checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy",
|
"zerocopy 0.7.35",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -958,8 +971,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rand_chacha",
|
"rand_chacha 0.3.1",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha 0.9.0",
|
||||||
|
"rand_core 0.9.0",
|
||||||
|
"zerocopy 0.8.14",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -969,7 +993,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ppv-lite86",
|
"ppv-lite86",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.9.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -978,7 +1012,17 @@ version = "0.6.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom",
|
"getrandom 0.2.15",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b08f3c9802962f7e1b25113931d94f43ed9725bebc59db9d0c3e9a23b67e15ff"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.1",
|
||||||
|
"zerocopy 0.8.14",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1003,7 +1047,7 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
"pkcs1",
|
"pkcs1",
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"signature",
|
"signature",
|
||||||
"spki",
|
"spki",
|
||||||
"subtle",
|
"subtle",
|
||||||
@ -1114,7 +1158,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1276,7 +1320,7 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"rsa",
|
"rsa",
|
||||||
"sha1",
|
"sha1",
|
||||||
"sha2",
|
"sha2",
|
||||||
@ -1313,7 +1357,7 @@ dependencies = [
|
|||||||
"md-5",
|
"md-5",
|
||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
@ -1406,7 +1450,7 @@ checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom",
|
"getrandom 0.2.15",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
@ -1606,6 +1650,15 @@ version = "0.11.0+wasi-snapshot-preview1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
|
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.13.3+wasi-0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen-rt",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasite"
|
name = "wasite"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@ -1779,6 +1832,15 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-rt"
|
||||||
|
version = "0.33.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "write16"
|
name = "write16"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@ -1822,7 +1884,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
|
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"zerocopy-derive",
|
"zerocopy-derive 0.7.35",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy"
|
||||||
|
version = "0.8.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a367f292d93d4eab890745e75a778da40909cab4d6ff8173693812f79c4a2468"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy-derive 0.8.14",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1836,6 +1907,17 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy-derive"
|
||||||
|
version = "0.8.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3931cb58c62c13adec22e38686b559c86a30565e16ad6e8510a337cedc611e1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerofrom"
|
name = "zerofrom"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
|
@ -34,6 +34,9 @@ features = ["postgres", "runtime-tokio", "macros", "migrate"]
|
|||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
georm-macros = { workspace = true }
|
georm-macros = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
rand = "0.9"
|
||||||
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
|
||||||
|
156
README.md
156
README.md
@ -1 +1,155 @@
|
|||||||
# Georm, a simple, opiniated SQLx ORM for PostgreSQL
|
<h1 align="center">Georm</h1>
|
||||||
|
<div align="center">
|
||||||
|
<strong>
|
||||||
|
A simple, opinionated SQLx ORM for PostgreSQL
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<!-- Github Actions -->
|
||||||
|
<a href="https://github.com/phundrak/georm/actions/workflows/ci.yaml?query=branch%3Amain">
|
||||||
|
<img src="https://img.shields.io/github/actions/workflow/status/phundrak/georm/ci.yaml?branch=main&style=flat-square" alt="actions status" /></a>
|
||||||
|
<!-- Version -->
|
||||||
|
<a href="https://crates.io/crates/georm">
|
||||||
|
<img src="https://img.shields.io/crates/v/georm.svg?style=flat-square"
|
||||||
|
alt="Crates.io version" /></a>
|
||||||
|
<!-- Discord -->
|
||||||
|
<!-- Docs -->
|
||||||
|
<a href="https://docs.rs/georm">
|
||||||
|
<img src="https://img.shields.io/badge/docs-latest-blue.svg?style=flat-square" alt="docs.rs docs" /></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<h4>What is Georm?</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
Georm is a quite simple ORM built around
|
||||||
|
[SQLx](https://crates.io/crates/sqlx) that gives access to a few
|
||||||
|
useful functions when interacting with a database, implementing
|
||||||
|
automatically the most basic SQL interactions you’re tired of writing.
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<h4>Why is Georm?</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
I wanted an ORM that’s easy and straightforward to use. I am aware
|
||||||
|
some other projects exist, such as
|
||||||
|
[SeaORM](https://www.sea-ql.org/SeaORM/), but they generally don’t fit
|
||||||
|
my needs and/or my wants of a simple interface. I ended up writing the
|
||||||
|
ORM I wanted to use.
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<h4>How is Georm?</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
I use it in a few projects, and I’m quite happy with it right now. But
|
||||||
|
of course, I’m open to constructive criticism and suggestions!
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<h4>How can I use it?</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
Georm works with SQLx, but does not re-export it itself. To get
|
||||||
|
started, install both Georm and SQLx in your Rust project:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo add sqlx --features postgres,macros # and any other feature you might want
|
||||||
|
cargo add georm
|
||||||
|
```
|
||||||
|
|
||||||
|
As Georm relies heavily on the macro
|
||||||
|
[`query_as!`](https://docs.rs/sqlx/latest/sqlx/macro.query_as.html),
|
||||||
|
the `macros` feature is not optional. Declare your tables in your
|
||||||
|
Postgres database (you may want to use SQLx’s `migrate` feature for
|
||||||
|
this), and then declare their equivalent in Rust.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE biographies (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
content TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE authors (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
biography_id INT,
|
||||||
|
FOREIGN KEY (biography_id) REFERENCES biographies(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Author {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To link a struct to a table in your database, derive the
|
||||||
|
`sqlx::FromRow` and the `georm::Georm` traits.
|
||||||
|
```rust
|
||||||
|
#[derive(sqlx::FromRow, Georm)]
|
||||||
|
pub struct Author {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Now, indicate with the `georm` proc-macro which table they refer to.
|
||||||
|
```rust
|
||||||
|
#[derive(sqlx::FromRow, Georm)]
|
||||||
|
#[georm(table = "authors")]
|
||||||
|
pub struct Author {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Finally, indicate with the same proc-macro which field of your struct
|
||||||
|
is the primary key in your database.
|
||||||
|
```rust
|
||||||
|
#[derive(sqlx::FromRow, Georm)]
|
||||||
|
#[georm(table = "authors")]
|
||||||
|
pub struct Author {
|
||||||
|
#[georm(id)]
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Congratulations, your struct `Author` now has access to all the
|
||||||
|
functions described in the `Georm` trait!
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<h4>Entity relationship</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
It is possible to implement one-to-one, one-to-many, and many-to-many
|
||||||
|
relationships with Georm. This is a quick example of how a struct with
|
||||||
|
several relationships of different types may be declared:
|
||||||
|
```rust
|
||||||
|
#[derive(sqlx::FromRow, Georm)]
|
||||||
|
#[georm(
|
||||||
|
table = "books",
|
||||||
|
one_to_many = [
|
||||||
|
{ name = "reviews", remote_id = "book_id", table = "reviews", entity = Review }
|
||||||
|
],
|
||||||
|
many_to_many = [{
|
||||||
|
name = "genres",
|
||||||
|
table = "genres",
|
||||||
|
entity = Genre,
|
||||||
|
link = { table = "book_genres", from = "book_id", to = "genre_id" }
|
||||||
|
}]
|
||||||
|
)]
|
||||||
|
pub struct Book {
|
||||||
|
#[georm(id)]
|
||||||
|
ident: i32,
|
||||||
|
title: String,
|
||||||
|
#[georm(relation = {entity = Author, table = "authors", name = "author"})]
|
||||||
|
author_id: i32,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To read more about these features, you can refer to the [online
|
||||||
|
documentation](https://docs.rs/sqlx/latest/georm/).
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
[licenses]
|
[licenses]
|
||||||
# If there is a need to add another license, please refer to this
|
# If there is a need to add another license, please refer to this page
|
||||||
# page: https://www.gnu.org/licenses/license-list.html
|
# for compatible licenses:
|
||||||
|
# https://www.gnu.org/licenses/license-list.html
|
||||||
allow = ["MIT", "Apache-2.0", "BSD-3-Clause", "Unicode-3.0", "Zlib"]
|
allow = ["MIT", "Apache-2.0", "BSD-3-Clause", "Unicode-3.0", "Zlib"]
|
||||||
confidence-threshold = 0.8
|
confidence-threshold = 0.8
|
||||||
|
|
||||||
[bans]
|
[bans]
|
||||||
multiple-versions = "warn"
|
multiple-versions = "allow"
|
||||||
wildcards = "allow"
|
wildcards = "allow"
|
||||||
highlight = "all"
|
highlight = "all"
|
||||||
workspace-default-features = "allow"
|
workspace-default-features = "allow"
|
||||||
|
12
flake.lock
generated
12
flake.lock
generated
@ -20,11 +20,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1737746512,
|
"lastModified": 1738142207,
|
||||||
"narHash": "sha256-nU6AezEX4EuahTO1YopzueAXfjFfmCHylYEFCagduHU=",
|
"narHash": "sha256-NGqpVVxNAHwIicXpgaVqJEJWeyqzoQJ9oc8lnK9+WC4=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "825479c345a7f806485b7f00dbe3abb50641b083",
|
"rev": "9d3ae807ebd2981d593cddd0080856873139aa40",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@ -62,11 +62,11 @@
|
|||||||
"nixpkgs": "nixpkgs_2"
|
"nixpkgs": "nixpkgs_2"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1737858462,
|
"lastModified": 1738290352,
|
||||||
"narHash": "sha256-rohhmT/b8QNaIL3nY01jFtCyZu2dGTufef5YieECWZM=",
|
"narHash": "sha256-YKOHUmc0Clm4tMV8grnxYL4IIwtjTayoq/3nqk0QM7k=",
|
||||||
"owner": "oxalica",
|
"owner": "oxalica",
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"rev": "dd236609a6c272d00ceaa042b1a81a31968e7f4d",
|
"rev": "b031b584125d33d23a0182f91ddbaf3ab4880236",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
21
flake.nix
21
flake.nix
@ -13,33 +13,12 @@
|
|||||||
overlays = [ (import rust-overlay) ];
|
overlays = [ (import rust-overlay) ];
|
||||||
pkgs = import nixpkgs { inherit system overlays; };
|
pkgs = import nixpkgs { inherit system overlays; };
|
||||||
rustVersion = (pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml);
|
rustVersion = (pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml);
|
||||||
rustPlatform = pkgs.makeRustPlatform {
|
|
||||||
cargo = rustVersion;
|
|
||||||
rustc = rustVersion;
|
|
||||||
};
|
|
||||||
|
|
||||||
libName = "georm";
|
|
||||||
|
|
||||||
libRustBuildGeorm = rustPlatform.buildRustPackage {
|
|
||||||
pname = libName;
|
|
||||||
version = "0.1.0";
|
|
||||||
src = ./.;
|
|
||||||
cargoLock.lockFile = ./Cargo.lock;
|
|
||||||
buildPhase = ''
|
|
||||||
SQLX_OFFLINE="1" cargo build --release
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
in {
|
in {
|
||||||
packages = {
|
|
||||||
lib = libRustBuildGeorm;
|
|
||||||
};
|
|
||||||
defaultPackage = libRustBuildGeorm;
|
|
||||||
devShell = with pkgs; mkShell {
|
devShell = with pkgs; mkShell {
|
||||||
buildInputs = [
|
buildInputs = [
|
||||||
bacon
|
bacon
|
||||||
cargo
|
cargo
|
||||||
cargo-deny
|
cargo-deny
|
||||||
cargo-tarpaulin
|
|
||||||
just
|
just
|
||||||
rust-analyzer
|
rust-analyzer
|
||||||
(rustVersion.override {
|
(rustVersion.override {
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
use quote::quote;
|
use quote::quote;
|
||||||
use std::fmt::{self, Display};
|
|
||||||
|
|
||||||
#[derive(deluxe::ExtractAttributes)]
|
#[derive(deluxe::ExtractAttributes)]
|
||||||
#[deluxe(attributes(georm))]
|
#[deluxe(attributes(georm))]
|
||||||
@ -32,7 +31,7 @@ impl From<&O2MRelationship> for proc_macro2::TokenStream {
|
|||||||
);
|
);
|
||||||
quote! {
|
quote! {
|
||||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<#entity>> {
|
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<#entity>> {
|
||||||
query_as!(#entity, #query, self.get_id()).fetch_all(pool).await
|
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_all(pool).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -45,16 +44,6 @@ pub struct M2MLink {
|
|||||||
pub to: String,
|
pub to: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
//#[georm(
|
|
||||||
// table = "users",
|
|
||||||
// many_to_many = [
|
|
||||||
// {
|
|
||||||
// name = friends,
|
|
||||||
// entity: User,
|
|
||||||
// link = { table = "user_friendships", from: "user1", to "user2" }
|
|
||||||
// }
|
|
||||||
// ]
|
|
||||||
//)]
|
|
||||||
#[derive(deluxe::ParseMetaItem)]
|
#[derive(deluxe::ParseMetaItem)]
|
||||||
pub struct M2MRelationship {
|
pub struct M2MRelationship {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@ -104,13 +93,11 @@ impl From<&M2MRelationshipComplete> for proc_macro2::TokenStream {
|
|||||||
);
|
);
|
||||||
let entity = &value.entity;
|
let entity = &value.entity;
|
||||||
let query = format!(
|
let query = format!(
|
||||||
"
|
"SELECT remote.*
|
||||||
SELECT remote.*
|
|
||||||
FROM {} local
|
FROM {} local
|
||||||
JOIN {} link ON link.{} = local.{}
|
JOIN {} link ON link.{} = local.{}
|
||||||
JOIN {} remote ON link.{} = remote.{}
|
JOIN {} remote ON link.{} = remote.{}
|
||||||
WHERE local.{} = $1
|
WHERE local.{} = $1",
|
||||||
",
|
|
||||||
value.local.table,
|
value.local.table,
|
||||||
value.link.table,
|
value.link.table,
|
||||||
value.link.from,
|
value.link.from,
|
||||||
@ -122,7 +109,7 @@ WHERE local.{} = $1
|
|||||||
);
|
);
|
||||||
quote! {
|
quote! {
|
||||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<#entity>> {
|
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<#entity>> {
|
||||||
query_as!(#entity, #query, self.get_id()).fetch_all(pool).await
|
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_all(pool).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -134,16 +121,10 @@ struct GeormFieldAttributes {
|
|||||||
#[deluxe(default = false)]
|
#[deluxe(default = false)]
|
||||||
pub id: bool,
|
pub id: bool,
|
||||||
#[deluxe(default = None)]
|
#[deluxe(default = None)]
|
||||||
pub column: Option<String>,
|
|
||||||
#[deluxe(default = None)]
|
|
||||||
pub relation: Option<O2ORelationship>,
|
pub relation: Option<O2ORelationship>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// #[georm(
|
#[derive(deluxe::ParseMetaItem, Clone, Debug)]
|
||||||
// table = "profileId",
|
|
||||||
// one_to_one = { name = profile, id = "id", entity = Profile, nullable }
|
|
||||||
// )]
|
|
||||||
#[derive(deluxe::ParseMetaItem, Clone)]
|
|
||||||
pub struct O2ORelationship {
|
pub struct O2ORelationship {
|
||||||
pub entity: syn::Type,
|
pub entity: syn::Type,
|
||||||
pub table: String,
|
pub table: String,
|
||||||
@ -154,12 +135,11 @@ pub struct O2ORelationship {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct GeormField {
|
pub struct GeormField {
|
||||||
pub ident: syn::Ident,
|
pub ident: syn::Ident,
|
||||||
pub field: syn::Field,
|
pub field: syn::Field,
|
||||||
pub ty: syn::Type,
|
pub ty: syn::Type,
|
||||||
pub column: Option<String>,
|
|
||||||
pub id: bool,
|
pub id: bool,
|
||||||
pub relation: Option<O2ORelationship>,
|
pub relation: Option<O2ORelationship>,
|
||||||
}
|
}
|
||||||
@ -170,40 +150,22 @@ impl GeormField {
|
|||||||
let ty = field.clone().ty;
|
let ty = field.clone().ty;
|
||||||
let attrs: GeormFieldAttributes =
|
let attrs: GeormFieldAttributes =
|
||||||
deluxe::extract_attributes(field).expect("Could not extract attributes from field");
|
deluxe::extract_attributes(field).expect("Could not extract attributes from field");
|
||||||
let GeormFieldAttributes {
|
let GeormFieldAttributes { id, relation } = attrs;
|
||||||
id,
|
|
||||||
column,
|
|
||||||
relation,
|
|
||||||
} = attrs;
|
|
||||||
Self {
|
Self {
|
||||||
ident,
|
ident,
|
||||||
field: field.to_owned(),
|
field: field.to_owned(),
|
||||||
id,
|
id,
|
||||||
ty,
|
ty,
|
||||||
relation,
|
relation,
|
||||||
column,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for GeormField {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
||||||
write!(
|
|
||||||
f,
|
|
||||||
"{}",
|
|
||||||
self.column
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| self.ident.to_string())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&GeormField> for proc_macro2::TokenStream {
|
impl From<&GeormField> for proc_macro2::TokenStream {
|
||||||
fn from(value: &GeormField) -> Self {
|
fn from(value: &GeormField) -> Self {
|
||||||
let Some(relation) = value.relation.clone() else {
|
let Some(relation) = value.relation.clone() else {
|
||||||
return quote! {};
|
return quote! {};
|
||||||
};
|
};
|
||||||
|
|
||||||
let function = syn::Ident::new(
|
let function = syn::Ident::new(
|
||||||
&format!("get_{}", relation.name),
|
&format!("get_{}", relation.name),
|
||||||
proc_macro2::Span::call_site(),
|
proc_macro2::Span::call_site(),
|
||||||
@ -225,8 +187,8 @@ impl From<&GeormField> for proc_macro2::TokenStream {
|
|||||||
quote! { fetch_one }
|
quote! { fetch_one }
|
||||||
};
|
};
|
||||||
quote! {
|
quote! {
|
||||||
pub async fn #function(&value, pool: &::sqlx::PgPool) -> ::sqlx::Result<#return_type> {
|
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<#return_type> {
|
||||||
query_as!(#entity, #query, value.#local_ident).#fetch(pool).await
|
::sqlx::query_as!(#entity, #query, self.#local_ident).#fetch(pool).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -49,12 +49,11 @@ pub fn georm_derive_macro2(
|
|||||||
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, id) = extract_georm_field_attrs(&mut ast)?;
|
||||||
let trait_impl = trait_implementation::derive_trait(&ast, &struct_attrs.table, &fields, &id);
|
|
||||||
let relationships = relationships::derive_relationships(&ast, &struct_attrs, &fields, &id);
|
let relationships = relationships::derive_relationships(&ast, &struct_attrs, &fields, &id);
|
||||||
|
let trait_impl = trait_implementation::derive_trait(&ast, &struct_attrs.table, &fields, &id);
|
||||||
let code = quote! {
|
let code = quote! {
|
||||||
#trait_impl
|
|
||||||
#relationships
|
#relationships
|
||||||
|
#trait_impl
|
||||||
};
|
};
|
||||||
println!("{code}");
|
|
||||||
Ok(code)
|
Ok(code)
|
||||||
}
|
}
|
||||||
|
@ -35,12 +35,12 @@ pub fn derive_relationships(
|
|||||||
id: &GeormField,
|
id: &GeormField,
|
||||||
) -> TokenStream {
|
) -> TokenStream {
|
||||||
let struct_name = &ast.ident;
|
let struct_name = &ast.ident;
|
||||||
let one_to_one = derive(fields, |field| field.relation.is_none());
|
let one_to_one = derive(fields, |field| field.relation.is_some());
|
||||||
let one_to_many = derive(&struct_attrs.one_to_many, |_| true);
|
let one_to_many = derive(&struct_attrs.one_to_many, |_| true);
|
||||||
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.to_string()))
|
.map(|v| M2MRelationshipComplete::new(v, &struct_attrs.table, id.ident.to_string()))
|
||||||
.collect();
|
.collect();
|
||||||
let many_to_many = derive(&many_to_many, |_| true);
|
let many_to_many = derive(&many_to_many, |_| true);
|
||||||
|
|
||||||
|
@ -1,8 +1,17 @@
|
|||||||
use super::ir::GeormField;
|
use super::ir::GeormField;
|
||||||
use quote::quote;
|
use quote::quote;
|
||||||
|
|
||||||
|
fn generate_find_all_query(table: &str) -> proc_macro2::TokenStream {
|
||||||
|
let find_string = format!("SELECT * FROM {table}");
|
||||||
|
quote! {
|
||||||
|
async fn find_all(pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<Self>> {
|
||||||
|
::sqlx::query_as!(Self, #find_string).fetch_all(pool).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn generate_find_query(table: &str, id: &GeormField) -> proc_macro2::TokenStream {
|
fn generate_find_query(table: &str, id: &GeormField) -> proc_macro2::TokenStream {
|
||||||
let find_string = format!("SELECT * FROM {table} WHERE {id} = $1",);
|
let find_string = format!("SELECT * FROM {table} WHERE {} = $1", id.ident);
|
||||||
let ty = &id.ty;
|
let ty = &id.ty;
|
||||||
quote! {
|
quote! {
|
||||||
async fn find(pool: &::sqlx::PgPool, id: &#ty) -> ::sqlx::Result<Option<Self>> {
|
async fn find(pool: &::sqlx::PgPool, id: &#ty) -> ::sqlx::Result<Option<Self>> {
|
||||||
@ -19,7 +28,7 @@ fn generate_create_query(table: &str, fields: &[GeormField]) -> proc_macro2::Tok
|
|||||||
"INSERT INTO {table} ({}) VALUES ({}) RETURNING *",
|
"INSERT INTO {table} ({}) VALUES ({}) RETURNING *",
|
||||||
fields
|
fields
|
||||||
.iter()
|
.iter()
|
||||||
.map(std::string::ToString::to_string)
|
.map(|f| f.ident.to_string())
|
||||||
.collect::<Vec<String>>()
|
.collect::<Vec<String>>()
|
||||||
.join(", "),
|
.join(", "),
|
||||||
inputs.join(", ")
|
inputs.join(", ")
|
||||||
@ -47,11 +56,12 @@ fn generate_update_query(
|
|||||||
let update_columns = fields
|
let update_columns = fields
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, &field)| format!("{field} = ${}", i + 1))
|
.map(|(i, &field)| format!("{} = ${}", field.ident, i + 1))
|
||||||
.collect::<Vec<String>>()
|
.collect::<Vec<String>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
let update_string = format!(
|
let update_string = format!(
|
||||||
"UPDATE {table} SET {update_columns} WHERE {id} = ${} RETURNING *",
|
"UPDATE {table} SET {update_columns} WHERE {} = ${} RETURNING *",
|
||||||
|
id.ident,
|
||||||
fields.len() + 1
|
fields.len() + 1
|
||||||
);
|
);
|
||||||
fields.push(id);
|
fields.push(id);
|
||||||
@ -70,7 +80,7 @@ fn generate_update_query(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn generate_delete_query(table: &str, id: &GeormField) -> proc_macro2::TokenStream {
|
fn generate_delete_query(table: &str, id: &GeormField) -> proc_macro2::TokenStream {
|
||||||
let delete_string = format!("DELETE FROM {table} WHERE {id} = $1");
|
let delete_string = format!("DELETE FROM {table} WHERE {} = $1", id.ident);
|
||||||
let ty = &id.ty;
|
let ty = &id.ty;
|
||||||
quote! {
|
quote! {
|
||||||
async fn delete_by_id(pool: &::sqlx::PgPool, id: &#ty) -> ::sqlx::Result<u64> {
|
async fn delete_by_id(pool: &::sqlx::PgPool, id: &#ty) -> ::sqlx::Result<u64> {
|
||||||
@ -104,13 +114,13 @@ pub fn derive_trait(
|
|||||||
id: &GeormField,
|
id: &GeormField,
|
||||||
) -> proc_macro2::TokenStream {
|
) -> proc_macro2::TokenStream {
|
||||||
let ty = &id.ty;
|
let ty = &id.ty;
|
||||||
let id_ident = &id.ident;
|
|
||||||
|
|
||||||
// define impl variables
|
// define impl variables
|
||||||
let ident = &ast.ident;
|
let ident = &ast.ident;
|
||||||
let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl();
|
let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl();
|
||||||
|
|
||||||
// generate
|
// generate
|
||||||
|
let get_all = generate_find_all_query(table);
|
||||||
let get_id = generate_get_id(id);
|
let get_id = generate_get_id(id);
|
||||||
let find_query = generate_find_query(table, id);
|
let find_query = generate_find_query(table, id);
|
||||||
let create_query = generate_create_query(table, fields);
|
let create_query = generate_create_query(table, fields);
|
||||||
@ -118,19 +128,11 @@ pub fn derive_trait(
|
|||||||
let delete_query = generate_delete_query(table, id);
|
let delete_query = generate_delete_query(table, id);
|
||||||
quote! {
|
quote! {
|
||||||
impl #impl_generics Georm<#ty> for #ident #type_generics #where_clause {
|
impl #impl_generics Georm<#ty> for #ident #type_generics #where_clause {
|
||||||
|
#get_all
|
||||||
#get_id
|
#get_id
|
||||||
#find_query
|
#find_query
|
||||||
#create_query
|
#create_query
|
||||||
#update_query
|
#update_query
|
||||||
|
|
||||||
async fn create_or_update(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Self> {
|
|
||||||
if Self::find(pool, &self.#id_ident).await?.is_some() {
|
|
||||||
self.update(pool).await
|
|
||||||
} else {
|
|
||||||
self.create(pool).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#delete_query
|
#delete_query
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,73 +1,3 @@
|
|||||||
//! Creates ORM functionality for ``SQLx`` with `PostgreSQL`.
|
|
||||||
//!
|
|
||||||
//! This crate provides the trait implementation `Georm` which
|
|
||||||
//! generates the following ``SQLx`` queries:
|
|
||||||
//! - find an entity by id
|
|
||||||
//!
|
|
||||||
//! SQL query: `SELECT * FROM ... WHERE <id> = ...`
|
|
||||||
//! - insert an entity into the database
|
|
||||||
//!
|
|
||||||
//! SQL query: `INSERT INTO ... (...) VALUES (...) RETURNING *`
|
|
||||||
//! - update an entity in the database
|
|
||||||
//!
|
|
||||||
//! SQL query: `UPDATE ... SET ... WHERE <id> = ... RETURNING *`
|
|
||||||
//! - delete an entity from the database using its id or an id
|
|
||||||
//! provided by the interface’s user
|
|
||||||
//!
|
|
||||||
//! SQL query: `DELETE FROM ... WHERE <id> = ...`
|
|
||||||
//! - update an entity or create it if it does not already exist in
|
|
||||||
//! the database
|
|
||||||
//!
|
|
||||||
//! This macro relies on the trait `Georm` found in the `georm`
|
|
||||||
//! crate.
|
|
||||||
//!
|
|
||||||
//! To use this macro, you need to add it to the derives of the
|
|
||||||
//! struct. You will also need to define its identifier
|
|
||||||
//!
|
|
||||||
//! # Usage
|
|
||||||
//!
|
|
||||||
//! Add `#[georm(table = "my_table_name")]` atop of the structure,
|
|
||||||
//! after the `Georm` derive.
|
|
||||||
//!
|
|
||||||
//! ## Entity Identifier
|
|
||||||
//! You will also need to add `#[georm(id)]` atop of the field of your
|
|
||||||
//! struct that will be used as the identifier of your entity.
|
|
||||||
//!
|
|
||||||
//! ## Column Name
|
|
||||||
//! If the name of a field does not match the name of its related
|
|
||||||
//! column, you can use `#[georm(column = "...")]` to specify the
|
|
||||||
//! correct value.
|
|
||||||
//!
|
|
||||||
//! ```ignore
|
|
||||||
//! #[derive(Georm)]
|
|
||||||
//! #[georm(table = "users")]
|
|
||||||
//! pub struct User {
|
|
||||||
//! #[georm(id)]
|
|
||||||
//! id: String,
|
|
||||||
//! #[georm(column = "name")]
|
|
||||||
//! username: String,
|
|
||||||
//! created_at: Timestampz,
|
|
||||||
//! last_updated: Timestampz,
|
|
||||||
//! }
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! With the example of the `User` struct, this links it to the
|
|
||||||
//! `users` table of the connected database. It will use `Users.id` to
|
|
||||||
//! uniquely identify a user entity.
|
|
||||||
//!
|
|
||||||
//! # Limitations
|
|
||||||
//! ## ID
|
|
||||||
//! For now, only one identifier is supported. It does not have to be
|
|
||||||
//! a primary key, but it is strongly encouraged to use Georm ID on a
|
|
||||||
//! unique and non-null column of your database schema.
|
|
||||||
//!
|
|
||||||
//! ## Database type
|
|
||||||
//!
|
|
||||||
//! For now, only the ``PostgreSQL`` syntax is supported. If you use
|
|
||||||
//! another database that uses the same syntax, you’re in luck!
|
|
||||||
//! Otherwise, pull requests to add additional syntaxes are most
|
|
||||||
//! welcome.
|
|
||||||
|
|
||||||
mod georm;
|
mod georm;
|
||||||
use georm::georm_derive_macro2;
|
use georm::georm_derive_macro2;
|
||||||
|
|
||||||
|
36
justfile
36
justfile
@ -2,17 +2,11 @@ mod docker
|
|||||||
|
|
||||||
default: lint
|
default: lint
|
||||||
|
|
||||||
format:
|
clean:
|
||||||
cargo fmt --all
|
cargo clean
|
||||||
|
|
||||||
format-check:
|
test:
|
||||||
cargo fmt --check --all
|
cargo test --all-targets --all
|
||||||
|
|
||||||
build:
|
|
||||||
cargo build
|
|
||||||
|
|
||||||
build-release:
|
|
||||||
cargo build --release
|
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
cargo clippy --all-targets
|
cargo clippy --all-targets
|
||||||
@ -20,18 +14,22 @@ lint:
|
|||||||
audit:
|
audit:
|
||||||
cargo deny check all
|
cargo deny check all
|
||||||
|
|
||||||
test:
|
migrate:
|
||||||
cargo test --all-targets --all
|
cargo sqlx migrate run
|
||||||
|
|
||||||
coverage:
|
build:
|
||||||
mkdir -p coverage
|
cargo build
|
||||||
cargo tarpaulin --config .tarpaulin.local.toml
|
|
||||||
|
|
||||||
coverage-ci:
|
build-release:
|
||||||
mkdir -p coverage
|
cargo build --release
|
||||||
cargo tarpaulin --config .tarpaulin.ci.toml
|
|
||||||
|
|
||||||
check-all: format-check lint coverage audit
|
format:
|
||||||
|
cargo fmt --all
|
||||||
|
|
||||||
|
format-check:
|
||||||
|
cargo fmt --check --all
|
||||||
|
|
||||||
|
check-all: format-check lint audit test
|
||||||
|
|
||||||
## Local Variables:
|
## Local Variables:
|
||||||
## mode: makefile
|
## mode: makefile
|
||||||
|
6
migrations/20250126153330_simple-struct-tests.down.sql
Normal file
6
migrations/20250126153330_simple-struct-tests.down.sql
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
DROP TABLE IF EXISTS reviews;
|
||||||
|
DROP TABLE IF EXISTS book_genres;
|
||||||
|
DROP TABLE IF EXISTS books;
|
||||||
|
DROP TABLE IF EXISTS genres;
|
||||||
|
DROP TABLE IF EXISTS authors;
|
||||||
|
DROP TABLE IF EXISTS biographies;
|
38
migrations/20250126153330_simple-struct-tests.up.sql
Normal file
38
migrations/20250126153330_simple-struct-tests.up.sql
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
CREATE TABLE biographies (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
content TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE authors (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
biography_id INT,
|
||||||
|
FOREIGN KEY (biography_id) REFERENCES biographies(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE books (
|
||||||
|
ident SERIAL PRIMARY KEY,
|
||||||
|
title VARCHAR(100) NOT NULL,
|
||||||
|
author_id INT NOT NULL,
|
||||||
|
FOREIGN KEY (author_id) REFERENCES authors(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE reviews (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
book_id INT NOT NULL,
|
||||||
|
review TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (book_id) REFERENCES books(ident) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE genres (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE book_genres (
|
||||||
|
book_id INT NOT NULL,
|
||||||
|
genre_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (book_id, genre_id),
|
||||||
|
FOREIGN KEY (book_id) REFERENCES books(ident) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (genre_id) REFERENCES genres(id) ON DELETE CASCADE
|
||||||
|
);
|
272
src/lib.rs
272
src/lib.rs
@ -1,6 +1,265 @@
|
|||||||
|
//! # Georm
|
||||||
|
//!
|
||||||
|
//! ## Introduction
|
||||||
|
//!
|
||||||
|
//! Georm is a simple, opinionated SQLx ORM for PostgreSQL.
|
||||||
|
//!
|
||||||
|
//! To automatically implement the `Georm` trait, you need at least:
|
||||||
|
//! - to derive the `Georm` and `sqlx::FromRow` traits
|
||||||
|
//! - use the `georm` proc-macro to indicate the table in which your entity
|
||||||
|
//! lives
|
||||||
|
//! - use the `georm` proc-macro again to indicate which field of your struct is
|
||||||
|
//! the identifier of your entity.
|
||||||
|
//!
|
||||||
|
//! ## Simple usage
|
||||||
|
//! Here is a minimal use of Georm with a struct:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! #[derive(sqlx::FromRow, Georm)]
|
||||||
|
//! #[georm(table = "users")]
|
||||||
|
//! pub struct User {
|
||||||
|
//! #[georm(id)]
|
||||||
|
//! id: i32,
|
||||||
|
//! username: String,
|
||||||
|
//! hashed_password: String,
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The `User` type will now have access to all the functions declared in the
|
||||||
|
//! `Georm` trait.
|
||||||
|
//!
|
||||||
|
//! ## One-to-one relationships
|
||||||
|
//!
|
||||||
|
//! You can then create relationships between different entities. For instance,
|
||||||
|
//! you can use an identifier of another entity as a link to that other entity.
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! #[derive(sqlx::FromRow, Georm)]
|
||||||
|
//! #[georm(table = "profiles")]
|
||||||
|
//! pub struct Profile {
|
||||||
|
//! #[georm(id)]
|
||||||
|
//! id: i32,
|
||||||
|
//! #[georm(
|
||||||
|
//! relation = {
|
||||||
|
//! entity = User,
|
||||||
|
//! name = "user",
|
||||||
|
//! table = "users",
|
||||||
|
//! remote_id = "id",
|
||||||
|
//! nullable = false
|
||||||
|
//! })
|
||||||
|
//! ]
|
||||||
|
//! user_id: i32,
|
||||||
|
//! display_name: String,
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! This will give access to the `Profile::get_user(&self, pool: &sqlx::PgPool)
|
||||||
|
//! -> User` method.
|
||||||
|
//!
|
||||||
|
//! Here is an explanation of what these different values mean:
|
||||||
|
//!
|
||||||
|
//! | Value Name | Explanation | Default value |
|
||||||
|
//! |------------|-----------------------------------------------------------------------------------------|---------------|
|
||||||
|
//! | entity | Rust type of the entity found in the database | N/A |
|
||||||
|
//! | name | Name of the remote entity within the local entity; generates a method named `get_{name}` | N/A |
|
||||||
|
//! | table | Database table where the entity is stored | N/A |
|
||||||
|
//! | remote_id | Name of the column serving as the identifier of the entity | `"id"` |
|
||||||
|
//! | nullable | Whether the relationship can be broken | `false` |
|
||||||
|
//!
|
||||||
|
//! Note that in this instance, the `remote_id` and `nullable` values can be
|
||||||
|
//! omitted as this is their default value. This below is a strict equivalent:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! #[derive(sqlx::FromRow, Georm)]
|
||||||
|
//! #[georm(table = "profiles")]
|
||||||
|
//! pub struct Profile {
|
||||||
|
//! #[georm(id)]
|
||||||
|
//! id: i32,
|
||||||
|
//! #[georm(relation = { entity = User, table = "users", name = "user" })]
|
||||||
|
//! user_id: i32,
|
||||||
|
//! display_name: String,
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## One-to-many relationships
|
||||||
|
//!
|
||||||
|
//! Sometimes, our entity is the one being referenced to by multiple entities,
|
||||||
|
//! but we have no internal reference to these remote entities in our local
|
||||||
|
//! entity. Fortunately, we have a way to indicate to Georm how to find these.
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! #[derive(sqlx::FromRow, Georm)]
|
||||||
|
//! #[georm(table = "posts")]
|
||||||
|
//! struct Post {
|
||||||
|
//! #[georm(id)]
|
||||||
|
//! id: i32,
|
||||||
|
//! #[georm(relation = { entity = User, table = "users", name = "user" })]
|
||||||
|
//! author_id: i32,
|
||||||
|
//! content: String
|
||||||
|
//! }
|
||||||
|
//!
|
||||||
|
//! #[derive(sqlx::FromRow, Georm)]
|
||||||
|
//! #[georm(
|
||||||
|
//! table = "users",
|
||||||
|
//! one_to_many = [{
|
||||||
|
//! entity = Post,
|
||||||
|
//! name = "posts",
|
||||||
|
//! table = "posts",
|
||||||
|
//! remote_id = "id"
|
||||||
|
//! }]
|
||||||
|
//! )]
|
||||||
|
//! struct User {
|
||||||
|
//! #[georm(id)]
|
||||||
|
//! id: i32,
|
||||||
|
//! username: String,
|
||||||
|
//! hashed_password: String
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! As we’ve seen earlier, the struct `Post` has access to the method
|
||||||
|
//! `Post::get_user(&self, pool: &sqlx::PgPool) -> User` thanks to the
|
||||||
|
//! proc-macro used on `author_id`. However, `User` now has also access to
|
||||||
|
//! `User::get_posts(&self, pool: &sqlx::PgPool) -> Vec<Post>`. And as you can
|
||||||
|
//! see, `one_to_many` is an array, meaning you can define several one-to-many
|
||||||
|
//! relationships for `User`.
|
||||||
|
//!
|
||||||
|
//! Here is an explanation of the values of `one_to_many`:
|
||||||
|
//!
|
||||||
|
//! | Value Name | Explanaion | Default Value |
|
||||||
|
//! |------------|------------------------------------------------------------------------------------------|---------------|
|
||||||
|
//! | entity | Rust type of the entity found in the database | N/A |
|
||||||
|
//! | name | Name of the remote entity within the local entity; generates a method named `get_{name}` | N/A |
|
||||||
|
//! | table | Database table where the entity is stored | N/A |
|
||||||
|
//! | remote_id | Name of the column serving as the identifier of the entity | `"id"` |
|
||||||
|
//!
|
||||||
|
//! As with one-to-one relationships, `remote_id` is optional. The following
|
||||||
|
//! `User` struct is strictly equivalent.
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! #[derive(sqlx::FromRow, Georm)]
|
||||||
|
//! #[georm(
|
||||||
|
//! table = "users",
|
||||||
|
//! one_to_many = [{ entity = Post, name = "posts", table = "posts" }]
|
||||||
|
//! )]
|
||||||
|
//! struct User {
|
||||||
|
//! #[georm(id)]
|
||||||
|
//! id: i32,
|
||||||
|
//! username: String,
|
||||||
|
//! hashed_password: String
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Many-to-many relationships
|
||||||
|
//!
|
||||||
|
//! Many-to-many relationships between entities A and entities B with Georm rely
|
||||||
|
//! on a third table which refers to both. For instance, the following SQL code
|
||||||
|
//! describes a many-to-many relationship between books and book genre.
|
||||||
|
//!
|
||||||
|
//! ```sql
|
||||||
|
//! CREATE TABLE books (
|
||||||
|
//! id SERIAL PRIMARY KEY,
|
||||||
|
//! title VARCHAR(100) NOT NULL
|
||||||
|
//! );
|
||||||
|
//!
|
||||||
|
//! CREATE TABLE genres (
|
||||||
|
//! id SERIAL PRIMARY KEY,
|
||||||
|
//! name VARCHAR(100) NOT NULL
|
||||||
|
//! );
|
||||||
|
//!
|
||||||
|
//! CREATE TABLE books_genres (
|
||||||
|
//! book_id INT NOT NULL,
|
||||||
|
//! genre_id INT NOT NULL,
|
||||||
|
//! PRIMARY KEY (book_id, genre_id),
|
||||||
|
//! FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE CASCADE,
|
||||||
|
//! FOREIGN KEY (genre_id) REFERENCES genres(id) ON DELETE CASCADE
|
||||||
|
//! );
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The table `books_genres` is the one defining the many-to-many relationship
|
||||||
|
//! between the table `books` and the table `genres`. With Georm, this gives us
|
||||||
|
//! the following code:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! #[derive(sqlx::FromRow, Georm)]
|
||||||
|
//! #[georm(
|
||||||
|
//! table = "books",
|
||||||
|
//! many_to_many = [{
|
||||||
|
//! name = "genres",
|
||||||
|
//! entity = Genre,
|
||||||
|
//! table = "genres",
|
||||||
|
//! remote_id = "id",
|
||||||
|
//! link = { table = "books_genres", from = "book_id", to = "genre_id" }
|
||||||
|
//! }]
|
||||||
|
//! )]
|
||||||
|
//! struct Book {
|
||||||
|
//! #[georm(id)]
|
||||||
|
//! id: i32,
|
||||||
|
//! title: String
|
||||||
|
//! }
|
||||||
|
//!
|
||||||
|
//! #[derive(sqlx::FromRow, Georm)]
|
||||||
|
//! #[georm(
|
||||||
|
//! table = "genres",
|
||||||
|
//! many_to_many = [{
|
||||||
|
//! entity = Book,
|
||||||
|
//! name = "books",
|
||||||
|
//! table = "books",
|
||||||
|
//! remote_id = "id",
|
||||||
|
//! link = { table = "books_genres", from = "genre_id", to = "book_id" }
|
||||||
|
//! }]
|
||||||
|
//! )]
|
||||||
|
//! struct Genre {
|
||||||
|
//! #[georm(id)]
|
||||||
|
//! id: i32,
|
||||||
|
//! name: String
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! This generates two methods:
|
||||||
|
//! - `Book::get_genres(&self, pool: &sqlx::PgPool) -> Vec<Genre>`
|
||||||
|
//! - `Genre::get_books(&self, pool: &sqlx::PgPool) -> Vec<Book>`
|
||||||
|
//!
|
||||||
|
//! As you can see, `many_to_many` is also an array, meaning we can define
|
||||||
|
//! several many-to-many relationships for the same struct.
|
||||||
|
//!
|
||||||
|
//! Here is an explanation of the values behind `many_to_many`:
|
||||||
|
//!
|
||||||
|
//! | Value Name | Explanation | Default value |
|
||||||
|
//! |------------|------------------------------------------------------------------------------------------|---------------|
|
||||||
|
//! | entity | Rust type of the entity found in the database | N/A |
|
||||||
|
//! | name | Name of the remote entity within the local entity; generates a method named `get_{name}` | N/A |
|
||||||
|
//! | table | Database table where the entity is stored | N/A |
|
||||||
|
//! | remote_id | Name of the column serving as the identifier of the entity | `"id"` |
|
||||||
|
//! | link.table | Name of the many-to-many relationship table | N/A |
|
||||||
|
//! | link.from | Column of the linking table referring to this entity | N/A |
|
||||||
|
//! | link.to | Column of the linking table referring to the remote entity | N/A |
|
||||||
|
//!
|
||||||
|
//! ## Limitations
|
||||||
|
//! ### Database
|
||||||
|
//!
|
||||||
|
//! For now, Georm is limited to PostgreSQL. Other databases may be supported in
|
||||||
|
//! the future, such as Sqlite or MySQL, but that is not the case yet.
|
||||||
|
//!
|
||||||
|
//! ## 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.
|
||||||
|
|
||||||
pub use georm_macros::Georm;
|
pub use georm_macros::Georm;
|
||||||
|
|
||||||
pub trait Georm<Id> {
|
pub trait Georm<Id> {
|
||||||
|
/// Find all the entities in the database.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns any error Postgres may have encountered
|
||||||
|
fn find_all(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> impl ::std::future::Future<Output = ::sqlx::Result<Vec<Self>>> + Send
|
||||||
|
where
|
||||||
|
Self: Sized;
|
||||||
|
|
||||||
/// Find the entiy in the database based on its identifier.
|
/// Find the entiy in the database based on its identifier.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@ -42,9 +301,18 @@ pub trait Georm<Id> {
|
|||||||
fn create_or_update(
|
fn create_or_update(
|
||||||
&self,
|
&self,
|
||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
) -> impl ::std::future::Future<Output = sqlx::Result<Self>>
|
||||||
where
|
where
|
||||||
Self: Sized;
|
Self: Sized,
|
||||||
|
{
|
||||||
|
async {
|
||||||
|
if Self::find(pool, self.get_id()).await?.is_some() {
|
||||||
|
self.update(pool).await
|
||||||
|
} else {
|
||||||
|
self.create(pool).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete the entity from the database if it exists.
|
/// Delete the entity from the database if it exists.
|
||||||
///
|
///
|
||||||
|
14
tests/fixtures/m2m.sql
vendored
Normal file
14
tests/fixtures/m2m.sql
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
INSERT INTO genres (name)
|
||||||
|
VALUES ('fantasy'),
|
||||||
|
('horror'),
|
||||||
|
('classic');
|
||||||
|
|
||||||
|
INSERT INTO book_genres (book_id, genre_id)
|
||||||
|
VALUES (1, 1),
|
||||||
|
(1, 3),
|
||||||
|
(2, 1),
|
||||||
|
(2, 3),
|
||||||
|
(3, 1),
|
||||||
|
(3, 3),
|
||||||
|
(4, 2),
|
||||||
|
(4, 3);
|
11
tests/fixtures/o2o.sql
vendored
Normal file
11
tests/fixtures/o2o.sql
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
INSERT INTO books (title, author_id)
|
||||||
|
VALUES ('The Lord of the Rings: The Fellowship of the Ring', 1),
|
||||||
|
('The Lord of the Rings: The Two Towers', 1),
|
||||||
|
('The Lord of the Rings: The Return of the King', 1),
|
||||||
|
('To Build a Fire', 3);
|
||||||
|
|
||||||
|
INSERT INTO reviews (book_id, review)
|
||||||
|
VALUES (1, 'Great book'),
|
||||||
|
(3, 'Awesome book'),
|
||||||
|
(2, 'Probably his best work!'),
|
||||||
|
(2, 'Greatest book');
|
8
tests/fixtures/simple_struct.sql
vendored
Normal file
8
tests/fixtures/simple_struct.sql
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
INSERT INTO biographies (content)
|
||||||
|
VALUES ('Some text'),
|
||||||
|
('Some other text');
|
||||||
|
|
||||||
|
INSERT INTO authors (name, biography_id)
|
||||||
|
VALUES ('J.R.R. Tolkien', 2),
|
||||||
|
('George Orwell', NULL),
|
||||||
|
('Jack London', 1);
|
20
tests/m2m_relationship.rs
Normal file
20
tests/m2m_relationship.rs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
use georm::Georm;
|
||||||
|
|
||||||
|
mod models;
|
||||||
|
use models::*;
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("simple_struct", "o2o", "m2m"))]
|
||||||
|
async fn genres_should_be_able_to_access_all_books(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let fantasy = Genre::find(&pool, &1).await?.unwrap();
|
||||||
|
let books = fantasy.get_books(&pool).await?;
|
||||||
|
assert_eq!(3, books.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("simple_struct", "o2o", "m2m"))]
|
||||||
|
async fn books_should_be_able_to_access_their_genres(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let to_build_a_fire = Book::find(&pool, &4).await?.unwrap();
|
||||||
|
let genres = to_build_a_fire.get_genres(&pool).await?;
|
||||||
|
assert_eq!(2, genres.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
91
tests/models.rs
Normal file
91
tests/models.rs
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
use georm::Georm;
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq, Default)]
|
||||||
|
#[georm(table = "biographies")]
|
||||||
|
pub struct Biography {
|
||||||
|
#[georm(id)]
|
||||||
|
pub id: i32,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq, Default)]
|
||||||
|
#[georm(table = "authors")]
|
||||||
|
pub struct Author {
|
||||||
|
#[georm(id)]
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
#[georm(relation = {entity = Biography, table = "biographies", name = "biography", nullable = true})]
|
||||||
|
pub biography_id: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialOrd for Author {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||||
|
Some(self.id.cmp(&other.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ord for Author {
|
||||||
|
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||||
|
self.id.cmp(&other.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq, Default)]
|
||||||
|
#[georm(
|
||||||
|
table = "books",
|
||||||
|
one_to_many = [
|
||||||
|
{ name = "reviews", remote_id = "book_id", table = "reviews", entity = Review }
|
||||||
|
],
|
||||||
|
many_to_many = [{
|
||||||
|
name = "genres",
|
||||||
|
table = "genres",
|
||||||
|
entity = Genre,
|
||||||
|
link = { table = "book_genres", from = "book_id", to = "genre_id" }
|
||||||
|
}]
|
||||||
|
)]
|
||||||
|
pub struct Book {
|
||||||
|
#[georm(id)]
|
||||||
|
ident: i32,
|
||||||
|
title: String,
|
||||||
|
#[georm(relation = {entity = Author, table = "authors", name = "author"})]
|
||||||
|
author_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialOrd for Book {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||||
|
Some(self.ident.cmp(&other.ident))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ord for Book {
|
||||||
|
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||||
|
self.ident.cmp(&other.ident)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq)]
|
||||||
|
#[georm(table = "reviews")]
|
||||||
|
pub struct Review {
|
||||||
|
#[georm(id)]
|
||||||
|
pub id: i32,
|
||||||
|
#[georm(relation = {entity = Book, table = "books", remote_id = "ident", name = "book"})]
|
||||||
|
pub book_id: i32,
|
||||||
|
pub review: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq)]
|
||||||
|
#[georm(
|
||||||
|
table = "genres",
|
||||||
|
many_to_many = [{
|
||||||
|
name = "books",
|
||||||
|
table = "books",
|
||||||
|
entity = Book,
|
||||||
|
remote_id = "ident",
|
||||||
|
link = { table = "book_genres", from = "genre_id", to = "book_id" }
|
||||||
|
}]
|
||||||
|
)]
|
||||||
|
pub struct Genre {
|
||||||
|
#[georm(id)]
|
||||||
|
id: i32,
|
||||||
|
name: String,
|
||||||
|
}
|
33
tests/o2m_relationship.rs
Normal file
33
tests/o2m_relationship.rs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
use georm::Georm;
|
||||||
|
|
||||||
|
mod models;
|
||||||
|
use models::*;
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("simple_struct", "o2o"))]
|
||||||
|
async fn books_access_one_review(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let book = Book::find(&pool, &1).await?.unwrap();
|
||||||
|
let reviews = book.get_reviews(&pool).await?;
|
||||||
|
let review = Review {
|
||||||
|
id: 1,
|
||||||
|
book_id: 1,
|
||||||
|
review: "Great book".into(),
|
||||||
|
};
|
||||||
|
assert_eq!(vec![review], reviews);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("simple_struct", "o2o"))]
|
||||||
|
async fn books_should_access_their_multiple_reviews(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let book = Book::find(&pool, &2).await?.unwrap();
|
||||||
|
let reviews = book.get_reviews(&pool).await?;
|
||||||
|
assert_eq!(2, reviews.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("simple_struct", "o2o"))]
|
||||||
|
async fn books_can_have_no_reviews(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let book = Book::find(&pool, &4).await?.unwrap();
|
||||||
|
let reviews = book.get_reviews(&pool).await?;
|
||||||
|
assert_eq!(0, reviews.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
55
tests/o2o_relationship.rs
Normal file
55
tests/o2o_relationship.rs
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
use georm::Georm;
|
||||||
|
|
||||||
|
mod models;
|
||||||
|
use models::*;
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("simple_struct", "o2o"))]
|
||||||
|
async fn book_should_have_working_get_author_method(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let book = Book::find(&pool, &1).await?;
|
||||||
|
assert!(book.is_some());
|
||||||
|
let book = book.unwrap();
|
||||||
|
let author = book.get_author(&pool).await?;
|
||||||
|
let expected_author = Author {
|
||||||
|
id: 1,
|
||||||
|
name: "J.R.R. Tolkien".into(),
|
||||||
|
biography_id: Some(2),
|
||||||
|
};
|
||||||
|
assert_eq!(expected_author, author);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("simple_struct"))]
|
||||||
|
async fn author_should_have_working_get_biography_method(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let author = Author::find(&pool, &1).await?;
|
||||||
|
assert!(author.is_some());
|
||||||
|
let author = author.unwrap();
|
||||||
|
let biography = author.get_biography(&pool).await?;
|
||||||
|
assert!(biography.is_some());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("simple_struct"))]
|
||||||
|
async fn author_should_have_optional_biographies(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let tolkien = Author::find(&pool, &1).await?;
|
||||||
|
assert!(tolkien.is_some());
|
||||||
|
let tolkien_biography = tolkien.unwrap().get_biography(&pool).await?;
|
||||||
|
assert!(tolkien_biography.is_some());
|
||||||
|
let biography = Biography {
|
||||||
|
id: 2,
|
||||||
|
content: "Some other text".into(),
|
||||||
|
};
|
||||||
|
assert_eq!(biography, tolkien_biography.unwrap());
|
||||||
|
let orwell = Author::find(&pool, &2).await?;
|
||||||
|
assert!(orwell.is_some());
|
||||||
|
assert!(orwell.unwrap().get_biography(&pool).await?.is_none());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("simple_struct", "o2o"))]
|
||||||
|
async fn books_are_found_despite_nonstandard_id_name(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let review = Review::find(&pool, &1).await?.unwrap();
|
||||||
|
let book = review.get_book(&pool).await?;
|
||||||
|
let tolkien = Author::find(&pool, &1).await?.unwrap();
|
||||||
|
assert_eq!(tolkien, book.get_author(&pool).await?);
|
||||||
|
Ok(())
|
||||||
|
}
|
164
tests/simple_struct.rs
Normal file
164
tests/simple_struct.rs
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
use georm::Georm;
|
||||||
|
use rand::seq::SliceRandom;
|
||||||
|
|
||||||
|
use models::Author;
|
||||||
|
mod models;
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("simple_struct"))]
|
||||||
|
async fn find_all_query_works(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let result = Author::find_all(&pool).await?;
|
||||||
|
assert_eq!(3, result.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn find_all_returns_empty_vec_on_empty_table(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let result = Author::find_all(&pool).await?;
|
||||||
|
assert_eq!(0, result.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("simple_struct"))]
|
||||||
|
async fn find_query_works(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let id = 1;
|
||||||
|
let res = Author::find(&pool, &id).await?;
|
||||||
|
assert!(res.is_some());
|
||||||
|
let res = res.unwrap();
|
||||||
|
assert_eq!(String::from("J.R.R. Tolkien"), res.name);
|
||||||
|
assert_eq!(1, res.id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn find_returns_none_if_not_found(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let res = Author::find(&pool, &420).await?;
|
||||||
|
assert!(res.is_none());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn create_works(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let author = Author {
|
||||||
|
id: 1,
|
||||||
|
name: "J.R.R. Tolkien".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
author.create(&pool).await?;
|
||||||
|
let all_authors = Author::find_all(&pool).await?;
|
||||||
|
assert_eq!(1, all_authors.len());
|
||||||
|
assert_eq!(vec![author], all_authors);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("simple_struct"))]
|
||||||
|
async fn create_fails_if_already_exists(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let author = Author {
|
||||||
|
id: 2,
|
||||||
|
name: "Miura Kentaro".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let result = author.create(&pool).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
let error = result.err().unwrap();
|
||||||
|
assert_eq!("error returned from database: duplicate key value violates unique constraint \"authors_pkey\"", error.to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("simple_struct"))]
|
||||||
|
async fn update_works(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let expected_initial = Author {
|
||||||
|
name: "J.R.R. Tolkien".into(),
|
||||||
|
id: 1,
|
||||||
|
biography_id: Some(2),
|
||||||
|
};
|
||||||
|
let expected_final = Author {
|
||||||
|
name: "Jolkien Rolkien Rolkien Tolkien".into(),
|
||||||
|
id: 1,
|
||||||
|
biography_id: Some(2),
|
||||||
|
};
|
||||||
|
let tolkien = Author::find(&pool, &1).await?;
|
||||||
|
assert!(tolkien.is_some());
|
||||||
|
let mut tolkien = tolkien.unwrap();
|
||||||
|
assert_eq!(expected_initial, tolkien);
|
||||||
|
tolkien.name = expected_final.name.clone();
|
||||||
|
let updated = tolkien.update(&pool).await?;
|
||||||
|
assert_eq!(expected_final, updated);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn update_fails_if_not_already_exists(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let author = Author {
|
||||||
|
id: 2,
|
||||||
|
name: "Miura Kentaro".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let result = author.update(&pool).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
let error = result.err().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
"no rows returned by a query that expected to return at least one row",
|
||||||
|
error.to_string()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn should_create_if_does_not_exist(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let all_authors = Author::find_all(&pool).await?;
|
||||||
|
assert_eq!(0, all_authors.len());
|
||||||
|
let author = Author {
|
||||||
|
id: 4,
|
||||||
|
name: "Miura Kentaro".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
author.create_or_update(&pool).await?;
|
||||||
|
let all_authors = Author::find_all(&pool).await?;
|
||||||
|
assert_eq!(1, all_authors.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("simple_struct"))]
|
||||||
|
async fn should_update_if_exist(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let all_authors = Author::find_all(&pool).await?;
|
||||||
|
assert_eq!(3, all_authors.len());
|
||||||
|
let author = Author {
|
||||||
|
id: 2,
|
||||||
|
name: "Miura Kentaro".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
author.create_or_update(&pool).await?;
|
||||||
|
let mut all_authors = Author::find_all(&pool).await?;
|
||||||
|
all_authors.sort();
|
||||||
|
assert_eq!(3, all_authors.len());
|
||||||
|
assert_eq!(author, all_authors[1]);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("simple_struct"))]
|
||||||
|
async fn delete_by_id_should_delete_only_one_entry(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
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));
|
||||||
|
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));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("simple_struct"))]
|
||||||
|
async fn delete_should_delete_current_entity_from_db(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let mut all_authors = Author::find_all(&pool).await?;
|
||||||
|
assert_eq!(3, all_authors.len());
|
||||||
|
all_authors.shuffle(&mut rand::rng());
|
||||||
|
let author = all_authors.first().unwrap();
|
||||||
|
let result = author.delete(&pool).await?;
|
||||||
|
assert_eq!(1, result);
|
||||||
|
let all_authors = Author::find_all(&pool).await?;
|
||||||
|
assert_eq!(2, all_authors.len());
|
||||||
|
assert!(all_authors.iter().all(|a| a.get_id() != author.get_id()));
|
||||||
|
Ok(())
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user