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"
|
||||
dependencies = [
|
||||
"georm-macros",
|
||||
"rand 0.9.0",
|
||||
"sqlx",
|
||||
]
|
||||
|
||||
@ -439,7 +440,19 @@ checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"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]]
|
||||
@ -765,7 +778,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasi 0.11.0+wasi-snapshot-preview1",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
@ -781,7 +794,7 @@ dependencies = [
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
@ -920,7 +933,7 @@ version = "0.2.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
"zerocopy 0.7.35",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -958,8 +971,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_chacha 0.3.1",
|
||||
"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]]
|
||||
@ -969,7 +993,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"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]]
|
||||
@ -978,7 +1012,17 @@ version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
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]]
|
||||
@ -1003,7 +1047,7 @@ dependencies = [
|
||||
"num-traits",
|
||||
"pkcs1",
|
||||
"pkcs8",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"signature",
|
||||
"spki",
|
||||
"subtle",
|
||||
@ -1114,7 +1158,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1276,7 +1320,7 @@ dependencies = [
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"rsa",
|
||||
"sha1",
|
||||
"sha2",
|
||||
@ -1313,7 +1357,7 @@ dependencies = [
|
||||
"md-5",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@ -1406,7 +1450,7 @@ checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"getrandom 0.2.15",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"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"
|
||||
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]]
|
||||
name = "wasite"
|
||||
version = "0.1.0"
|
||||
@ -1779,6 +1832,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "write16"
|
||||
version = "1.0.0"
|
||||
@ -1822,7 +1884,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
|
||||
dependencies = [
|
||||
"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]]
|
||||
@ -1836,6 +1907,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.5"
|
||||
|
@ -34,6 +34,9 @@ features = ["postgres", "runtime-tokio", "macros", "migrate"]
|
||||
sqlx = { workspace = true }
|
||||
georm-macros = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
rand = "0.9"
|
||||
|
||||
[workspace.lints.rust]
|
||||
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]
|
||||
# If there is a need to add another license, please refer to this
|
||||
# page: https://www.gnu.org/licenses/license-list.html
|
||||
# If there is a need to add another license, please refer to this page
|
||||
# for compatible licenses:
|
||||
# https://www.gnu.org/licenses/license-list.html
|
||||
allow = ["MIT", "Apache-2.0", "BSD-3-Clause", "Unicode-3.0", "Zlib"]
|
||||
confidence-threshold = 0.8
|
||||
|
||||
[bans]
|
||||
multiple-versions = "warn"
|
||||
multiple-versions = "allow"
|
||||
wildcards = "allow"
|
||||
highlight = "all"
|
||||
workspace-default-features = "allow"
|
||||
|
12
flake.lock
generated
12
flake.lock
generated
@ -20,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1737746512,
|
||||
"narHash": "sha256-nU6AezEX4EuahTO1YopzueAXfjFfmCHylYEFCagduHU=",
|
||||
"lastModified": 1738142207,
|
||||
"narHash": "sha256-NGqpVVxNAHwIicXpgaVqJEJWeyqzoQJ9oc8lnK9+WC4=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "825479c345a7f806485b7f00dbe3abb50641b083",
|
||||
"rev": "9d3ae807ebd2981d593cddd0080856873139aa40",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -62,11 +62,11 @@
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1737858462,
|
||||
"narHash": "sha256-rohhmT/b8QNaIL3nY01jFtCyZu2dGTufef5YieECWZM=",
|
||||
"lastModified": 1738290352,
|
||||
"narHash": "sha256-YKOHUmc0Clm4tMV8grnxYL4IIwtjTayoq/3nqk0QM7k=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "dd236609a6c272d00ceaa042b1a81a31968e7f4d",
|
||||
"rev": "b031b584125d33d23a0182f91ddbaf3ab4880236",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
21
flake.nix
21
flake.nix
@ -13,33 +13,12 @@
|
||||
overlays = [ (import rust-overlay) ];
|
||||
pkgs = import nixpkgs { inherit system overlays; };
|
||||
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 {
|
||||
packages = {
|
||||
lib = libRustBuildGeorm;
|
||||
};
|
||||
defaultPackage = libRustBuildGeorm;
|
||||
devShell = with pkgs; mkShell {
|
||||
buildInputs = [
|
||||
bacon
|
||||
cargo
|
||||
cargo-deny
|
||||
cargo-tarpaulin
|
||||
just
|
||||
rust-analyzer
|
||||
(rustVersion.override {
|
||||
|
@ -1,5 +1,4 @@
|
||||
use quote::quote;
|
||||
use std::fmt::{self, Display};
|
||||
|
||||
#[derive(deluxe::ExtractAttributes)]
|
||||
#[deluxe(attributes(georm))]
|
||||
@ -32,7 +31,7 @@ impl From<&O2MRelationship> for proc_macro2::TokenStream {
|
||||
);
|
||||
quote! {
|
||||
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,
|
||||
}
|
||||
|
||||
//#[georm(
|
||||
// table = "users",
|
||||
// many_to_many = [
|
||||
// {
|
||||
// name = friends,
|
||||
// entity: User,
|
||||
// link = { table = "user_friendships", from: "user1", to "user2" }
|
||||
// }
|
||||
// ]
|
||||
//)]
|
||||
#[derive(deluxe::ParseMetaItem)]
|
||||
pub struct M2MRelationship {
|
||||
pub name: String,
|
||||
@ -104,13 +93,11 @@ impl From<&M2MRelationshipComplete> for proc_macro2::TokenStream {
|
||||
);
|
||||
let entity = &value.entity;
|
||||
let query = format!(
|
||||
"
|
||||
SELECT remote.*
|
||||
"SELECT remote.*
|
||||
FROM {} local
|
||||
JOIN {} link ON link.{} = local.{}
|
||||
JOIN {} remote ON link.{} = remote.{}
|
||||
WHERE local.{} = $1
|
||||
",
|
||||
WHERE local.{} = $1",
|
||||
value.local.table,
|
||||
value.link.table,
|
||||
value.link.from,
|
||||
@ -122,7 +109,7 @@ WHERE local.{} = $1
|
||||
);
|
||||
quote! {
|
||||
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)]
|
||||
pub id: bool,
|
||||
#[deluxe(default = None)]
|
||||
pub column: Option<String>,
|
||||
#[deluxe(default = None)]
|
||||
pub relation: Option<O2ORelationship>,
|
||||
}
|
||||
|
||||
// #[georm(
|
||||
// table = "profileId",
|
||||
// one_to_one = { name = profile, id = "id", entity = Profile, nullable }
|
||||
// )]
|
||||
#[derive(deluxe::ParseMetaItem, Clone)]
|
||||
#[derive(deluxe::ParseMetaItem, Clone, Debug)]
|
||||
pub struct O2ORelationship {
|
||||
pub entity: syn::Type,
|
||||
pub table: String,
|
||||
@ -154,12 +135,11 @@ pub struct O2ORelationship {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GeormField {
|
||||
pub ident: syn::Ident,
|
||||
pub field: syn::Field,
|
||||
pub ty: syn::Type,
|
||||
pub column: Option<String>,
|
||||
pub id: bool,
|
||||
pub relation: Option<O2ORelationship>,
|
||||
}
|
||||
@ -170,40 +150,22 @@ impl GeormField {
|
||||
let ty = field.clone().ty;
|
||||
let attrs: GeormFieldAttributes =
|
||||
deluxe::extract_attributes(field).expect("Could not extract attributes from field");
|
||||
let GeormFieldAttributes {
|
||||
id,
|
||||
column,
|
||||
relation,
|
||||
} = attrs;
|
||||
let GeormFieldAttributes { id, relation } = attrs;
|
||||
Self {
|
||||
ident,
|
||||
field: field.to_owned(),
|
||||
id,
|
||||
ty,
|
||||
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 {
|
||||
fn from(value: &GeormField) -> Self {
|
||||
let Some(relation) = value.relation.clone() else {
|
||||
return quote! {};
|
||||
};
|
||||
|
||||
let function = syn::Ident::new(
|
||||
&format!("get_{}", relation.name),
|
||||
proc_macro2::Span::call_site(),
|
||||
@ -225,8 +187,8 @@ impl From<&GeormField> for proc_macro2::TokenStream {
|
||||
quote! { fetch_one }
|
||||
};
|
||||
quote! {
|
||||
pub async fn #function(&value, pool: &::sqlx::PgPool) -> ::sqlx::Result<#return_type> {
|
||||
query_as!(#entity, #query, value.#local_ident).#fetch(pool).await
|
||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<#return_type> {
|
||||
::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 =
|
||||
deluxe::extract_attributes(&mut ast).expect("Could not extract attributes from struct");
|
||||
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 trait_impl = trait_implementation::derive_trait(&ast, &struct_attrs.table, &fields, &id);
|
||||
let code = quote! {
|
||||
#trait_impl
|
||||
#relationships
|
||||
#trait_impl
|
||||
};
|
||||
println!("{code}");
|
||||
Ok(code)
|
||||
}
|
||||
|
@ -35,12 +35,12 @@ pub fn derive_relationships(
|
||||
id: &GeormField,
|
||||
) -> TokenStream {
|
||||
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 many_to_many: Vec<M2MRelationshipComplete> = struct_attrs
|
||||
.many_to_many
|
||||
.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();
|
||||
let many_to_many = derive(&many_to_many, |_| true);
|
||||
|
||||
|
@ -1,8 +1,17 @@
|
||||
use super::ir::GeormField;
|
||||
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 {
|
||||
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;
|
||||
quote! {
|
||||
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 *",
|
||||
fields
|
||||
.iter()
|
||||
.map(std::string::ToString::to_string)
|
||||
.map(|f| f.ident.to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join(", "),
|
||||
inputs.join(", ")
|
||||
@ -47,11 +56,12 @@ fn generate_update_query(
|
||||
let update_columns = fields
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &field)| format!("{field} = ${}", i + 1))
|
||||
.map(|(i, &field)| format!("{} = ${}", field.ident, i + 1))
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ");
|
||||
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.push(id);
|
||||
@ -70,7 +80,7 @@ fn generate_update_query(
|
||||
}
|
||||
|
||||
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;
|
||||
quote! {
|
||||
async fn delete_by_id(pool: &::sqlx::PgPool, id: &#ty) -> ::sqlx::Result<u64> {
|
||||
@ -104,13 +114,13 @@ pub fn derive_trait(
|
||||
id: &GeormField,
|
||||
) -> proc_macro2::TokenStream {
|
||||
let ty = &id.ty;
|
||||
let id_ident = &id.ident;
|
||||
|
||||
// define impl variables
|
||||
let ident = &ast.ident;
|
||||
let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl();
|
||||
|
||||
// generate
|
||||
let get_all = generate_find_all_query(table);
|
||||
let get_id = generate_get_id(id);
|
||||
let find_query = generate_find_query(table, id);
|
||||
let create_query = generate_create_query(table, fields);
|
||||
@ -118,19 +128,11 @@ pub fn derive_trait(
|
||||
let delete_query = generate_delete_query(table, id);
|
||||
quote! {
|
||||
impl #impl_generics Georm<#ty> for #ident #type_generics #where_clause {
|
||||
#get_all
|
||||
#get_id
|
||||
#find_query
|
||||
#create_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
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
use georm::georm_derive_macro2;
|
||||
|
||||
|
36
justfile
36
justfile
@ -2,17 +2,11 @@ mod docker
|
||||
|
||||
default: lint
|
||||
|
||||
format:
|
||||
cargo fmt --all
|
||||
clean:
|
||||
cargo clean
|
||||
|
||||
format-check:
|
||||
cargo fmt --check --all
|
||||
|
||||
build:
|
||||
cargo build
|
||||
|
||||
build-release:
|
||||
cargo build --release
|
||||
test:
|
||||
cargo test --all-targets --all
|
||||
|
||||
lint:
|
||||
cargo clippy --all-targets
|
||||
@ -20,18 +14,22 @@ lint:
|
||||
audit:
|
||||
cargo deny check all
|
||||
|
||||
test:
|
||||
cargo test --all-targets --all
|
||||
migrate:
|
||||
cargo sqlx migrate run
|
||||
|
||||
coverage:
|
||||
mkdir -p coverage
|
||||
cargo tarpaulin --config .tarpaulin.local.toml
|
||||
build:
|
||||
cargo build
|
||||
|
||||
coverage-ci:
|
||||
mkdir -p coverage
|
||||
cargo tarpaulin --config .tarpaulin.ci.toml
|
||||
build-release:
|
||||
cargo build --release
|
||||
|
||||
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:
|
||||
## 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 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.
|
||||
///
|
||||
/// # Errors
|
||||
@ -42,9 +301,18 @@ pub trait Georm<Id> {
|
||||
fn create_or_update(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
||||
) -> impl ::std::future::Future<Output = sqlx::Result<Self>>
|
||||
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.
|
||||
///
|
||||
|
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