mirror of
https://github.com/Phundrak/georm.git
synced 2025-12-16 09:41:53 +01:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
fcd0f57857
|
|||
|
7e7a3ccd29
|
|||
|
a7696270da
|
|||
|
19284665e6
|
|||
|
190c4d7b1d
|
|||
|
9e56952dc6
|
|||
|
0c3d5e6262
|
|||
|
8217a28a28
|
|||
|
ab2d80d2f6
|
|||
|
7cdaa27f3b
|
|||
|
a38b8e873d
|
|||
|
aafbfb7964
|
@@ -1,14 +0,0 @@
|
|||||||
;;; Directory Local Variables -*- no-byte-compile: t -*-
|
|
||||||
;;; For more information see (info "(emacs) Directory Variables")
|
|
||||||
|
|
||||||
((rustic-mode . ((fill-column . 80)))
|
|
||||||
(sql-mode . ((eval . (progn
|
|
||||||
(setq-local lsp-sqls-connections
|
|
||||||
`(((driver . "postgresql")
|
|
||||||
(dataSourceName \,
|
|
||||||
(format "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable"
|
|
||||||
(getenv "DB_HOST")
|
|
||||||
(getenv "DB_PORT")
|
|
||||||
(getenv "DB_USER")
|
|
||||||
(getenv "DB_PASSWORD")
|
|
||||||
(getenv "DB_NAME")))))))))))
|
|
||||||
9
.envrc
9
.envrc
@@ -1,2 +1,7 @@
|
|||||||
use flake
|
export DIRENV_WARN_TIMEOUT=20s
|
||||||
dotenv_if_exists
|
|
||||||
|
eval "$(devenv direnvrc)"
|
||||||
|
|
||||||
|
# The use_devenv function supports passing flags to the devenv command
|
||||||
|
# For example: use devenv --impure --option services.postgres.enable:bool true
|
||||||
|
use devenv
|
||||||
|
|||||||
16
.github/workflows/ci.yaml
vendored
16
.github/workflows/ci.yaml
vendored
@@ -32,16 +32,16 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install Nix
|
- name: Install Nix
|
||||||
uses: cachix/install-nix-action@v27
|
uses: cachix/install-nix-action@v31
|
||||||
with:
|
- name: Install devenv
|
||||||
nix_path: nixpkgs=channel:nixos-unstable
|
run: nix profile install nixpkgs#devenv
|
||||||
- name: Migrate database
|
- name: Migrate database
|
||||||
run: nix develop --command -- just migrate
|
run: devenv shell just migrate
|
||||||
- name: Formatting check
|
- name: Formatting check
|
||||||
run: nix develop --command -- just format-check
|
run: devenv shell just format-check
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: nix develop --command -- just lint
|
run: devenv shell just lint
|
||||||
- name: Audit
|
- name: Audit
|
||||||
run: nix develop --command -- just audit
|
run: devenv shell just audit
|
||||||
- name: Tests
|
- name: Tests
|
||||||
run: nix develop --command -- just test
|
run: devenv shell just test
|
||||||
|
|||||||
55
.gitignore
vendored
55
.gitignore
vendored
@@ -1,4 +1,57 @@
|
|||||||
.direnv
|
|
||||||
.env
|
.env
|
||||||
/coverage
|
/coverage
|
||||||
/target
|
/target
|
||||||
|
/.sqls
|
||||||
|
/examples/target
|
||||||
|
|
||||||
|
# Devenv
|
||||||
|
.devenv*
|
||||||
|
devenv.local.nix
|
||||||
|
|
||||||
|
# direnv
|
||||||
|
.direnv
|
||||||
|
|
||||||
|
# pre-commit
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
|
||||||
|
# Emacs backup files
|
||||||
|
*~
|
||||||
|
\#*\#
|
||||||
|
.\#*
|
||||||
|
.dir-locals.el
|
||||||
|
|
||||||
|
# Vim files
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# VS Code
|
||||||
|
.vscode/
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
# JetBrains IDEs
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# Linux
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
._*
|
||||||
|
|||||||
1049
Cargo.lock
generated
1049
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
20
Cargo.toml
20
Cargo.toml
@@ -1,9 +1,13 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [".", "georm-macros"]
|
members = [
|
||||||
|
".",
|
||||||
|
"georm-macros",
|
||||||
|
"examples/postgres/*"
|
||||||
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.1"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
authors = ["Lucien Cartier-Tilet <lucien@phundrak.com>"]
|
authors = ["Lucien Cartier-Tilet <lucien@phundrak.com>"]
|
||||||
homepage = "https://github.com/Phundrak/georm"
|
homepage = "https://github.com/Phundrak/georm"
|
||||||
repository = "https://github.com/Phundrak/georm"
|
repository = "https://github.com/Phundrak/georm"
|
||||||
@@ -23,10 +27,10 @@ repository.workspace = true
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
georm-macros = { version = "=0.1.1", path = "georm-macros" }
|
georm-macros = { path = "georm-macros" }
|
||||||
|
|
||||||
[workspace.dependencies.sqlx]
|
[workspace.dependencies.sqlx]
|
||||||
version = "0.8.3"
|
version = "0.8.6"
|
||||||
default-features = false
|
default-features = false
|
||||||
features = ["postgres", "runtime-tokio", "macros", "migrate"]
|
features = ["postgres", "runtime-tokio", "macros", "migrate"]
|
||||||
|
|
||||||
@@ -35,8 +39,14 @@ sqlx = { workspace = true }
|
|||||||
georm-macros = { workspace = true }
|
georm-macros = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
|
|
||||||
|
[dev-dependencies.sqlx]
|
||||||
|
version = "0.8.6"
|
||||||
|
default-features = false
|
||||||
|
features = ["postgres", "runtime-tokio", "macros", "migrate", "chrono"]
|
||||||
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
|
||||||
|
|||||||
799
README.md
799
README.md
@@ -1,116 +1,314 @@
|
|||||||
|
<div align="center">
|
||||||
|
<a href="https://github.com/Phundrak/georm">
|
||||||
|
<img src="assets/logo.png" alt="Georm logo" width="150px" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h1 align="center">Georm</h1>
|
<h1 align="center">Georm</h1>
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<strong>
|
<strong>
|
||||||
A simple, opinionated SQLx ORM for PostgreSQL
|
A simple, type-safe SQLx ORM for PostgreSQL
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<!-- Github Actions -->
|
<!-- Github Actions -->
|
||||||
<a href="https://github.com/phundrak/georm/actions/workflows/ci.yaml?query=branch%3Amain">
|
<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>
|
<img src="https://img.shields.io/github/actions/workflow/status/phundrak/georm/ci.yaml?branch=main&style=flat-square" alt="actions status" />
|
||||||
|
</a>
|
||||||
<!-- Version -->
|
<!-- Version -->
|
||||||
<a href="https://crates.io/crates/georm">
|
<a href="https://crates.io/crates/georm">
|
||||||
<img src="https://img.shields.io/crates/v/georm.svg?style=flat-square"
|
<img src="https://img.shields.io/crates/v/georm.svg?style=flat-square" alt="Crates.io version" />
|
||||||
alt="Crates.io version" /></a>
|
</a>
|
||||||
<!-- Discord -->
|
|
||||||
<!-- Docs -->
|
<!-- Docs -->
|
||||||
<a href="https://docs.rs/georm">
|
<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>
|
<img src="https://img.shields.io/badge/docs-latest-blue.svg?style=flat-square" alt="docs.rs docs" />
|
||||||
|
</a>
|
||||||
|
<!-- License -->
|
||||||
|
<a href="#license">
|
||||||
|
<img src="https://img.shields.io/badge/license-MIT%20OR%20GPL--3.0-blue?style=flat-square" alt="License" />
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div align="center">
|
## Overview
|
||||||
<h4>What is Georm?</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
Georm is a quite simple ORM built around
|
Georm is a lightweight, opinionated Object-Relational Mapping (ORM) library built on top of [SQLx](https://crates.io/crates/sqlx) for PostgreSQL. It provides a clean, type-safe interface for common database operations while leveraging SQLx's compile-time query verification.
|
||||||
[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">
|
### Key Features
|
||||||
<h4>Why is Georm?</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
I wanted an ORM that’s easy and straightforward to use. I am aware
|
- **Type Safety**: Compile-time verified SQL queries using SQLx macros
|
||||||
some other projects exist, such as
|
- **Zero Runtime Cost**: No reflection or runtime query building
|
||||||
[SeaORM](https://www.sea-ql.org/SeaORM/), but they generally don’t fit
|
- **Simple API**: Intuitive derive macros for common operations
|
||||||
my needs and/or my wants of a simple interface. I ended up writing the
|
- **Relationship Support**: One-to-one, one-to-many, and many-to-many relationships
|
||||||
ORM I wanted to use.
|
- **Composite Primary Keys**: Support for multi-field primary keys
|
||||||
|
- **Defaultable Fields**: Easy entity creation with database defaults and auto-generated values
|
||||||
|
- **PostgreSQL Native**: Optimized for PostgreSQL features and data types
|
||||||
|
|
||||||
<div align="center">
|
## Quick Start
|
||||||
<h4>How is Georm?</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
I use it in a few projects, and I’m quite happy with it right now. But
|
### Installation
|
||||||
of course, I’m open to constructive criticism and suggestions!
|
|
||||||
|
|
||||||
<div align="center">
|
Add Georm and SQLx to your `Cargo.toml`:
|
||||||
<h4>How can I use it?</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
Georm works with SQLx, but does not re-export it itself. To get
|
```toml
|
||||||
started, install both Georm and SQLx in your Rust project:
|
[dependencies]
|
||||||
|
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "macros"] }
|
||||||
```sh
|
georm = "0.1"
|
||||||
cargo add sqlx --features postgres,macros # and any other feature you might want
|
|
||||||
cargo add georm
|
|
||||||
```
|
```
|
||||||
|
|
||||||
As Georm relies heavily on the macro
|
### Basic Usage
|
||||||
[`query_as!`](https://docs.rs/sqlx/latest/sqlx/macro.query_as.html),
|
|
||||||
the `macros` feature is not optional. Declare your tables in your
|
1. **Define your database schema**:
|
||||||
Postgres database (you may want to use SQLx’s `migrate` feature for
|
|
||||||
this), and then declare their equivalent in Rust.
|
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE biographies (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
content TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE authors (
|
CREATE TABLE authors (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
name VARCHAR(100) NOT NULL,
|
name VARCHAR(100) NOT NULL,
|
||||||
biography_id INT,
|
email VARCHAR(255) UNIQUE NOT NULL
|
||||||
FOREIGN KEY (biography_id) REFERENCES biographies(id)
|
);
|
||||||
|
|
||||||
|
CREATE TABLE posts (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
published BOOLEAN DEFAULT FALSE,
|
||||||
|
author_id INT NOT NULL REFERENCES authors(id),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
```rust
|
2. **Define your Rust entities**:
|
||||||
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
|
```rust
|
||||||
#[derive(sqlx::FromRow, Georm)]
|
use georm::Georm;
|
||||||
pub struct Author {
|
|
||||||
pub id: i32,
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Now, indicate with the `georm` proc-macro which table they refer to.
|
#[derive(Georm)]
|
||||||
```rust
|
|
||||||
#[derive(sqlx::FromRow, Georm)]
|
|
||||||
#[georm(table = "authors")]
|
#[georm(table = "authors")]
|
||||||
pub struct Author {
|
pub struct Author {
|
||||||
|
#[georm(id)]
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Georm)]
|
||||||
|
#[georm(table = "posts")]
|
||||||
|
pub struct Post {
|
||||||
|
#[georm(id)]
|
||||||
|
pub id: i32,
|
||||||
|
pub title: String,
|
||||||
|
pub content: String,
|
||||||
|
pub published: bool,
|
||||||
|
#[georm(relation = {
|
||||||
|
entity = Author,
|
||||||
|
table = "authors",
|
||||||
|
name = "author"
|
||||||
|
})]
|
||||||
|
pub author_id: i32,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Finally, indicate with the same proc-macro which field of your struct
|
3. **Use the generated methods**:
|
||||||
is the primary key in your database.
|
|
||||||
```rust
|
```rust
|
||||||
#[derive(sqlx::FromRow, Georm)]
|
use sqlx::PgPool;
|
||||||
#[georm(table = "authors")]
|
|
||||||
|
async fn example(pool: &PgPool) -> sqlx::Result<()> {
|
||||||
|
// Create an author
|
||||||
|
let author = Author {
|
||||||
|
id: 0, // Will be auto-generated
|
||||||
|
name: "Jane Doe".to_string(),
|
||||||
|
email: "jane@example.com".to_string(),
|
||||||
|
};
|
||||||
|
let author = author.create(pool).await?;
|
||||||
|
|
||||||
|
// Create a post
|
||||||
|
let post = Post {
|
||||||
|
id: 0,
|
||||||
|
title: "Hello, Georm!".to_string(),
|
||||||
|
content: "This is my first post using Georm.".to_string(),
|
||||||
|
published: false,
|
||||||
|
author_id: author.id,
|
||||||
|
created_at: chrono::Utc::now(),
|
||||||
|
};
|
||||||
|
let post = post.create(pool).await?;
|
||||||
|
|
||||||
|
// Find all posts
|
||||||
|
let all_posts = Post::find_all(pool).await?;
|
||||||
|
|
||||||
|
// Get the post's author
|
||||||
|
let post_author = post.get_author(pool).await?;
|
||||||
|
|
||||||
|
println!("Post '{}' by {}", post.title, post_author.name);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Features
|
||||||
|
|
||||||
|
### Composite Primary Keys
|
||||||
|
|
||||||
|
Georm supports composite primary keys by marking multiple fields with `#[georm(id)]`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Georm)]
|
||||||
|
#[georm(table = "user_roles")]
|
||||||
|
pub struct UserRole {
|
||||||
|
#[georm(id)]
|
||||||
|
pub user_id: i32,
|
||||||
|
#[georm(id)]
|
||||||
|
pub role_id: i32,
|
||||||
|
pub assigned_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This automatically generates a composite ID struct:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Generated automatically
|
||||||
|
pub struct UserRoleId {
|
||||||
|
pub user_id: i32,
|
||||||
|
pub role_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
let id = UserRoleId { user_id: 1, role_id: 2 };
|
||||||
|
let user_role = UserRole::find(pool, &id).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Relationships are not yet supported for entities with composite primary keys.
|
||||||
|
|
||||||
|
### Defaultable Fields
|
||||||
|
|
||||||
|
For fields with database defaults or auto-generated values, use the `defaultable` attribute:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Georm)]
|
||||||
|
#[georm(table = "posts")]
|
||||||
|
pub struct Post {
|
||||||
|
#[georm(id, defaultable)]
|
||||||
|
pub id: i32, // Auto-generated serial
|
||||||
|
pub title: String,
|
||||||
|
#[georm(defaultable)]
|
||||||
|
pub published: bool, // Has database default (false)
|
||||||
|
#[georm(defaultable)]
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>, // DEFAULT NOW()
|
||||||
|
pub author_id: i32,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This generates a `PostDefault` struct for easier creation:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use georm::Defaultable;
|
||||||
|
|
||||||
|
let post_default = PostDefault {
|
||||||
|
id: None, // Let database auto-generate
|
||||||
|
title: "My Post".to_string(),
|
||||||
|
published: None, // Use database default
|
||||||
|
created_at: None, // Use database default (NOW())
|
||||||
|
author_id: 42,
|
||||||
|
};
|
||||||
|
|
||||||
|
let created_post = post_default.create(pool).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Relationships
|
||||||
|
|
||||||
|
Georm supports comprehensive relationship modeling with two approaches: field-level relationships for foreign keys and struct-level relationships for reverse lookups.
|
||||||
|
|
||||||
|
#### Field-Level Relationships (Foreign Keys)
|
||||||
|
|
||||||
|
Use the `relation` attribute on foreign key fields to generate lookup methods:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Georm)]
|
||||||
|
#[georm(table = "posts")]
|
||||||
|
pub struct Post {
|
||||||
|
#[georm(id)]
|
||||||
|
pub id: i32,
|
||||||
|
pub title: String,
|
||||||
|
#[georm(relation = {
|
||||||
|
entity = Author, // Target entity type
|
||||||
|
table = "authors", // Target table name
|
||||||
|
name = "author", // Method name (generates get_author)
|
||||||
|
remote_id = "id", // Target table's key column (default: "id")
|
||||||
|
nullable = false // Whether relationship can be null (default: false)
|
||||||
|
})]
|
||||||
|
pub author_id: i32,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generated method**: `post.get_author(pool).await? -> Author`
|
||||||
|
|
||||||
|
For nullable relationships:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Georm)]
|
||||||
|
#[georm(table = "posts")]
|
||||||
|
pub struct Post {
|
||||||
|
#[georm(id)]
|
||||||
|
pub id: i32,
|
||||||
|
pub title: String,
|
||||||
|
#[georm(relation = {
|
||||||
|
entity = Category,
|
||||||
|
table = "categories",
|
||||||
|
name = "category",
|
||||||
|
nullable = true // Allows NULL values
|
||||||
|
})]
|
||||||
|
pub category_id: Option<i32>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generated method**: `post.get_category(pool).await? -> Option<Category>`
|
||||||
|
|
||||||
|
#### Struct-Level Relationships (Reverse Lookups)
|
||||||
|
|
||||||
|
Define relationships at the struct level to query related entities that reference this entity:
|
||||||
|
|
||||||
|
##### One-to-One Relationships
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Georm)]
|
||||||
|
#[georm(
|
||||||
|
table = "users",
|
||||||
|
one_to_one = [{
|
||||||
|
entity = Profile, // Related entity type
|
||||||
|
name = "profile", // Method name (generates get_profile)
|
||||||
|
table = "profiles", // Related table name
|
||||||
|
remote_id = "user_id", // Foreign key in related table
|
||||||
|
}]
|
||||||
|
)]
|
||||||
|
pub struct User {
|
||||||
|
#[georm(id)]
|
||||||
|
pub id: i32,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generated method**: `user.get_profile(pool).await? -> Option<Profile>`
|
||||||
|
|
||||||
|
##### One-to-Many Relationships
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Georm)]
|
||||||
|
#[georm(
|
||||||
|
table = "authors",
|
||||||
|
one_to_many = [{
|
||||||
|
entity = Post, // Related entity type
|
||||||
|
name = "posts", // Method name (generates get_posts)
|
||||||
|
table = "posts", // Related table name
|
||||||
|
remote_id = "author_id" // Foreign key in related table
|
||||||
|
}, {
|
||||||
|
entity = Comment, // Multiple relationships allowed
|
||||||
|
name = "comments",
|
||||||
|
table = "comments",
|
||||||
|
remote_id = "author_id"
|
||||||
|
}]
|
||||||
|
)]
|
||||||
pub struct Author {
|
pub struct Author {
|
||||||
#[georm(id)]
|
#[georm(id)]
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
@@ -118,38 +316,457 @@ pub struct Author {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Congratulations, your struct `Author` now has access to all the
|
**Generated methods**:
|
||||||
functions described in the `Georm` trait!
|
- `author.get_posts(pool).await? -> Vec<Post>`
|
||||||
|
- `author.get_comments(pool).await? -> Vec<Comment>`
|
||||||
|
|
||||||
<div align="center">
|
##### Many-to-Many Relationships
|
||||||
<h4>Entity relationship</h4>
|
|
||||||
</div>
|
For many-to-many relationships, specify the link table that connects the entities:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Example schema for books and genres
|
||||||
|
CREATE TABLE books (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
title VARCHAR(200) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE genres (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE book_genres (
|
||||||
|
book_id INT NOT NULL REFERENCES books(id),
|
||||||
|
genre_id INT NOT NULL REFERENCES genres(id),
|
||||||
|
PRIMARY KEY (book_id, genre_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
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
|
```rust
|
||||||
#[derive(sqlx::FromRow, Georm)]
|
#[derive(Georm)]
|
||||||
#[georm(
|
#[georm(
|
||||||
table = "books",
|
table = "books",
|
||||||
one_to_many = [
|
|
||||||
{ name = "reviews", remote_id = "book_id", table = "reviews", entity = Review }
|
|
||||||
],
|
|
||||||
many_to_many = [{
|
many_to_many = [{
|
||||||
name = "genres",
|
entity = Genre, // Related entity type
|
||||||
table = "genres",
|
name = "genres", // Method name (generates get_genres)
|
||||||
entity = Genre,
|
table = "genres", // Related table name
|
||||||
link = { table = "book_genres", from = "book_id", to = "genre_id" }
|
remote_id = "id", // Primary key in related table (default: "id")
|
||||||
|
link = { // Link table configuration
|
||||||
|
table = "book_genres", // Join table name
|
||||||
|
from = "book_id", // Column referencing this entity
|
||||||
|
to = "genre_id" // Column referencing related entity
|
||||||
|
}
|
||||||
}]
|
}]
|
||||||
)]
|
)]
|
||||||
pub struct Book {
|
pub struct Book {
|
||||||
#[georm(id)]
|
#[georm(id)]
|
||||||
ident: i32,
|
pub id: i32,
|
||||||
title: String,
|
pub title: String,
|
||||||
#[georm(relation = {entity = Author, table = "authors", name = "author"})]
|
}
|
||||||
author_id: i32,
|
|
||||||
|
#[derive(Georm)]
|
||||||
|
#[georm(
|
||||||
|
table = "genres",
|
||||||
|
many_to_many = [{
|
||||||
|
entity = Book,
|
||||||
|
name = "books",
|
||||||
|
table = "books",
|
||||||
|
link = {
|
||||||
|
table = "book_genres",
|
||||||
|
from = "genre_id", // Note: reversed perspective
|
||||||
|
to = "book_id"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
)]
|
||||||
|
pub struct Genre {
|
||||||
|
#[georm(id)]
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
To read more about these features, you can refer to the [online
|
**Generated methods**:
|
||||||
documentation](https://docs.rs/georm/).
|
- `book.get_genres(pool).await? -> Vec<Genre>`
|
||||||
|
- `genre.get_books(pool).await? -> Vec<Book>`
|
||||||
|
|
||||||
|
#### Relationship Attribute Reference
|
||||||
|
|
||||||
|
| Attribute | Description | Required | Default |
|
||||||
|
|--------------|------------------------------------------------------|----------|---------|
|
||||||
|
| `entity` | Target entity type | Yes | N/A |
|
||||||
|
| `name` | Method name (generates `get_{name}`) | Yes | N/A |
|
||||||
|
| `table` | Target table name | Yes | N/A |
|
||||||
|
| `remote_id` | Target table's key column | No | `"id"` |
|
||||||
|
| `nullable` | Whether relationship can be null (field-level only) | No | `false` |
|
||||||
|
| `link.table` | Join table name (many-to-many only) | Yes* | N/A |
|
||||||
|
| `link.from` | Column referencing this entity (many-to-many only) | Yes* | N/A |
|
||||||
|
| `link.to` | Column referencing target entity (many-to-many only) | Yes* | N/A |
|
||||||
|
|
||||||
|
*Required for many-to-many relationships
|
||||||
|
|
||||||
|
#### Complex Relationship Example
|
||||||
|
|
||||||
|
Here's a comprehensive example showing multiple relationship types:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Georm)]
|
||||||
|
#[georm(
|
||||||
|
table = "posts",
|
||||||
|
one_to_many = [{
|
||||||
|
entity = Comment,
|
||||||
|
name = "comments",
|
||||||
|
table = "comments",
|
||||||
|
remote_id = "post_id"
|
||||||
|
}],
|
||||||
|
many_to_many = [{
|
||||||
|
entity = Tag,
|
||||||
|
name = "tags",
|
||||||
|
table = "tags",
|
||||||
|
link = {
|
||||||
|
table = "post_tags",
|
||||||
|
from = "post_id",
|
||||||
|
to = "tag_id"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
)]
|
||||||
|
pub struct Post {
|
||||||
|
#[georm(id)]
|
||||||
|
pub id: i32,
|
||||||
|
pub title: String,
|
||||||
|
pub content: String,
|
||||||
|
|
||||||
|
// Field-level relationship (foreign key)
|
||||||
|
#[georm(relation = {
|
||||||
|
entity = Author,
|
||||||
|
table = "authors",
|
||||||
|
name = "author"
|
||||||
|
})]
|
||||||
|
pub author_id: i32,
|
||||||
|
|
||||||
|
// Nullable field-level relationship
|
||||||
|
#[georm(relation = {
|
||||||
|
entity = Category,
|
||||||
|
table = "categories",
|
||||||
|
name = "category",
|
||||||
|
nullable = true
|
||||||
|
})]
|
||||||
|
pub category_id: Option<i32>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generated methods**:
|
||||||
|
- `post.get_author(pool).await? -> Author` (from field relation)
|
||||||
|
- `post.get_category(pool).await? -> Option<Category>` (nullable field relation)
|
||||||
|
- `post.get_comments(pool).await? -> Vec<Comment>` (one-to-many)
|
||||||
|
- `post.get_tags(pool).await? -> Vec<Tag>` (many-to-many)
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Core Operations
|
||||||
|
|
||||||
|
All entities implementing `Georm<Id>` get these methods:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Query operations
|
||||||
|
Post::find_all(pool).await?; // Find all posts
|
||||||
|
Post::find(pool, &post_id).await?; // Find by ID
|
||||||
|
|
||||||
|
// Mutation operations
|
||||||
|
post.create(pool).await?; // Insert new record
|
||||||
|
post.update(pool).await?; // Update existing record
|
||||||
|
post.create_or_update(pool).await?; // Upsert operation
|
||||||
|
post.delete(pool).await?; // Delete this record
|
||||||
|
Post::delete_by_id(pool, &post_id).await?; // Delete by ID
|
||||||
|
|
||||||
|
// Utility
|
||||||
|
post.get_id(); // Get entity ID
|
||||||
|
```
|
||||||
|
|
||||||
|
### Defaultable Operations
|
||||||
|
|
||||||
|
Entities with defaultable fields get a companion `<Entity>Default` struct:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Create with defaults
|
||||||
|
post_default.create(pool).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Attributes Reference
|
||||||
|
|
||||||
|
#### Struct-level attributes
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[georm(
|
||||||
|
table = "table_name", // Required: database table name
|
||||||
|
one_to_one = [{ /* ... */ }], // Optional: one-to-one relationships
|
||||||
|
one_to_many = [{ /* ... */ }], // Optional: one-to-many relationships
|
||||||
|
many_to_many = [{ /* ... */ }] // Optional: many-to-many relationships
|
||||||
|
)]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Field-level attributes
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[georm(id)] // Mark as primary key
|
||||||
|
#[georm(defaultable)] // Mark as defaultable field
|
||||||
|
#[georm(relation = { /* ... */ })] // Define relationship
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
Georm is designed for zero runtime overhead:
|
||||||
|
|
||||||
|
- **Compile-time queries**: All SQL is verified at compile time
|
||||||
|
- **No reflection**: Direct field access, no runtime introspection
|
||||||
|
- **Minimal allocations**: Efficient use of owned vs borrowed data
|
||||||
|
- **SQLx integration**: Leverages SQLx's optimized PostgreSQL driver
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Comprehensive Example
|
||||||
|
|
||||||
|
For an example showcasing user management, comments, and follower relationships, see the example in `examples/postgres/users-comments-and-followers/`. This example demonstrates:
|
||||||
|
|
||||||
|
- User management and profile management
|
||||||
|
- Comment system with user associations
|
||||||
|
- Follower/following relationships (many-to-many)
|
||||||
|
- Interactive CLI interface with CRUD operations
|
||||||
|
- Database migrations and schema setup
|
||||||
|
|
||||||
|
To run the example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set up your database
|
||||||
|
export DATABASE_URL="postgres://username:password@localhost/georm_example"
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
cargo sqlx migrate run
|
||||||
|
|
||||||
|
# Run the example
|
||||||
|
cd examples/postgres/users-comments-and-followers
|
||||||
|
cargo run help # For a list of all available actions
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comparison
|
||||||
|
|
||||||
|
| Feature | Georm | SeaORM | Diesel |
|
||||||
|
|----------------------|-------|--------|--------|
|
||||||
|
| Compile-time safety | ✅ | ✅ | ✅ |
|
||||||
|
| Relationship support | ✅ | ✅ | ✅ |
|
||||||
|
| Async support | ✅ | ✅ | ⚠️ |
|
||||||
|
| Learning curve | Low | Medium | High |
|
||||||
|
| Macro simplicity | ✅ | ❌ | ❌ |
|
||||||
|
| Advanced queries | ❌ | ✅ | ✅ |
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
### High Priority
|
||||||
|
- **Transaction Support**: Comprehensive transaction handling with atomic operations
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
- **Composite Key Relationships**: Add relationship support (one-to-one, one-to-many, many-to-many) for entities with composite primary keys
|
||||||
|
- **Multi-Database Support**: MySQL and SQLite support with feature flags
|
||||||
|
- **Field-Based Queries**: Generate `find_by_{field_name}` methods that return `Vec<T>` for regular fields or `Option<T>` for unique fields
|
||||||
|
- **Relationship Optimization**: Eager loading and N+1 query prevention
|
||||||
|
- **Soft Delete**: Optional soft delete with `deleted_at` timestamps
|
||||||
|
|
||||||
|
### Lower Priority
|
||||||
|
- **Migration Support**: Schema generation and evolution utilities
|
||||||
|
- **Enhanced Error Handling**: Custom error types with better context
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
#### Prerequisites
|
||||||
|
|
||||||
|
- **Rust 1.86+**: Georm uses modern Rust features and follows the MSRV specified in `rust-toolchain.toml`
|
||||||
|
- **PostgreSQL 12+**: Required for running tests and development
|
||||||
|
- **Git**: For version control
|
||||||
|
- **Jujutsu**: For version control (alternative to Git)
|
||||||
|
|
||||||
|
#### Required Tools
|
||||||
|
|
||||||
|
The following tools are used in the development workflow:
|
||||||
|
|
||||||
|
- **[just](https://github.com/casey/just)**: Task runner for common development commands
|
||||||
|
- **[cargo-deny](https://github.com/EmbarkStudios/cargo-deny)**: License and security auditing
|
||||||
|
- **[sqlx-cli](https://github.com/launchbadge/sqlx/tree/main/sqlx-cli)**: Database migrations and management
|
||||||
|
- **[bacon](https://github.com/Canop/bacon)**: Background code checker (optional but recommended)
|
||||||
|
|
||||||
|
Install these tools:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install just (task runner)
|
||||||
|
cargo install just
|
||||||
|
|
||||||
|
# Install cargo-deny (for auditing)
|
||||||
|
cargo install cargo-deny
|
||||||
|
|
||||||
|
# Install sqlx-cli (for database management)
|
||||||
|
cargo install sqlx-cli --no-default-features --features native-tls,postgres
|
||||||
|
|
||||||
|
# Install bacon (optional, for live feedback)
|
||||||
|
cargo install bacon
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/Phundrak/georm.git
|
||||||
|
cd georm
|
||||||
|
|
||||||
|
# Set up your PostgreSQL database and set DATABASE_URL
|
||||||
|
export DATABASE_URL="postgres://username:password@localhost/georm_test"
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
just migrate
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
|
just test
|
||||||
|
|
||||||
|
# Run linting
|
||||||
|
just lint
|
||||||
|
|
||||||
|
# Run security audit
|
||||||
|
just audit
|
||||||
|
|
||||||
|
# Run all checks (format, lint, audit, test)
|
||||||
|
just check-all
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Available Commands (via just)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just # Default: run linting
|
||||||
|
just build # Build the project
|
||||||
|
just build-release # Build in release mode
|
||||||
|
just test # Run all tests
|
||||||
|
just lint # Run clippy linting
|
||||||
|
just audit # Run security and license audit
|
||||||
|
just migrate # Run database migrations
|
||||||
|
just format # Format all code
|
||||||
|
just format-check # Check code formatting
|
||||||
|
just check-all # Run all checks (format, lint, audit, test)
|
||||||
|
just clean # Clean build artifacts
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Running Specific Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests for a specific module
|
||||||
|
cargo test --test simple_struct
|
||||||
|
cargo test --test defaultable_struct
|
||||||
|
cargo test --test m2m_relationship
|
||||||
|
|
||||||
|
# Run tests with output
|
||||||
|
cargo test -- --nocapture
|
||||||
|
|
||||||
|
# Run a specific test function
|
||||||
|
cargo test defaultable_struct_should_exist
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Development with Bacon (Optional)
|
||||||
|
|
||||||
|
For continuous feedback during development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run clippy continuously
|
||||||
|
bacon
|
||||||
|
|
||||||
|
# Run tests continuously
|
||||||
|
bacon test
|
||||||
|
|
||||||
|
# Build docs continuously
|
||||||
|
bacon doc
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Devenv Development Environment (Optional)
|
||||||
|
|
||||||
|
If you use [Nix](https://nixos.org/), you can use the provided devenv configuration for a reproducible development environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enter the development shell with all tools pre-installed
|
||||||
|
devenv shell
|
||||||
|
|
||||||
|
# Or use direnv for automatic environment activation
|
||||||
|
direnv allow
|
||||||
|
```
|
||||||
|
|
||||||
|
The devenv configuration provides:
|
||||||
|
- Exact Rust version (1.86) with required components
|
||||||
|
- All development tools (just, cargo-deny, sqlx-cli, bacon)
|
||||||
|
- LSP support (rust-analyzer)
|
||||||
|
- SQL tooling (sqls for SQL language server)
|
||||||
|
- PostgreSQL database for development
|
||||||
|
|
||||||
|
**Devenv configuration:**
|
||||||
|
- **Rust toolchain**: Specified version with rustfmt, clippy, and rust-analyzer
|
||||||
|
- **Development tools**: just, cargo-deny, sqlx-cli, bacon
|
||||||
|
- **SQL tools**: sqls (SQL language server)
|
||||||
|
- **Database**: PostgreSQL with automatic setup
|
||||||
|
- **Platform support**: Cross-platform (Linux, macOS, etc.)
|
||||||
|
|
||||||
|
#### Database Setup for Tests
|
||||||
|
|
||||||
|
Tests require a PostgreSQL database. Set up a test database:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Connect to PostgreSQL as superuser
|
||||||
|
CREATE DATABASE georm_test;
|
||||||
|
CREATE USER georm_user WITH PASSWORD 'georm_password';
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE georm_test TO georm_user;
|
||||||
|
```
|
||||||
|
|
||||||
|
Set the environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DATABASE_URL="postgres://georm_user:georm_password@localhost/georm_test"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### IDE Setup
|
||||||
|
|
||||||
|
- Ensure `rust-analyzer` is configured
|
||||||
|
- Set up PostgreSQL connection for SQL syntax highlighting
|
||||||
|
|
||||||
|
#### Code Style
|
||||||
|
|
||||||
|
The project uses standard Rust formatting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Format code
|
||||||
|
just format
|
||||||
|
|
||||||
|
# Check formatting (CI)
|
||||||
|
just format-check
|
||||||
|
```
|
||||||
|
|
||||||
|
Clippy linting is enforced:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run linting
|
||||||
|
just lint
|
||||||
|
|
||||||
|
# Fix auto-fixable lints
|
||||||
|
cargo clippy --fix
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Licensed under either of
|
||||||
|
|
||||||
|
* MIT License ([LICENSE-MIT](LICENSE-MIT.md) or http://opensource.org/licenses/MIT)
|
||||||
|
* GNU General Public License v3.0 ([LICENSE-GPL](LICENSE-GPL.md) or https://www.gnu.org/licenses/gpl-3.0.html)
|
||||||
|
|
||||||
|
at your option.
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
- Built on top of the excellent [SQLx](https://github.com/launchbadge/sqlx) library
|
||||||
|
- Inspired by [Hibernate](https://hibernate.org/)
|
||||||
|
|||||||
BIN
assets/logo.png
Normal file
BIN
assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
1272
assets/logo.svg
Normal file
1272
assets/logo.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 59 KiB |
123
devenv.lock
Normal file
123
devenv.lock
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"devenv": {
|
||||||
|
"locked": {
|
||||||
|
"dir": "src/modules",
|
||||||
|
"lastModified": 1749054588,
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv",
|
||||||
|
"rev": "b6be42d9e6f6053be1d180e4a4fb95e0aa9a8424",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"dir": "src/modules",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-compat": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1747046372,
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"git-hooks": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": "flake-compat",
|
||||||
|
"gitignore": "gitignore",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1747372754,
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "git-hooks.nix",
|
||||||
|
"rev": "80479b6ec16fefd9c1db3ea13aeb038c60530f46",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "git-hooks.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitignore": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"git-hooks",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1709087332,
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "gitignore.nix",
|
||||||
|
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "gitignore.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1746807397,
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv-nixpkgs",
|
||||||
|
"rev": "c5208b594838ea8e6cca5997fbf784b7cca1ca90",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"ref": "rolling",
|
||||||
|
"repo": "devenv-nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"devenv": "devenv",
|
||||||
|
"git-hooks": "git-hooks",
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"pre-commit-hooks": [
|
||||||
|
"git-hooks"
|
||||||
|
],
|
||||||
|
"rust-overlay": "rust-overlay"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1749091064,
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "12419593ce78f2e8e1e89a373c6515885e218acb",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
36
devenv.nix
Normal file
36
devenv.nix
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{ pkgs, nixpkgs, rust-overlay, ... }:
|
||||||
|
let
|
||||||
|
overlays = [ (import rust-overlay) ];
|
||||||
|
system = pkgs.stdenv.system;
|
||||||
|
rustPkgs = import nixpkgs { inherit system overlays; };
|
||||||
|
rustVersion = (rustPkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml);
|
||||||
|
in {
|
||||||
|
dotenv.enable = true;
|
||||||
|
|
||||||
|
packages = with rustPkgs; [
|
||||||
|
bacon
|
||||||
|
cargo-deny
|
||||||
|
just
|
||||||
|
postgresql
|
||||||
|
sqls
|
||||||
|
sqlx-cli
|
||||||
|
(rustVersion.override {
|
||||||
|
extensions = [
|
||||||
|
"rust-src"
|
||||||
|
"rustfmt"
|
||||||
|
"clippy"
|
||||||
|
"rust-analyzer"
|
||||||
|
];
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
services.postgres = {
|
||||||
|
enable = true;
|
||||||
|
listen_addresses = "localhost";
|
||||||
|
initialScript = ''
|
||||||
|
CREATE USER georm WITH PASSWORD 'georm' SUPERUSER;
|
||||||
|
CREATE DATABASE georm OWNER georm;
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE georm TO georm;
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
8
devenv.yaml
Normal file
8
devenv.yaml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
inputs:
|
||||||
|
rust-overlay:
|
||||||
|
url: github:oxalica/rust-overlay
|
||||||
|
inputs:
|
||||||
|
nixpkgs:
|
||||||
|
follows: nixpkgs
|
||||||
|
nixpkgs:
|
||||||
|
url: github:cachix/devenv-nixpkgs/rolling
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
services:
|
|
||||||
db:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
container_name: georm-backend-db
|
|
||||||
environment:
|
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
|
||||||
POSTGRES_USER: ${DB_USER}
|
|
||||||
POSTGRES_DB: ${DB_NAME}
|
|
||||||
ports:
|
|
||||||
- 127.0.0.1:5432:5432
|
|
||||||
volumes:
|
|
||||||
- georm_backend_db_data:/var/lib/postgresql/data
|
|
||||||
|
|
||||||
pgadmin:
|
|
||||||
image: dpage/pgadmin4:8
|
|
||||||
restart: unless-stopped
|
|
||||||
container_name: georm-backend-pgadmin
|
|
||||||
environment:
|
|
||||||
PGADMIN_DEFAULT_EMAIL: admin@example.com
|
|
||||||
PGADMIN_DEFAULT_PASSWORD: password
|
|
||||||
PGADMIN_DISABLE_POSTFIX: true
|
|
||||||
PGADMIN_CONFIG_SERVER_MODE: 'False'
|
|
||||||
ports:
|
|
||||||
- 127.0.0.1:8080:80
|
|
||||||
volumes:
|
|
||||||
- georm_backend_pgadmin_data:/var/lib/pgadmin
|
|
||||||
depends_on:
|
|
||||||
- db
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
georm_backend_db_data:
|
|
||||||
georm_backend_pgadmin_data:
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
default: start
|
|
||||||
|
|
||||||
start:
|
|
||||||
docker compose -f compose.dev.yml up -d
|
|
||||||
|
|
||||||
stop:
|
|
||||||
docker compose -f compose.dev.yml down
|
|
||||||
|
|
||||||
logs:
|
|
||||||
docker compose -f compose.dev.yml logs -f
|
|
||||||
|
|
||||||
## Local Variables:
|
|
||||||
## mode: makefile
|
|
||||||
## End:
|
|
||||||
14
examples/postgres/users-comments-and-followers/Cargo.toml
Normal file
14
examples/postgres/users-comments-and-followers/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "georm-users-comments-and-followers"
|
||||||
|
workspace = "../../../"
|
||||||
|
publish = false
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
georm = { path = "../../.." }
|
||||||
|
sqlx = { workspace = true }
|
||||||
|
clap = { version = "4.4", features = ["derive"] }
|
||||||
|
inquire = "0.7.5"
|
||||||
|
thiserror = "2.0.11"
|
||||||
|
tokio = { version = "1.43.0", features = ["full"] }
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
use super::{Executable, Result};
|
||||||
|
use crate::{
|
||||||
|
errors::UserInputError,
|
||||||
|
models::{Comment, CommentDefault, User},
|
||||||
|
};
|
||||||
|
use clap::{Args, Subcommand};
|
||||||
|
use georm::{Defaultable, Georm};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Args, Clone)]
|
||||||
|
pub struct CommentArgs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: CommentCommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Executable for CommentArgs {
|
||||||
|
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
|
||||||
|
self.command.execute(pool).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Subcommand)]
|
||||||
|
pub enum CommentCommand {
|
||||||
|
Create {
|
||||||
|
text: Option<String>,
|
||||||
|
username: Option<String>,
|
||||||
|
},
|
||||||
|
Remove {
|
||||||
|
id: Option<i32>,
|
||||||
|
},
|
||||||
|
RemoveFromUser {
|
||||||
|
username: Option<String>,
|
||||||
|
},
|
||||||
|
ListFromUser {
|
||||||
|
username: Option<String>,
|
||||||
|
},
|
||||||
|
List,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Executable for CommentCommand {
|
||||||
|
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
|
||||||
|
match self {
|
||||||
|
CommentCommand::Create { text, username } => {
|
||||||
|
create_comment(username.clone(), text.clone(), pool).await
|
||||||
|
}
|
||||||
|
CommentCommand::Remove { id } => remove_comment(*id, pool).await,
|
||||||
|
CommentCommand::RemoveFromUser { username } => {
|
||||||
|
remove_user_comment(username.clone(), pool).await
|
||||||
|
}
|
||||||
|
CommentCommand::ListFromUser { username } => {
|
||||||
|
list_user_comments(username.clone(), pool).await
|
||||||
|
}
|
||||||
|
CommentCommand::List => list_comments(pool).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_comment(
|
||||||
|
username: Option<String>,
|
||||||
|
text: Option<String>,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> Result {
|
||||||
|
let prompt = "Who is creating the comment?";
|
||||||
|
let user = User::get_user_by_username_or_select(username.as_deref(), prompt, pool).await?;
|
||||||
|
let content = match text {
|
||||||
|
Some(text) => text,
|
||||||
|
None => inquire::Text::new("Content of the comment:")
|
||||||
|
.prompt()
|
||||||
|
.map_err(UserInputError::InquireError)?,
|
||||||
|
};
|
||||||
|
let comment = CommentDefault {
|
||||||
|
author_id: user.id,
|
||||||
|
content,
|
||||||
|
id: None,
|
||||||
|
};
|
||||||
|
let comment = comment.create(pool).await?;
|
||||||
|
println!("Successfuly created comment:\n{comment}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_comment(id: Option<i32>, pool: &sqlx::PgPool) -> Result {
|
||||||
|
let prompt = "Select the comment to remove:";
|
||||||
|
let comment = match id {
|
||||||
|
Some(id) => Comment::find(pool, &id)
|
||||||
|
.await
|
||||||
|
.map_err(UserInputError::DatabaseError)?
|
||||||
|
.ok_or(UserInputError::CommentDoesNotExist)?,
|
||||||
|
None => Comment::select_comment(prompt, pool).await?,
|
||||||
|
};
|
||||||
|
comment.delete(pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_user_comment(username: Option<String>, pool: &sqlx::PgPool) -> Result {
|
||||||
|
let prompt = "Select user whose comment you want to delete:";
|
||||||
|
let user = User::get_user_by_username_or_select(username.as_deref(), prompt, pool).await?;
|
||||||
|
let comments: HashMap<String, Comment> = user
|
||||||
|
.get_comments(pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|comment| (comment.content.clone(), comment))
|
||||||
|
.collect();
|
||||||
|
let selected_comment_content =
|
||||||
|
inquire::Select::new(prompt, comments.clone().into_keys().collect())
|
||||||
|
.prompt()
|
||||||
|
.map_err(UserInputError::InquireError)?;
|
||||||
|
let comment: &Comment = comments.get(&selected_comment_content).unwrap();
|
||||||
|
comment.delete(pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_user_comments(username: Option<String>, pool: &sqlx::PgPool) -> Result {
|
||||||
|
let prompt = "User whose comment you want to list:";
|
||||||
|
let user = User::get_user_by_username_or_select(username.as_deref(), prompt, pool).await?;
|
||||||
|
println!("List of comments from user:\n");
|
||||||
|
for comment in user.get_comments(pool).await? {
|
||||||
|
println!("{comment}\n");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_comments(pool: &sqlx::PgPool) -> Result {
|
||||||
|
let comments = Comment::find_all(pool).await?;
|
||||||
|
println!("List of all comments:\n");
|
||||||
|
for comment in comments {
|
||||||
|
println!("{comment}\n")
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
use super::{Executable, Result};
|
||||||
|
use crate::models::{FollowerDefault, User};
|
||||||
|
use clap::{Args, Subcommand};
|
||||||
|
use georm::Defaultable;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Args, Clone)]
|
||||||
|
pub struct FollowersArgs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: FollowersCommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Executable for FollowersArgs {
|
||||||
|
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
|
||||||
|
self.command.execute(pool).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Subcommand)]
|
||||||
|
pub enum FollowersCommand {
|
||||||
|
Follow {
|
||||||
|
follower: Option<String>,
|
||||||
|
followed: Option<String>,
|
||||||
|
},
|
||||||
|
Unfollow {
|
||||||
|
follower: Option<String>,
|
||||||
|
},
|
||||||
|
ListFollowers {
|
||||||
|
user: Option<String>,
|
||||||
|
},
|
||||||
|
ListFollowed {
|
||||||
|
user: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Executable for FollowersCommand {
|
||||||
|
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
|
||||||
|
match self {
|
||||||
|
FollowersCommand::Follow { follower, followed } => {
|
||||||
|
follow_user(follower.clone(), followed.clone(), pool).await
|
||||||
|
}
|
||||||
|
FollowersCommand::Unfollow { follower } => unfollow_user(follower.clone(), pool).await,
|
||||||
|
FollowersCommand::ListFollowers { user } => {
|
||||||
|
list_user_followers(user.clone(), pool).await
|
||||||
|
}
|
||||||
|
FollowersCommand::ListFollowed { user } => list_user_followed(user.clone(), pool).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn follow_user(
|
||||||
|
follower: Option<String>,
|
||||||
|
followed: Option<String>,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> Result {
|
||||||
|
let follower = User::get_user_by_username_or_select(
|
||||||
|
follower.as_deref(),
|
||||||
|
"Select who will be following someone:",
|
||||||
|
pool,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let followed = User::get_user_by_username_or_select(
|
||||||
|
followed.as_deref(),
|
||||||
|
"Select who will be followed:",
|
||||||
|
pool,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let follow = FollowerDefault {
|
||||||
|
id: None,
|
||||||
|
follower: follower.id,
|
||||||
|
followed: followed.id,
|
||||||
|
};
|
||||||
|
follow.create(pool).await?;
|
||||||
|
println!("User {follower} now follows {followed}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unfollow_user(follower: Option<String>, pool: &sqlx::PgPool) -> Result {
|
||||||
|
let follower =
|
||||||
|
User::get_user_by_username_or_select(follower.as_deref(), "Select who is following", pool)
|
||||||
|
.await?;
|
||||||
|
let followed_list: HashMap<String, User> = follower
|
||||||
|
.get_followed(pool)
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.map(|person| (person.username.clone(), person.clone()))
|
||||||
|
.collect();
|
||||||
|
let followed = inquire::Select::new(
|
||||||
|
"Who to unfollow?",
|
||||||
|
followed_list.clone().into_keys().collect(),
|
||||||
|
)
|
||||||
|
.prompt()
|
||||||
|
.unwrap();
|
||||||
|
let followed = followed_list.get(&followed).unwrap();
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM Followers WHERE follower = $1 AND followed = $2",
|
||||||
|
follower.id,
|
||||||
|
followed.id
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
println!("User {follower} unfollowed {followed}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_user_followers(user: Option<String>, pool: &sqlx::PgPool) -> Result {
|
||||||
|
let user = User::get_user_by_username_or_select(
|
||||||
|
user.as_deref(),
|
||||||
|
"Whose followers do you want to display?",
|
||||||
|
pool,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
println!("List of followers of {user}:\n");
|
||||||
|
user.get_followers(pool)
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.for_each(|person| println!("{person}"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_user_followed(user: Option<String>, pool: &sqlx::PgPool) -> Result {
|
||||||
|
let user = User::get_user_by_username_or_select(
|
||||||
|
user.as_deref(),
|
||||||
|
"Whose follows do you want to display?",
|
||||||
|
pool,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
println!("List of people followed by {user}:\n");
|
||||||
|
user.get_followed(pool)
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.for_each(|person| println!("{person}"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
mod comments;
|
||||||
|
mod followers;
|
||||||
|
mod users;
|
||||||
|
|
||||||
|
type Result = crate::Result<()>;
|
||||||
|
|
||||||
|
pub trait Executable {
|
||||||
|
async fn execute(&self, pool: &sqlx::PgPool) -> Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Parser)]
|
||||||
|
pub struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Executable for Cli {
|
||||||
|
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
|
||||||
|
self.command.execute(pool).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Subcommand)]
|
||||||
|
pub enum Commands {
|
||||||
|
Users(users::UserArgs),
|
||||||
|
Followers(followers::FollowersArgs),
|
||||||
|
Comments(comments::CommentArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Executable for Commands {
|
||||||
|
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
|
||||||
|
match self {
|
||||||
|
Commands::Users(user_args) => user_args.execute(pool).await,
|
||||||
|
Commands::Followers(followers_args) => followers_args.execute(pool).await,
|
||||||
|
Commands::Comments(comment_args) => comment_args.execute(pool).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
113
examples/postgres/users-comments-and-followers/src/cli/users.rs
Normal file
113
examples/postgres/users-comments-and-followers/src/cli/users.rs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
use super::{Executable, Result};
|
||||||
|
use crate::{errors::UserInputError, models::User};
|
||||||
|
use clap::{Args, Subcommand};
|
||||||
|
use georm::Georm;
|
||||||
|
use inquire::{max_length, min_length, required};
|
||||||
|
|
||||||
|
#[derive(Debug, Args, Clone)]
|
||||||
|
pub struct UserArgs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: UserCommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Executable for UserArgs {
|
||||||
|
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
|
||||||
|
self.command.execute(pool).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Subcommand)]
|
||||||
|
pub enum UserCommand {
|
||||||
|
Add { username: Option<String> },
|
||||||
|
Remove { id: Option<i32> },
|
||||||
|
UpdateProfile { id: Option<i32> },
|
||||||
|
List,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Executable for UserCommand {
|
||||||
|
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
|
||||||
|
match self {
|
||||||
|
UserCommand::Add { username } => add_user(username.clone(), pool).await,
|
||||||
|
UserCommand::Remove { id } => remove_user(*id, pool).await,
|
||||||
|
UserCommand::UpdateProfile { id } => update_profile(*id, pool).await,
|
||||||
|
UserCommand::List => list_all(pool).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_user(username: Option<String>, pool: &sqlx::PgPool) -> Result {
|
||||||
|
let username = match username {
|
||||||
|
Some(username) => username,
|
||||||
|
None => inquire::Text::new("Enter a username:")
|
||||||
|
.prompt()
|
||||||
|
.map_err(|_| UserInputError::InputRequired)?,
|
||||||
|
};
|
||||||
|
let user = User::try_new(&username, pool).await?;
|
||||||
|
println!("The user {user} has been created!");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_user(id: Option<i32>, pool: &sqlx::PgPool) -> Result {
|
||||||
|
let user = User::remove_interactive(id, pool).await?;
|
||||||
|
println!("Removed user {user} from database");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_profile(id: Option<i32>, pool: &sqlx::PgPool) -> Result {
|
||||||
|
let (user, mut profile) = User::update_profile(id, pool).await?;
|
||||||
|
let update_display_name = inquire::Confirm::new(
|
||||||
|
format!(
|
||||||
|
"Your current display name is \"{}\", do you want to update it?",
|
||||||
|
profile.get_display_name()
|
||||||
|
)
|
||||||
|
.as_str(),
|
||||||
|
)
|
||||||
|
.with_default(false)
|
||||||
|
.prompt()
|
||||||
|
.map_err(UserInputError::InquireError)?;
|
||||||
|
let display_name = if update_display_name {
|
||||||
|
Some(
|
||||||
|
inquire::Text::new("New display name:")
|
||||||
|
.with_help_message("Your display name should not exceed 100 characters")
|
||||||
|
.with_validator(min_length!(3))
|
||||||
|
.with_validator(max_length!(100))
|
||||||
|
.with_validator(required!())
|
||||||
|
.prompt()
|
||||||
|
.map_err(UserInputError::InquireError)?,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Some(profile.get_display_name())
|
||||||
|
};
|
||||||
|
let update_bio = inquire::Confirm::new(
|
||||||
|
format!(
|
||||||
|
"Your current bio is:\n===\n{}\n===\nDo you want to update it?",
|
||||||
|
profile.get_bio()
|
||||||
|
)
|
||||||
|
.as_str(),
|
||||||
|
)
|
||||||
|
.with_default(false)
|
||||||
|
.prompt()
|
||||||
|
.map_err(UserInputError::InquireError)?;
|
||||||
|
let bio = if update_bio {
|
||||||
|
Some(
|
||||||
|
inquire::Text::new("New bio:")
|
||||||
|
.with_validator(min_length!(0))
|
||||||
|
.prompt()
|
||||||
|
.map_err(UserInputError::InquireError)?,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Some(profile.get_bio())
|
||||||
|
};
|
||||||
|
let profile = profile.update_interactive(display_name, bio, pool).await?;
|
||||||
|
println!("Profile of {user} updated:\n{profile}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_all(pool: &sqlx::PgPool) -> Result {
|
||||||
|
let users = User::find_all(pool).await?;
|
||||||
|
println!("List of users:\n");
|
||||||
|
for user in users {
|
||||||
|
println!("{user}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
15
examples/postgres/users-comments-and-followers/src/errors.rs
Normal file
15
examples/postgres/users-comments-and-followers/src/errors.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum UserInputError {
|
||||||
|
#[error("Input required")]
|
||||||
|
InputRequired,
|
||||||
|
#[error("User ID does not exist")]
|
||||||
|
UserDoesNotExist,
|
||||||
|
#[error("Comment does not exist")]
|
||||||
|
CommentDoesNotExist,
|
||||||
|
#[error("Unexpected error, please try again")]
|
||||||
|
InquireError(#[from] inquire::error::InquireError),
|
||||||
|
#[error("Error from database: {0}")]
|
||||||
|
DatabaseError(#[from] sqlx::Error),
|
||||||
|
}
|
||||||
20
examples/postgres/users-comments-and-followers/src/main.rs
Normal file
20
examples/postgres/users-comments-and-followers/src/main.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
mod cli;
|
||||||
|
mod errors;
|
||||||
|
mod models;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
use cli::{Cli, Executable};
|
||||||
|
|
||||||
|
type Result<T> = std::result::Result<T, errors::UserInputError>;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let args = Cli::parse();
|
||||||
|
let url = std::env::var("DATABASE_URL").expect("Environment variable DATABASE_URL must be set");
|
||||||
|
let pool =
|
||||||
|
sqlx::PgPool::connect_lazy(url.as_str()).expect("Failed to create database connection");
|
||||||
|
match args.command.execute(&pool).await {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => eprintln!("Error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
use super::User;
|
||||||
|
use crate::{Result, errors::UserInputError};
|
||||||
|
use georm::Georm;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Georm, Clone)]
|
||||||
|
#[georm(table = "Comments")]
|
||||||
|
pub struct Comment {
|
||||||
|
#[georm(id, defaultable)]
|
||||||
|
pub id: i32,
|
||||||
|
#[georm(relation = {
|
||||||
|
entity = User,
|
||||||
|
table = "Users",
|
||||||
|
name = "author"
|
||||||
|
})]
|
||||||
|
pub author_id: i32,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Comment {
|
||||||
|
pub async fn select_comment(prompt: &str, pool: &sqlx::PgPool) -> Result<Self> {
|
||||||
|
let comments: HashMap<String, Self> = Self::find_all(pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|comment| (comment.content.clone(), comment))
|
||||||
|
.collect();
|
||||||
|
let comment_content = inquire::Select::new(prompt, comments.clone().into_keys().collect())
|
||||||
|
.prompt()
|
||||||
|
.map_err(UserInputError::InquireError)?;
|
||||||
|
let comment: &Self = comments.get(&comment_content).unwrap();
|
||||||
|
Ok(comment.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Comment {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"Comment:\nID:\t{}\nAuthor:\t{}\nContent:\t{}",
|
||||||
|
self.id, self.author_id, self.content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
use super::User;
|
||||||
|
use georm::Georm;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Georm)]
|
||||||
|
#[georm(table = "Followers")]
|
||||||
|
pub struct Follower {
|
||||||
|
#[georm(id, defaultable)]
|
||||||
|
pub id: i32,
|
||||||
|
#[georm(relation = {
|
||||||
|
entity = User,
|
||||||
|
table = "Users",
|
||||||
|
name = "followed"
|
||||||
|
})]
|
||||||
|
pub followed: i32,
|
||||||
|
#[georm(relation = {
|
||||||
|
entity = User,
|
||||||
|
table = "Users",
|
||||||
|
name = "follower"
|
||||||
|
})]
|
||||||
|
pub follower: i32,
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
mod users;
|
||||||
|
pub use users::*;
|
||||||
|
mod profiles;
|
||||||
|
pub use profiles::*;
|
||||||
|
mod comments;
|
||||||
|
pub use comments::*;
|
||||||
|
mod followers;
|
||||||
|
pub use followers::*;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
use super::User;
|
||||||
|
use crate::{Result, errors::UserInputError};
|
||||||
|
use georm::{Defaultable, Georm};
|
||||||
|
|
||||||
|
#[derive(Debug, Georm, Default)]
|
||||||
|
#[georm(table = "Profiles")]
|
||||||
|
pub struct Profile {
|
||||||
|
#[georm(id, defaultable)]
|
||||||
|
pub id: i32,
|
||||||
|
#[georm(relation = {
|
||||||
|
entity = User,
|
||||||
|
table = "Users",
|
||||||
|
name = "user",
|
||||||
|
nullable = false
|
||||||
|
})]
|
||||||
|
pub user_id: i32,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Profile {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"Display Name:\t{}\nBiography:\n{}\n",
|
||||||
|
self.get_display_name(),
|
||||||
|
self.get_bio()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Profile {
|
||||||
|
pub fn get_display_name(&self) -> String {
|
||||||
|
self.display_name.clone().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_bio(&self) -> String {
|
||||||
|
self.bio.clone().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn try_new(user_id: i32, pool: &sqlx::PgPool) -> Result<Self> {
|
||||||
|
let profile = ProfileDefault {
|
||||||
|
user_id,
|
||||||
|
id: None,
|
||||||
|
bio: None,
|
||||||
|
display_name: None,
|
||||||
|
};
|
||||||
|
profile
|
||||||
|
.create(pool)
|
||||||
|
.await
|
||||||
|
.map_err(UserInputError::DatabaseError)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_interactive(
|
||||||
|
&mut self,
|
||||||
|
display_name: Option<String>,
|
||||||
|
bio: Option<String>,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> Result<Self> {
|
||||||
|
self.display_name = display_name;
|
||||||
|
self.bio = bio;
|
||||||
|
self.update(pool)
|
||||||
|
.await
|
||||||
|
.map_err(UserInputError::DatabaseError)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::{Result, errors::UserInputError};
|
||||||
|
use georm::{Defaultable, Georm};
|
||||||
|
|
||||||
|
use super::{Comment, Profile};
|
||||||
|
|
||||||
|
#[derive(Debug, Georm, Clone)]
|
||||||
|
#[georm(
|
||||||
|
table = "Users",
|
||||||
|
one_to_one = [{
|
||||||
|
name = "profile", remote_id = "user_id", table = "Profiles", entity = Profile
|
||||||
|
}],
|
||||||
|
one_to_many = [{
|
||||||
|
name = "comments", remote_id = "author_id", table = "Comments", entity = Comment
|
||||||
|
}],
|
||||||
|
many_to_many = [{
|
||||||
|
name = "followers",
|
||||||
|
table = "Users",
|
||||||
|
entity = User,
|
||||||
|
link = { table = "Followers", from = "followed", to = "follower" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "followed",
|
||||||
|
table = "Users",
|
||||||
|
entity = User,
|
||||||
|
link = { table = "Followers", from = "follower", to = "followed" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
pub struct User {
|
||||||
|
#[georm(id, defaultable)]
|
||||||
|
pub id: i32,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for User {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{} (ID: {})", self.username, self.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for UserDefault {
|
||||||
|
fn from(value: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
id: None,
|
||||||
|
username: value.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl User {
|
||||||
|
async fn select_user(prompt: &str, pool: &sqlx::PgPool) -> Result<Self> {
|
||||||
|
let users: HashMap<String, Self> = Self::find_all(pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|user| (user.username.clone(), user))
|
||||||
|
.collect();
|
||||||
|
let username = inquire::Select::new(prompt, users.clone().into_keys().collect())
|
||||||
|
.prompt()
|
||||||
|
.map_err(UserInputError::InquireError)?;
|
||||||
|
let user: &Self = users.get(&username).unwrap();
|
||||||
|
Ok(user.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_by_id_or_select(
|
||||||
|
id: Option<i32>,
|
||||||
|
prompt: &str,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let user = match id {
|
||||||
|
Some(id) => Self::find(pool, &id)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserInputError::UserDoesNotExist)?,
|
||||||
|
None => Self::select_user(prompt, pool).await?,
|
||||||
|
};
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_by_username_or_select(
|
||||||
|
username: Option<&str>,
|
||||||
|
prompt: &str,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let user = match username {
|
||||||
|
Some(username) => Self::find_by_username(username, pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserInputError::UserDoesNotExist)?,
|
||||||
|
None => Self::select_user(prompt, pool).await?,
|
||||||
|
};
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_username(username: &str, pool: &sqlx::PgPool) -> Result<Option<Self>> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
Self,
|
||||||
|
"SELECT * FROM Users u WHERE u.username = $1",
|
||||||
|
username
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(UserInputError::DatabaseError)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn try_new(username: &str, pool: &sqlx::PgPool) -> Result<Self> {
|
||||||
|
let user = UserDefault::from(username);
|
||||||
|
user.create(pool)
|
||||||
|
.await
|
||||||
|
.map_err(UserInputError::DatabaseError)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_interactive(id: Option<i32>, pool: &sqlx::PgPool) -> Result<Self> {
|
||||||
|
let prompt = "Select a user to delete:";
|
||||||
|
let user = Self::get_user_by_id_or_select(id, prompt, pool).await?;
|
||||||
|
let _ = user.clone().delete(pool).await?;
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_profile(id: Option<i32>, pool: &sqlx::PgPool) -> Result<(User, Profile)> {
|
||||||
|
let prompt = "Select the user whose profile you want to update";
|
||||||
|
let user = Self::get_user_by_id_or_select(id, prompt, pool).await?;
|
||||||
|
let profile = match user.get_profile(pool).await? {
|
||||||
|
Some(profile) => profile,
|
||||||
|
None => Profile::try_new(user.id, pool).await?,
|
||||||
|
};
|
||||||
|
Ok((user, profile))
|
||||||
|
}
|
||||||
|
}
|
||||||
96
flake.lock
generated
96
flake.lock
generated
@@ -1,96 +0,0 @@
|
|||||||
{
|
|
||||||
"nodes": {
|
|
||||||
"flake-utils": {
|
|
||||||
"inputs": {
|
|
||||||
"systems": "systems"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1731533236,
|
|
||||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1738142207,
|
|
||||||
"narHash": "sha256-NGqpVVxNAHwIicXpgaVqJEJWeyqzoQJ9oc8lnK9+WC4=",
|
|
||||||
"owner": "nixos",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "9d3ae807ebd2981d593cddd0080856873139aa40",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nixos",
|
|
||||||
"ref": "nixos-unstable",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs_2": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1736320768,
|
|
||||||
"narHash": "sha256-nIYdTAiKIGnFNugbomgBJR+Xv5F1ZQU+HfaBqJKroC0=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "4bc9c909d9ac828a039f288cf872d16d38185db8",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixpkgs-unstable",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"inputs": {
|
|
||||||
"flake-utils": "flake-utils",
|
|
||||||
"nixpkgs": "nixpkgs",
|
|
||||||
"rust-overlay": "rust-overlay"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"rust-overlay": {
|
|
||||||
"inputs": {
|
|
||||||
"nixpkgs": "nixpkgs_2"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1738290352,
|
|
||||||
"narHash": "sha256-YKOHUmc0Clm4tMV8grnxYL4IIwtjTayoq/3nqk0QM7k=",
|
|
||||||
"owner": "oxalica",
|
|
||||||
"repo": "rust-overlay",
|
|
||||||
"rev": "b031b584125d33d23a0182f91ddbaf3ab4880236",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "oxalica",
|
|
||||||
"repo": "rust-overlay",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"systems": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "root",
|
|
||||||
"version": 7
|
|
||||||
}
|
|
||||||
37
flake.nix
37
flake.nix
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
description = "Georm, a simple, opiniated SQLx ORM for PostgreSQL";
|
|
||||||
|
|
||||||
inputs = {
|
|
||||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
|
||||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
|
||||||
};
|
|
||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils, rust-overlay }:
|
|
||||||
flake-utils.lib.eachSystem ["x86_64-linux"] (system:
|
|
||||||
let
|
|
||||||
overlays = [ (import rust-overlay) ];
|
|
||||||
pkgs = import nixpkgs { inherit system overlays; };
|
|
||||||
rustVersion = (pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml);
|
|
||||||
in {
|
|
||||||
devShell = with pkgs; mkShell {
|
|
||||||
buildInputs = [
|
|
||||||
bacon
|
|
||||||
cargo
|
|
||||||
cargo-deny
|
|
||||||
just
|
|
||||||
rust-analyzer
|
|
||||||
(rustVersion.override {
|
|
||||||
extensions = [
|
|
||||||
"rust-src"
|
|
||||||
"rustfmt"
|
|
||||||
"clippy"
|
|
||||||
"rust-analyzer"
|
|
||||||
];
|
|
||||||
})
|
|
||||||
sqls
|
|
||||||
sqlx-cli
|
|
||||||
];
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
87
georm-macros/src/georm/composite_keys.rs
Normal file
87
georm-macros/src/georm/composite_keys.rs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
use super::ir::GeormField;
|
||||||
|
use quote::quote;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum IdType {
|
||||||
|
Simple {
|
||||||
|
field_name: syn::Ident,
|
||||||
|
field_type: syn::Type,
|
||||||
|
},
|
||||||
|
Composite {
|
||||||
|
fields: Vec<IdField>,
|
||||||
|
field_type: syn::Ident,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct IdField {
|
||||||
|
pub name: syn::Ident,
|
||||||
|
pub ty: syn::Type,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_to_code(field: &GeormField) -> proc_macro2::TokenStream {
|
||||||
|
let ident = field.ident.clone();
|
||||||
|
let ty = field.ty.clone();
|
||||||
|
quote! {
|
||||||
|
pub #ident: #ty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_struct(
|
||||||
|
ast: &syn::DeriveInput,
|
||||||
|
fields: &[GeormField],
|
||||||
|
) -> (syn::Ident, proc_macro2::TokenStream) {
|
||||||
|
let struct_name = &ast.ident;
|
||||||
|
let id_struct_name = quote::format_ident!("{struct_name}Id");
|
||||||
|
let vis = &ast.vis;
|
||||||
|
let fields: Vec<proc_macro2::TokenStream> = fields
|
||||||
|
.iter()
|
||||||
|
.filter_map(|field| {
|
||||||
|
if field.id {
|
||||||
|
Some(field_to_code(field))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let code = quote! {
|
||||||
|
#vis struct #id_struct_name {
|
||||||
|
#(#fields),*
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(id_struct_name, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_primary_key(
|
||||||
|
ast: &syn::DeriveInput,
|
||||||
|
fields: &[GeormField],
|
||||||
|
) -> (IdType, proc_macro2::TokenStream) {
|
||||||
|
let georm_id_fields: Vec<&GeormField> = fields.iter().filter(|field| field.id).collect();
|
||||||
|
let id_fields: Vec<IdField> = georm_id_fields
|
||||||
|
.iter()
|
||||||
|
.map(|field| IdField {
|
||||||
|
name: field.ident.clone(),
|
||||||
|
ty: field.ty.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
match id_fields.len() {
|
||||||
|
0 => panic!("No ID field found"),
|
||||||
|
1 => (
|
||||||
|
IdType::Simple {
|
||||||
|
field_name: id_fields[0].name.clone(),
|
||||||
|
field_type: id_fields[0].ty.clone(),
|
||||||
|
},
|
||||||
|
quote! {},
|
||||||
|
),
|
||||||
|
_ => {
|
||||||
|
let (struct_name, struct_code) = generate_struct(ast, fields);
|
||||||
|
(
|
||||||
|
IdType::Composite {
|
||||||
|
fields: id_fields.clone(),
|
||||||
|
field_type: struct_name,
|
||||||
|
},
|
||||||
|
struct_code,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
147
georm-macros/src/georm/defaultable_struct.rs
Normal file
147
georm-macros/src/georm/defaultable_struct.rs
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
//! This module creates the defaultable version of a structured derived with
|
||||||
|
//! Georm. It creates a new struct named `<StructName>Default` where the fields
|
||||||
|
//! marked as defaultable become an `Option<T>`, where `T` is the initial type
|
||||||
|
//! of the field.
|
||||||
|
//!
|
||||||
|
//! The user does not have to mark a field defaultable if the field already has
|
||||||
|
//! a type `Option<T>`. It is intended only for fields marked as `NOT NULL` in
|
||||||
|
//! the database, but not required when creating the entity due to a `DEFAULT`
|
||||||
|
//! or something similar. The type `<StructName>Default` implements the
|
||||||
|
//! `Defaultable` trait.
|
||||||
|
|
||||||
|
use super::ir::{GeormField, GeormStructAttributes};
|
||||||
|
use quote::quote;
|
||||||
|
|
||||||
|
fn create_defaultable_field(field: &GeormField) -> proc_macro2::TokenStream {
|
||||||
|
let ident = &field.ident;
|
||||||
|
let ty = &field.ty;
|
||||||
|
let vis = &field.field.vis;
|
||||||
|
|
||||||
|
// If the field is marked as defaultable, wrap it in Option<T>
|
||||||
|
// Otherwise, keep the original type
|
||||||
|
let field_type = if field.defaultable {
|
||||||
|
quote! { Option<#ty> }
|
||||||
|
} else {
|
||||||
|
quote! { #ty }
|
||||||
|
};
|
||||||
|
|
||||||
|
quote! {
|
||||||
|
#vis #ident: #field_type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_defaultable_trait_impl(
|
||||||
|
struct_name: &syn::Ident,
|
||||||
|
defaultable_struct_name: &syn::Ident,
|
||||||
|
struct_attrs: &GeormStructAttributes,
|
||||||
|
fields: &[GeormField],
|
||||||
|
) -> proc_macro2::TokenStream {
|
||||||
|
let table = &struct_attrs.table;
|
||||||
|
|
||||||
|
// Find the ID field
|
||||||
|
let id_field = fields
|
||||||
|
.iter()
|
||||||
|
.find(|field| field.id)
|
||||||
|
.expect("Must have an ID field");
|
||||||
|
let id_type = &id_field.ty;
|
||||||
|
|
||||||
|
// Separate defaultable and non-defaultable fields
|
||||||
|
let non_defaultable_fields: Vec<_> = fields.iter().filter(|f| !f.defaultable).collect();
|
||||||
|
let defaultable_fields: Vec<_> = fields.iter().filter(|f| f.defaultable).collect();
|
||||||
|
|
||||||
|
// Build static parts for non-defaultable fields
|
||||||
|
let static_field_names: Vec<String> = non_defaultable_fields
|
||||||
|
.iter()
|
||||||
|
.map(|f| f.ident.to_string())
|
||||||
|
.collect();
|
||||||
|
let static_field_idents: Vec<&syn::Ident> =
|
||||||
|
non_defaultable_fields.iter().map(|f| &f.ident).collect();
|
||||||
|
|
||||||
|
// Generate field checks for defaultable fields
|
||||||
|
let mut field_checks = Vec::new();
|
||||||
|
let mut bind_checks = Vec::new();
|
||||||
|
|
||||||
|
for field in &defaultable_fields {
|
||||||
|
let field_name = field.ident.to_string();
|
||||||
|
let field_ident = &field.ident;
|
||||||
|
|
||||||
|
field_checks.push(quote! {
|
||||||
|
if self.#field_ident.is_some() {
|
||||||
|
dynamic_fields.push(#field_name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
bind_checks.push(quote! {
|
||||||
|
if let Some(ref value) = self.#field_ident {
|
||||||
|
query_builder = query_builder.bind(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
quote! {
|
||||||
|
impl ::georm::Defaultable<#id_type, #struct_name> for #defaultable_struct_name {
|
||||||
|
async fn create(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<#struct_name> {
|
||||||
|
let mut dynamic_fields = Vec::new();
|
||||||
|
|
||||||
|
#(#field_checks)*
|
||||||
|
|
||||||
|
let mut all_fields = vec![#(#static_field_names),*];
|
||||||
|
all_fields.extend(dynamic_fields);
|
||||||
|
|
||||||
|
let placeholders: Vec<String> = (1..=all_fields.len())
|
||||||
|
.map(|i| format!("${}", i))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let query = format!(
|
||||||
|
"INSERT INTO {} ({}) VALUES ({}) RETURNING *",
|
||||||
|
#table,
|
||||||
|
all_fields.join(", "),
|
||||||
|
placeholders.join(", ")
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut query_builder = ::sqlx::query_as::<_, #struct_name>(&query);
|
||||||
|
|
||||||
|
// Bind non-defaultable fields first
|
||||||
|
#(query_builder = query_builder.bind(&self.#static_field_idents);)*
|
||||||
|
|
||||||
|
// Then bind defaultable fields that have values
|
||||||
|
#(#bind_checks)*
|
||||||
|
|
||||||
|
query_builder.fetch_one(pool).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn derive_defaultable_struct(
|
||||||
|
ast: &syn::DeriveInput,
|
||||||
|
struct_attrs: &GeormStructAttributes,
|
||||||
|
fields: &[GeormField],
|
||||||
|
) -> proc_macro2::TokenStream {
|
||||||
|
// Only generate if there are defaultable fields
|
||||||
|
if fields.iter().all(|field| !field.defaultable) {
|
||||||
|
return quote! {};
|
||||||
|
}
|
||||||
|
|
||||||
|
let struct_name = &ast.ident;
|
||||||
|
let vis = &ast.vis;
|
||||||
|
let defaultable_struct_name = quote::format_ident!("{}Default", struct_name);
|
||||||
|
|
||||||
|
let defaultable_fields: Vec<proc_macro2::TokenStream> =
|
||||||
|
fields.iter().map(create_defaultable_field).collect();
|
||||||
|
|
||||||
|
let trait_impl = generate_defaultable_trait_impl(
|
||||||
|
struct_name,
|
||||||
|
&defaultable_struct_name,
|
||||||
|
struct_attrs,
|
||||||
|
fields,
|
||||||
|
);
|
||||||
|
|
||||||
|
quote! {
|
||||||
|
#vis struct #defaultable_struct_name {
|
||||||
|
#(#defaultable_fields),*
|
||||||
|
}
|
||||||
|
|
||||||
|
#trait_impl
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
use quote::quote;
|
|
||||||
|
|
||||||
#[derive(deluxe::ExtractAttributes)]
|
|
||||||
#[deluxe(attributes(georm))]
|
|
||||||
pub struct GeormStructAttributes {
|
|
||||||
pub table: String,
|
|
||||||
#[deluxe(default = Vec::new())]
|
|
||||||
pub one_to_many: Vec<O2MRelationship>,
|
|
||||||
#[deluxe(default = Vec::new())]
|
|
||||||
pub many_to_many: Vec<M2MRelationship>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(deluxe::ParseMetaItem)]
|
|
||||||
pub struct O2MRelationship {
|
|
||||||
pub name: String,
|
|
||||||
pub remote_id: String,
|
|
||||||
pub table: String,
|
|
||||||
pub entity: syn::Type,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&O2MRelationship> for proc_macro2::TokenStream {
|
|
||||||
fn from(value: &O2MRelationship) -> Self {
|
|
||||||
let query = format!(
|
|
||||||
"SELECT * FROM {} WHERE {} = $1",
|
|
||||||
value.table, value.remote_id
|
|
||||||
);
|
|
||||||
let entity = &value.entity;
|
|
||||||
let function = syn::Ident::new(
|
|
||||||
&format!("get_{}", value.name),
|
|
||||||
proc_macro2::Span::call_site(),
|
|
||||||
);
|
|
||||||
quote! {
|
|
||||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<#entity>> {
|
|
||||||
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_all(pool).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(deluxe::ParseMetaItem, Clone)]
|
|
||||||
pub struct M2MLink {
|
|
||||||
pub table: String,
|
|
||||||
pub from: String,
|
|
||||||
pub to: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(deluxe::ParseMetaItem)]
|
|
||||||
pub struct M2MRelationship {
|
|
||||||
pub name: String,
|
|
||||||
pub entity: syn::Type,
|
|
||||||
pub table: String,
|
|
||||||
#[deluxe(default = String::from("id"))]
|
|
||||||
pub remote_id: String,
|
|
||||||
pub link: M2MLink,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Identifier {
|
|
||||||
pub table: String,
|
|
||||||
pub id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct M2MRelationshipComplete {
|
|
||||||
pub name: String,
|
|
||||||
pub entity: syn::Type,
|
|
||||||
pub local: Identifier,
|
|
||||||
pub remote: Identifier,
|
|
||||||
pub link: M2MLink,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl M2MRelationshipComplete {
|
|
||||||
pub fn new(other: &M2MRelationship, local_table: &String, local_id: String) -> Self {
|
|
||||||
Self {
|
|
||||||
name: other.name.clone(),
|
|
||||||
entity: other.entity.clone(),
|
|
||||||
link: other.link.clone(),
|
|
||||||
local: Identifier {
|
|
||||||
table: local_table.to_string(),
|
|
||||||
id: local_id,
|
|
||||||
},
|
|
||||||
remote: Identifier {
|
|
||||||
table: other.table.clone(),
|
|
||||||
id: other.remote_id.clone(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&M2MRelationshipComplete> for proc_macro2::TokenStream {
|
|
||||||
fn from(value: &M2MRelationshipComplete) -> Self {
|
|
||||||
let function = syn::Ident::new(
|
|
||||||
&format!("get_{}", value.name),
|
|
||||||
proc_macro2::Span::call_site(),
|
|
||||||
);
|
|
||||||
let entity = &value.entity;
|
|
||||||
let query = format!(
|
|
||||||
"SELECT remote.*
|
|
||||||
FROM {} local
|
|
||||||
JOIN {} link ON link.{} = local.{}
|
|
||||||
JOIN {} remote ON link.{} = remote.{}
|
|
||||||
WHERE local.{} = $1",
|
|
||||||
value.local.table,
|
|
||||||
value.link.table,
|
|
||||||
value.link.from,
|
|
||||||
value.local.id,
|
|
||||||
value.remote.table,
|
|
||||||
value.link.to,
|
|
||||||
value.remote.id,
|
|
||||||
value.local.id
|
|
||||||
);
|
|
||||||
quote! {
|
|
||||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<#entity>> {
|
|
||||||
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_all(pool).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(deluxe::ExtractAttributes, Clone)]
|
|
||||||
#[deluxe(attributes(georm))]
|
|
||||||
struct GeormFieldAttributes {
|
|
||||||
#[deluxe(default = false)]
|
|
||||||
pub id: bool,
|
|
||||||
#[deluxe(default = None)]
|
|
||||||
pub relation: Option<O2ORelationship>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(deluxe::ParseMetaItem, Clone, Debug)]
|
|
||||||
pub struct O2ORelationship {
|
|
||||||
pub entity: syn::Type,
|
|
||||||
pub table: String,
|
|
||||||
#[deluxe(default = String::from("id"))]
|
|
||||||
pub remote_id: String,
|
|
||||||
#[deluxe(default = false)]
|
|
||||||
pub nullable: bool,
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct GeormField {
|
|
||||||
pub ident: syn::Ident,
|
|
||||||
pub field: syn::Field,
|
|
||||||
pub ty: syn::Type,
|
|
||||||
pub id: bool,
|
|
||||||
pub relation: Option<O2ORelationship>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GeormField {
|
|
||||||
pub fn new(field: &mut syn::Field) -> Self {
|
|
||||||
let ident = field.clone().ident.unwrap();
|
|
||||||
let ty = field.clone().ty;
|
|
||||||
let attrs: GeormFieldAttributes =
|
|
||||||
deluxe::extract_attributes(field).expect("Could not extract attributes from field");
|
|
||||||
let GeormFieldAttributes { id, relation } = attrs;
|
|
||||||
Self {
|
|
||||||
ident,
|
|
||||||
field: field.to_owned(),
|
|
||||||
id,
|
|
||||||
ty,
|
|
||||||
relation,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(),
|
|
||||||
);
|
|
||||||
let entity = &relation.entity;
|
|
||||||
let return_type = if relation.nullable {
|
|
||||||
quote! { Option<#entity> }
|
|
||||||
} else {
|
|
||||||
quote! { #entity }
|
|
||||||
};
|
|
||||||
let query = format!(
|
|
||||||
"SELECT * FROM {} WHERE {} = $1",
|
|
||||||
relation.table, relation.remote_id
|
|
||||||
);
|
|
||||||
let local_ident = &value.field.ident;
|
|
||||||
let fetch = if relation.nullable {
|
|
||||||
quote! { fetch_optional }
|
|
||||||
} else {
|
|
||||||
quote! { fetch_one }
|
|
||||||
};
|
|
||||||
quote! {
|
|
||||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<#return_type> {
|
|
||||||
::sqlx::query_as!(#entity, #query, self.#local_ident).#fetch(pool).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
79
georm-macros/src/georm/ir/m2m_relationship.rs
Normal file
79
georm-macros/src/georm/ir/m2m_relationship.rs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
use quote::quote;
|
||||||
|
|
||||||
|
#[derive(deluxe::ParseMetaItem, Clone)]
|
||||||
|
pub struct M2MLink {
|
||||||
|
pub table: String,
|
||||||
|
pub from: String,
|
||||||
|
pub to: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(deluxe::ParseMetaItem)]
|
||||||
|
pub struct M2MRelationship {
|
||||||
|
pub name: String,
|
||||||
|
pub entity: syn::Type,
|
||||||
|
pub table: String,
|
||||||
|
#[deluxe(default = String::from("id"))]
|
||||||
|
pub remote_id: String,
|
||||||
|
pub link: M2MLink,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Identifier {
|
||||||
|
pub table: String,
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct M2MRelationshipComplete {
|
||||||
|
pub name: String,
|
||||||
|
pub entity: syn::Type,
|
||||||
|
pub local: Identifier,
|
||||||
|
pub remote: Identifier,
|
||||||
|
pub link: M2MLink,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl M2MRelationshipComplete {
|
||||||
|
pub fn new(other: &M2MRelationship, local_table: &String, local_id: &String) -> Self {
|
||||||
|
Self {
|
||||||
|
name: other.name.clone(),
|
||||||
|
entity: other.entity.clone(),
|
||||||
|
link: other.link.clone(),
|
||||||
|
local: Identifier {
|
||||||
|
table: local_table.to_string(),
|
||||||
|
id: local_id.to_string(),
|
||||||
|
},
|
||||||
|
remote: Identifier {
|
||||||
|
table: other.table.clone(),
|
||||||
|
id: other.remote_id.clone(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&M2MRelationshipComplete> for proc_macro2::TokenStream {
|
||||||
|
fn from(value: &M2MRelationshipComplete) -> Self {
|
||||||
|
let function = syn::Ident::new(
|
||||||
|
&format!("get_{}", value.name),
|
||||||
|
proc_macro2::Span::call_site(),
|
||||||
|
);
|
||||||
|
let entity = &value.entity;
|
||||||
|
let query = format!(
|
||||||
|
"SELECT remote.*
|
||||||
|
FROM {} local
|
||||||
|
JOIN {} link ON link.{} = local.{}
|
||||||
|
JOIN {} remote ON link.{} = remote.{}
|
||||||
|
WHERE local.{} = $1",
|
||||||
|
value.local.table,
|
||||||
|
value.link.table,
|
||||||
|
value.link.from,
|
||||||
|
value.local.id,
|
||||||
|
value.remote.table,
|
||||||
|
value.link.to,
|
||||||
|
value.remote.id,
|
||||||
|
value.local.id
|
||||||
|
);
|
||||||
|
quote! {
|
||||||
|
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<#entity>> {
|
||||||
|
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_all(pool).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
130
georm-macros/src/georm/ir/mod.rs
Normal file
130
georm-macros/src/georm/ir/mod.rs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
use quote::quote;
|
||||||
|
|
||||||
|
pub mod simple_relationship;
|
||||||
|
use simple_relationship::{OneToMany, OneToOne, SimpleRelationship};
|
||||||
|
|
||||||
|
pub mod m2m_relationship;
|
||||||
|
use m2m_relationship::M2MRelationship;
|
||||||
|
|
||||||
|
#[derive(deluxe::ExtractAttributes)]
|
||||||
|
#[deluxe(attributes(georm))]
|
||||||
|
pub struct GeormStructAttributes {
|
||||||
|
pub table: String,
|
||||||
|
#[deluxe(default = Vec::new())]
|
||||||
|
pub one_to_one: Vec<SimpleRelationship<OneToOne>>,
|
||||||
|
#[deluxe(default = Vec::new())]
|
||||||
|
pub one_to_many: Vec<SimpleRelationship<OneToMany>>,
|
||||||
|
#[deluxe(default = Vec::new())]
|
||||||
|
pub many_to_many: Vec<M2MRelationship>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(deluxe::ExtractAttributes, Clone)]
|
||||||
|
#[deluxe(attributes(georm))]
|
||||||
|
struct GeormFieldAttributes {
|
||||||
|
#[deluxe(default = false)]
|
||||||
|
pub id: bool,
|
||||||
|
#[deluxe(default = None)]
|
||||||
|
pub relation: Option<O2ORelationship>,
|
||||||
|
#[deluxe(default = false)]
|
||||||
|
pub defaultable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(deluxe::ParseMetaItem, Clone, Debug)]
|
||||||
|
pub struct O2ORelationship {
|
||||||
|
pub entity: syn::Type,
|
||||||
|
pub table: String,
|
||||||
|
#[deluxe(default = String::from("id"))]
|
||||||
|
pub remote_id: String,
|
||||||
|
#[deluxe(default = false)]
|
||||||
|
pub nullable: bool,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct GeormField {
|
||||||
|
pub ident: syn::Ident,
|
||||||
|
pub field: syn::Field,
|
||||||
|
pub ty: syn::Type,
|
||||||
|
pub id: bool,
|
||||||
|
pub relation: Option<O2ORelationship>,
|
||||||
|
pub defaultable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GeormField {
|
||||||
|
pub fn new(field: &mut syn::Field) -> Self {
|
||||||
|
let ident = field.clone().ident.unwrap();
|
||||||
|
let ty = field.clone().ty;
|
||||||
|
let attrs: GeormFieldAttributes =
|
||||||
|
deluxe::extract_attributes(field).expect("Could not extract attributes from field");
|
||||||
|
let GeormFieldAttributes {
|
||||||
|
id,
|
||||||
|
relation,
|
||||||
|
defaultable,
|
||||||
|
} = attrs;
|
||||||
|
|
||||||
|
// Validate that defaultable is not used on Option<T> fields
|
||||||
|
if defaultable && Self::is_option_type(&ty) {
|
||||||
|
panic!(
|
||||||
|
"Field '{}' is already an Option<T> and cannot be marked as defaultable. \
|
||||||
|
Remove the #[georm(defaultable)] attribute.",
|
||||||
|
ident
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
ident,
|
||||||
|
field: field.to_owned(),
|
||||||
|
id,
|
||||||
|
ty,
|
||||||
|
relation,
|
||||||
|
defaultable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a type is Option<T>
|
||||||
|
fn is_option_type(ty: &syn::Type) -> bool {
|
||||||
|
match ty {
|
||||||
|
syn::Type::Path(type_path) => {
|
||||||
|
if let Some(segment) = type_path.path.segments.last() {
|
||||||
|
segment.ident == "Option"
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
let entity = &relation.entity;
|
||||||
|
let return_type = if relation.nullable {
|
||||||
|
quote! { Option<#entity> }
|
||||||
|
} else {
|
||||||
|
quote! { #entity }
|
||||||
|
};
|
||||||
|
let query = format!(
|
||||||
|
"SELECT * FROM {} WHERE {} = $1",
|
||||||
|
relation.table, relation.remote_id
|
||||||
|
);
|
||||||
|
let local_ident = &value.field.ident;
|
||||||
|
let fetch = if relation.nullable {
|
||||||
|
quote! { fetch_optional }
|
||||||
|
} else {
|
||||||
|
quote! { fetch_one }
|
||||||
|
};
|
||||||
|
quote! {
|
||||||
|
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<#return_type> {
|
||||||
|
::sqlx::query_as!(#entity, #query, self.#local_ident).#fetch(pool).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
georm-macros/src/georm/ir/simple_relationship.rs
Normal file
66
georm-macros/src/georm/ir/simple_relationship.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
use quote::quote;
|
||||||
|
|
||||||
|
pub trait SimpleRelationshipType {}
|
||||||
|
|
||||||
|
#[derive(deluxe::ParseMetaItem, Default)]
|
||||||
|
pub struct OneToOne;
|
||||||
|
impl SimpleRelationshipType for OneToOne {}
|
||||||
|
|
||||||
|
#[derive(deluxe::ParseMetaItem, Default)]
|
||||||
|
pub struct OneToMany;
|
||||||
|
impl SimpleRelationshipType for OneToMany {}
|
||||||
|
|
||||||
|
#[derive(deluxe::ParseMetaItem)]
|
||||||
|
pub struct SimpleRelationship<T>
|
||||||
|
where
|
||||||
|
T: SimpleRelationshipType + deluxe::ParseMetaItem + Default,
|
||||||
|
{
|
||||||
|
pub name: String,
|
||||||
|
pub remote_id: String,
|
||||||
|
pub table: String,
|
||||||
|
pub entity: syn::Type,
|
||||||
|
#[deluxe(default = T::default())]
|
||||||
|
_phantom: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> SimpleRelationship<T>
|
||||||
|
where
|
||||||
|
T: SimpleRelationshipType + deluxe::ParseMetaItem + Default,
|
||||||
|
{
|
||||||
|
pub fn make_query(&self) -> String {
|
||||||
|
format!("SELECT * FROM {} WHERE {} = $1", self.table, self.remote_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn make_function_name(&self) -> syn::Ident {
|
||||||
|
syn::Ident::new(
|
||||||
|
&format!("get_{}", self.name),
|
||||||
|
proc_macro2::Span::call_site(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&SimpleRelationship<OneToOne>> for proc_macro2::TokenStream {
|
||||||
|
fn from(value: &SimpleRelationship<OneToOne>) -> Self {
|
||||||
|
let query = value.make_query();
|
||||||
|
let entity = &value.entity;
|
||||||
|
let function = value.make_function_name();
|
||||||
|
quote! {
|
||||||
|
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Option<#entity>> {
|
||||||
|
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_optional(pool).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&SimpleRelationship<OneToMany>> for proc_macro2::TokenStream {
|
||||||
|
fn from(value: &SimpleRelationship<OneToMany>) -> Self {
|
||||||
|
let query = value.make_query();
|
||||||
|
let entity = &value.entity;
|
||||||
|
let function = value.make_function_name();
|
||||||
|
quote! {
|
||||||
|
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<#entity>> {
|
||||||
|
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_all(pool).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
use ir::GeormField;
|
|
||||||
use quote::quote;
|
use quote::quote;
|
||||||
|
|
||||||
|
mod composite_keys;
|
||||||
|
mod defaultable_struct;
|
||||||
mod ir;
|
mod ir;
|
||||||
|
pub(crate) use ir::GeormField;
|
||||||
mod relationships;
|
mod relationships;
|
||||||
mod trait_implementation;
|
mod traits;
|
||||||
|
pub(crate) use composite_keys::IdType;
|
||||||
|
|
||||||
fn extract_georm_field_attrs(
|
fn extract_georm_field_attrs(ast: &mut syn::DeriveInput) -> deluxe::Result<Vec<GeormField>> {
|
||||||
ast: &mut syn::DeriveInput,
|
|
||||||
) -> deluxe::Result<(Vec<GeormField>, GeormField)> {
|
|
||||||
let syn::Data::Struct(s) = &mut ast.data else {
|
let syn::Data::Struct(s) = &mut ast.data else {
|
||||||
return Err(syn::Error::new_spanned(
|
return Err(syn::Error::new_spanned(
|
||||||
ast,
|
ast,
|
||||||
@@ -25,20 +26,13 @@ fn extract_georm_field_attrs(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|field| field.id)
|
.filter(|field| field.id)
|
||||||
.collect();
|
.collect();
|
||||||
match identifiers.len() {
|
if identifiers.is_empty() {
|
||||||
0 => Err(syn::Error::new_spanned(
|
Err(syn::Error::new_spanned(
|
||||||
ast,
|
ast,
|
||||||
"Struct {name} must have one identifier",
|
"Struct {name} must have one identifier",
|
||||||
)),
|
))
|
||||||
1 => Ok((fields, identifiers.first().unwrap().clone())),
|
} else {
|
||||||
_ => {
|
Ok(fields)
|
||||||
let id1 = identifiers.first().unwrap();
|
|
||||||
let id2 = identifiers.get(1).unwrap();
|
|
||||||
Err(syn::Error::new_spanned(id2.field.clone(), format!(
|
|
||||||
"Field {} cannot be an identifier, {} already is one.\nOnly one identifier is supported.",
|
|
||||||
id1.ident, id2.ident
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,12 +42,43 @@ pub fn georm_derive_macro2(
|
|||||||
let mut ast: syn::DeriveInput = syn::parse2(item).expect("Failed to parse input");
|
let mut ast: syn::DeriveInput = syn::parse2(item).expect("Failed to parse input");
|
||||||
let struct_attrs: ir::GeormStructAttributes =
|
let struct_attrs: ir::GeormStructAttributes =
|
||||||
deluxe::extract_attributes(&mut ast).expect("Could not extract attributes from struct");
|
deluxe::extract_attributes(&mut ast).expect("Could not extract attributes from struct");
|
||||||
let (fields, id) = extract_georm_field_attrs(&mut ast)?;
|
let fields = extract_georm_field_attrs(&mut ast)?;
|
||||||
let relationships = relationships::derive_relationships(&ast, &struct_attrs, &fields, &id);
|
let defaultable_struct =
|
||||||
let trait_impl = trait_implementation::derive_trait(&ast, &struct_attrs.table, &fields, &id);
|
defaultable_struct::derive_defaultable_struct(&ast, &struct_attrs, &fields);
|
||||||
|
let from_row_impl = generate_from_row_impl(&ast, &fields);
|
||||||
|
|
||||||
|
let (identifier, id_struct) = composite_keys::create_primary_key(&ast, &fields);
|
||||||
|
|
||||||
|
let relationships =
|
||||||
|
relationships::derive_relationships(&ast, &struct_attrs, &fields, &identifier);
|
||||||
|
let trait_impl = traits::derive_trait(&ast, &struct_attrs.table, &fields, &identifier);
|
||||||
|
|
||||||
let code = quote! {
|
let code = quote! {
|
||||||
|
#id_struct
|
||||||
|
#defaultable_struct
|
||||||
#relationships
|
#relationships
|
||||||
#trait_impl
|
#trait_impl
|
||||||
|
#from_row_impl
|
||||||
};
|
};
|
||||||
Ok(code)
|
Ok(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn generate_from_row_impl(
|
||||||
|
ast: &syn::DeriveInput,
|
||||||
|
fields: &[GeormField],
|
||||||
|
) -> proc_macro2::TokenStream {
|
||||||
|
let struct_name = &ast.ident;
|
||||||
|
let field_idents: Vec<&syn::Ident> = fields.iter().map(|f| &f.ident).collect();
|
||||||
|
let field_names: Vec<String> = fields.iter().map(|f| f.ident.to_string()).collect();
|
||||||
|
|
||||||
|
quote! {
|
||||||
|
impl<'r> ::sqlx::FromRow<'r, ::sqlx::postgres::PgRow> for #struct_name {
|
||||||
|
fn from_row(row: &'r ::sqlx::postgres::PgRow) -> ::sqlx::Result<Self> {
|
||||||
|
use ::sqlx::Row;
|
||||||
|
Ok(Self {
|
||||||
|
#(#field_idents: row.try_get(#field_names)?),*
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use crate::georm::ir::M2MRelationshipComplete;
|
use crate::georm::ir::m2m_relationship::M2MRelationshipComplete;
|
||||||
|
|
||||||
|
use super::composite_keys::IdType;
|
||||||
use super::ir::GeormField;
|
use super::ir::GeormField;
|
||||||
use proc_macro2::TokenStream;
|
use proc_macro2::TokenStream;
|
||||||
use quote::quote;
|
use quote::quote;
|
||||||
@@ -15,16 +16,12 @@ fn join_token_streams(token_streams: &[TokenStream]) -> TokenStream {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn derive<T, P>(relationships: &[T], condition: P) -> TokenStream
|
fn derive<T>(relationships: &[T]) -> TokenStream
|
||||||
where
|
where
|
||||||
for<'a> &'a T: Into<TokenStream>,
|
for<'a> &'a T: Into<TokenStream>,
|
||||||
P: FnMut(&&T) -> bool,
|
|
||||||
{
|
{
|
||||||
let implementations: Vec<TokenStream> = relationships
|
let implementations: Vec<TokenStream> =
|
||||||
.iter()
|
relationships.iter().map(std::convert::Into::into).collect();
|
||||||
.filter(condition)
|
|
||||||
.map(std::convert::Into::into)
|
|
||||||
.collect();
|
|
||||||
join_token_streams(&implementations)
|
join_token_streams(&implementations)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,21 +29,39 @@ pub fn derive_relationships(
|
|||||||
ast: &syn::DeriveInput,
|
ast: &syn::DeriveInput,
|
||||||
struct_attrs: &super::ir::GeormStructAttributes,
|
struct_attrs: &super::ir::GeormStructAttributes,
|
||||||
fields: &[GeormField],
|
fields: &[GeormField],
|
||||||
id: &GeormField,
|
id: &IdType,
|
||||||
) -> TokenStream {
|
) -> TokenStream {
|
||||||
|
let id = match id {
|
||||||
|
IdType::Simple {
|
||||||
|
field_name,
|
||||||
|
field_type: _,
|
||||||
|
} => field_name.to_string(),
|
||||||
|
IdType::Composite {
|
||||||
|
fields: _,
|
||||||
|
field_type: _,
|
||||||
|
} => {
|
||||||
|
eprintln!(
|
||||||
|
"Warning: entity {}: Relationships are not supported for entities with composite primary keys yet",
|
||||||
|
ast.ident
|
||||||
|
);
|
||||||
|
return quote! {};
|
||||||
|
}
|
||||||
|
};
|
||||||
let struct_name = &ast.ident;
|
let struct_name = &ast.ident;
|
||||||
let one_to_one = derive(fields, |field| field.relation.is_some());
|
let one_to_one_local = derive(fields);
|
||||||
let one_to_many = derive(&struct_attrs.one_to_many, |_| true);
|
let one_to_one_remote = derive(&struct_attrs.one_to_one);
|
||||||
|
let one_to_many = derive(&struct_attrs.one_to_many);
|
||||||
let many_to_many: Vec<M2MRelationshipComplete> = struct_attrs
|
let many_to_many: Vec<M2MRelationshipComplete> = struct_attrs
|
||||||
.many_to_many
|
.many_to_many
|
||||||
.iter()
|
.iter()
|
||||||
.map(|v| M2MRelationshipComplete::new(v, &struct_attrs.table, id.ident.to_string()))
|
.map(|v| M2MRelationshipComplete::new(v, &struct_attrs.table, &id))
|
||||||
.collect();
|
.collect();
|
||||||
let many_to_many = derive(&many_to_many, |_| true);
|
let many_to_many = derive(&many_to_many);
|
||||||
|
|
||||||
quote! {
|
quote! {
|
||||||
impl #struct_name {
|
impl #struct_name {
|
||||||
#one_to_one
|
#one_to_one_local
|
||||||
|
#one_to_one_remote
|
||||||
#one_to_many
|
#one_to_many
|
||||||
#many_to_many
|
#many_to_many
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
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 {} = $1", id.ident);
|
|
||||||
let ty = &id.ty;
|
|
||||||
quote! {
|
|
||||||
async fn find(pool: &::sqlx::PgPool, id: &#ty) -> ::sqlx::Result<Option<Self>> {
|
|
||||||
::sqlx::query_as!(Self, #find_string, id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_create_query(table: &str, fields: &[GeormField]) -> proc_macro2::TokenStream {
|
|
||||||
let inputs: Vec<String> = (1..=fields.len()).map(|num| format!("${num}")).collect();
|
|
||||||
let create_string = format!(
|
|
||||||
"INSERT INTO {table} ({}) VALUES ({}) RETURNING *",
|
|
||||||
fields
|
|
||||||
.iter()
|
|
||||||
.map(|f| f.ident.to_string())
|
|
||||||
.collect::<Vec<String>>()
|
|
||||||
.join(", "),
|
|
||||||
inputs.join(", ")
|
|
||||||
);
|
|
||||||
let field_idents: Vec<syn::Ident> = fields.iter().map(|f| f.ident.clone()).collect();
|
|
||||||
quote! {
|
|
||||||
async fn create(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Self> {
|
|
||||||
::sqlx::query_as!(
|
|
||||||
Self,
|
|
||||||
#create_string,
|
|
||||||
#(self.#field_idents),*
|
|
||||||
)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_update_query(
|
|
||||||
table: &str,
|
|
||||||
fields: &[GeormField],
|
|
||||||
id: &GeormField,
|
|
||||||
) -> proc_macro2::TokenStream {
|
|
||||||
let mut fields: Vec<&GeormField> = fields.iter().filter(|f| !f.id).collect();
|
|
||||||
let update_columns = fields
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, &field)| format!("{} = ${}", field.ident, i + 1))
|
|
||||||
.collect::<Vec<String>>()
|
|
||||||
.join(", ");
|
|
||||||
let update_string = format!(
|
|
||||||
"UPDATE {table} SET {update_columns} WHERE {} = ${} RETURNING *",
|
|
||||||
id.ident,
|
|
||||||
fields.len() + 1
|
|
||||||
);
|
|
||||||
fields.push(id);
|
|
||||||
let field_idents: Vec<_> = fields.iter().map(|f| f.ident.clone()).collect();
|
|
||||||
quote! {
|
|
||||||
async fn update(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Self> {
|
|
||||||
::sqlx::query_as!(
|
|
||||||
Self,
|
|
||||||
#update_string,
|
|
||||||
#(self.#field_idents),*
|
|
||||||
)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_delete_query(table: &str, id: &GeormField) -> proc_macro2::TokenStream {
|
|
||||||
let delete_string = format!("DELETE FROM {table} WHERE {} = $1", id.ident);
|
|
||||||
let ty = &id.ty;
|
|
||||||
quote! {
|
|
||||||
async fn delete_by_id(pool: &::sqlx::PgPool, id: &#ty) -> ::sqlx::Result<u64> {
|
|
||||||
let rows_affected = ::sqlx::query!(#delete_string, id)
|
|
||||||
.execute(pool)
|
|
||||||
.await?
|
|
||||||
.rows_affected();
|
|
||||||
Ok(rows_affected)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<u64> {
|
|
||||||
Self::delete_by_id(pool, self.get_id()).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_get_id(id: &GeormField) -> proc_macro2::TokenStream {
|
|
||||||
let ident = &id.ident;
|
|
||||||
let ty = &id.ty;
|
|
||||||
quote! {
|
|
||||||
fn get_id(&self) -> &#ty {
|
|
||||||
&self.#ident
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn derive_trait(
|
|
||||||
ast: &syn::DeriveInput,
|
|
||||||
table: &str,
|
|
||||||
fields: &[GeormField],
|
|
||||||
id: &GeormField,
|
|
||||||
) -> proc_macro2::TokenStream {
|
|
||||||
let ty = &id.ty;
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
let update_query = generate_update_query(table, fields, id);
|
|
||||||
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
|
|
||||||
#delete_query
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
27
georm-macros/src/georm/traits/create.rs
Normal file
27
georm-macros/src/georm/traits/create.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
use crate::georm::GeormField;
|
||||||
|
use quote::quote;
|
||||||
|
|
||||||
|
pub fn generate_create_query(table: &str, fields: &[GeormField]) -> proc_macro2::TokenStream {
|
||||||
|
let inputs: Vec<String> = (1..=fields.len()).map(|num| format!("${num}")).collect();
|
||||||
|
let create_string = format!(
|
||||||
|
"INSERT INTO {table} ({}) VALUES ({}) RETURNING *",
|
||||||
|
fields
|
||||||
|
.iter()
|
||||||
|
.map(|f| f.ident.to_string())
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(", "),
|
||||||
|
inputs.join(", ")
|
||||||
|
);
|
||||||
|
let field_idents: Vec<syn::Ident> = fields.iter().map(|f| f.ident.clone()).collect();
|
||||||
|
quote! {
|
||||||
|
async fn create(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Self> {
|
||||||
|
::sqlx::query_as!(
|
||||||
|
Self,
|
||||||
|
#create_string,
|
||||||
|
#(self.#field_idents),*
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
georm-macros/src/georm/traits/delete.rs
Normal file
39
georm-macros/src/georm/traits/delete.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
use crate::georm::IdType;
|
||||||
|
use quote::quote;
|
||||||
|
|
||||||
|
pub fn generate_delete_query(table: &str, id: &IdType) -> proc_macro2::TokenStream {
|
||||||
|
let where_clause = match id {
|
||||||
|
IdType::Simple { field_name, .. } => format!("{} = $1", field_name),
|
||||||
|
IdType::Composite { fields, .. } => fields
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, field)| format!("{} = ${}", field.name, i + 1))
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(" AND "),
|
||||||
|
};
|
||||||
|
let query_args = match id {
|
||||||
|
IdType::Simple { .. } => quote! { id },
|
||||||
|
IdType::Composite { fields, .. } => {
|
||||||
|
let fields: Vec<syn::Ident> = fields.iter().map(|f| f.name.clone()).collect();
|
||||||
|
quote! { #(id.#fields), * }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let id_type = match id {
|
||||||
|
IdType::Simple { field_type, .. } => quote! { #field_type },
|
||||||
|
IdType::Composite { field_type, .. } => quote! { #field_type },
|
||||||
|
};
|
||||||
|
let delete_string = format!("DELETE FROM {table} WHERE {where_clause}");
|
||||||
|
quote! {
|
||||||
|
async fn delete_by_id(pool: &::sqlx::PgPool, id: &#id_type) -> ::sqlx::Result<u64> {
|
||||||
|
let rows_affected = ::sqlx::query!(#delete_string, #query_args)
|
||||||
|
.execute(pool)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
Ok(rows_affected)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<u64> {
|
||||||
|
Self::delete_by_id(pool, &self.get_id()).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
georm-macros/src/georm/traits/find.rs
Normal file
47
georm-macros/src/georm/traits/find.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use crate::georm::IdType;
|
||||||
|
use quote::quote;
|
||||||
|
|
||||||
|
pub 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_find_query(table: &str, id: &IdType) -> proc_macro2::TokenStream {
|
||||||
|
match id {
|
||||||
|
IdType::Simple {
|
||||||
|
field_name,
|
||||||
|
field_type,
|
||||||
|
} => {
|
||||||
|
let find_string = format!("SELECT * FROM {table} WHERE {} = $1", field_name);
|
||||||
|
quote! {
|
||||||
|
async fn find(pool: &::sqlx::PgPool, id: &#field_type) -> ::sqlx::Result<Option<Self>> {
|
||||||
|
::sqlx::query_as!(Self, #find_string, id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IdType::Composite { fields, field_type } => {
|
||||||
|
let id_match_string = fields
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, field)| format!("{} = ${}", field.name, i + 1))
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(" AND ");
|
||||||
|
let id_members: Vec<syn::Ident> =
|
||||||
|
fields.iter().map(|field| field.name.clone()).collect();
|
||||||
|
let find_string = format!("SELECT * FROM {table} WHERE {id_match_string}");
|
||||||
|
quote! {
|
||||||
|
async fn find(pool: &::sqlx::PgPool, id: &#field_type) -> ::sqlx::Result<Option<Self>> {
|
||||||
|
::sqlx::query_as!(Self, #find_string, #(id.#id_members),*)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
georm-macros/src/georm/traits/mod.rs
Normal file
70
georm-macros/src/georm/traits/mod.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
use super::composite_keys::IdType;
|
||||||
|
use super::ir::GeormField;
|
||||||
|
use quote::quote;
|
||||||
|
|
||||||
|
mod create;
|
||||||
|
mod delete;
|
||||||
|
mod find;
|
||||||
|
mod update;
|
||||||
|
mod upsert;
|
||||||
|
|
||||||
|
fn generate_get_id(id: &IdType) -> proc_macro2::TokenStream {
|
||||||
|
match id {
|
||||||
|
IdType::Simple {
|
||||||
|
field_name,
|
||||||
|
field_type,
|
||||||
|
} => {
|
||||||
|
quote! {
|
||||||
|
fn get_id(&self) -> #field_type {
|
||||||
|
self.#field_name.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IdType::Composite { fields, field_type } => {
|
||||||
|
let field_names: Vec<syn::Ident> = fields.iter().map(|f| f.name.clone()).collect();
|
||||||
|
quote! {
|
||||||
|
fn get_id(&self) -> #field_type {
|
||||||
|
#field_type {
|
||||||
|
#(#field_names: self.#field_names),*
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn derive_trait(
|
||||||
|
ast: &syn::DeriveInput,
|
||||||
|
table: &str,
|
||||||
|
fields: &[GeormField],
|
||||||
|
id: &IdType,
|
||||||
|
) -> proc_macro2::TokenStream {
|
||||||
|
let ty = match id {
|
||||||
|
IdType::Simple { field_type, .. } => quote! {#field_type},
|
||||||
|
IdType::Composite { field_type, .. } => quote! {#field_type},
|
||||||
|
};
|
||||||
|
|
||||||
|
// define impl variables
|
||||||
|
let ident = &ast.ident;
|
||||||
|
let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl();
|
||||||
|
|
||||||
|
// generate
|
||||||
|
let get_id = generate_get_id(id);
|
||||||
|
let get_all = find::generate_find_all_query(table);
|
||||||
|
let find_query = find::generate_find_query(table, id);
|
||||||
|
let create_query = create::generate_create_query(table, fields);
|
||||||
|
let update_query = update::generate_update_query(table, fields, id);
|
||||||
|
let upsert_query = upsert::generate_upsert_query(table, fields, id);
|
||||||
|
let delete_query = delete::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
|
||||||
|
#upsert_query
|
||||||
|
#delete_query
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
georm-macros/src/georm/traits/update.rs
Normal file
48
georm-macros/src/georm/traits/update.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
use crate::georm::{GeormField, IdType};
|
||||||
|
use quote::quote;
|
||||||
|
|
||||||
|
pub fn generate_update_query(
|
||||||
|
table: &str,
|
||||||
|
fields: &[GeormField],
|
||||||
|
id: &IdType,
|
||||||
|
) -> proc_macro2::TokenStream {
|
||||||
|
let non_id_fields: Vec<syn::Ident> = fields
|
||||||
|
.iter()
|
||||||
|
.filter_map(|f| if f.id { None } else { Some(f.ident.clone()) })
|
||||||
|
.collect();
|
||||||
|
let update_columns = non_id_fields
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, field)| format!("{} = ${}", field, i + 1))
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(", ");
|
||||||
|
let mut all_fields = non_id_fields.clone();
|
||||||
|
let where_clause = match id {
|
||||||
|
IdType::Simple { field_name, .. } => {
|
||||||
|
let where_clause = format!("{} = ${}", field_name, non_id_fields.len() + 1);
|
||||||
|
all_fields.push(field_name.clone());
|
||||||
|
where_clause
|
||||||
|
}
|
||||||
|
IdType::Composite { fields, .. } => fields
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, field)| {
|
||||||
|
let where_clause = format!("{} = ${}", field.name, non_id_fields.len() + i + 1);
|
||||||
|
all_fields.push(field.name.clone());
|
||||||
|
where_clause
|
||||||
|
})
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(" AND "),
|
||||||
|
};
|
||||||
|
let update_string =
|
||||||
|
format!("UPDATE {table} SET {update_columns} WHERE {where_clause} RETURNING *");
|
||||||
|
quote! {
|
||||||
|
async fn update(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Self> {
|
||||||
|
::sqlx::query_as!(
|
||||||
|
Self, #update_string, #(self.#all_fields),*
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
georm-macros/src/georm/traits/upsert.rs
Normal file
53
georm-macros/src/georm/traits/upsert.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use crate::georm::{GeormField, IdType};
|
||||||
|
use quote::quote;
|
||||||
|
|
||||||
|
pub fn generate_upsert_query(
|
||||||
|
table: &str,
|
||||||
|
fields: &[GeormField],
|
||||||
|
id: &IdType,
|
||||||
|
) -> proc_macro2::TokenStream {
|
||||||
|
let inputs: Vec<String> = (1..=fields.len()).map(|num| format!("${num}")).collect();
|
||||||
|
let columns = fields
|
||||||
|
.iter()
|
||||||
|
.map(|f| f.ident.to_string())
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
let primary_key: proc_macro2::TokenStream = match id {
|
||||||
|
IdType::Simple { field_name, .. } => quote! {#field_name},
|
||||||
|
IdType::Composite { fields, .. } => {
|
||||||
|
let field_names: Vec<syn::Ident> = fields.iter().map(|f| f.name.clone()).collect();
|
||||||
|
quote! {
|
||||||
|
#(#field_names),*
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// For ON CONFLICT DO UPDATE, exclude the ID field from updates
|
||||||
|
let update_assignments = fields
|
||||||
|
.iter()
|
||||||
|
.filter(|f| !f.id)
|
||||||
|
.map(|f| format!("{} = EXCLUDED.{}", f.ident, f.ident))
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
let upsert_string = format!(
|
||||||
|
"INSERT INTO {table} ({columns}) VALUES ({}) ON CONFLICT ({}) DO UPDATE SET {update_assignments} RETURNING *",
|
||||||
|
inputs.join(", "),
|
||||||
|
primary_key
|
||||||
|
);
|
||||||
|
|
||||||
|
let field_idents: Vec<syn::Ident> = fields.iter().map(|f| f.ident.clone()).collect();
|
||||||
|
|
||||||
|
quote! {
|
||||||
|
async fn create_or_update(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Self> {
|
||||||
|
::sqlx::query_as!(
|
||||||
|
Self,
|
||||||
|
#upsert_string,
|
||||||
|
#(self.#field_idents),*
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
DROP TABLE IF EXISTS Followers;
|
||||||
|
DROP TABLE IF EXISTS Comments;
|
||||||
|
DROP TABLE IF EXISTS Profiles;
|
||||||
|
DROP TABLE IF EXISTS Users;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Add migration script here
|
||||||
|
CREATE TABLE Users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username VARCHAR(100) UNIQUE NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE Profiles (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INT UNIQUE NOT NULL,
|
||||||
|
bio TEXT,
|
||||||
|
display_name VARCHAR(100),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES Users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE Comments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
author_id INT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (author_id) REFERENCES Users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE Followers (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
followed INT NOT NULL,
|
||||||
|
follower INT NOT NULL,
|
||||||
|
FOREIGN KEY (followed) REFERENCES Users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (follower) REFERENCES Users(id) ON DELETE CASCADE,
|
||||||
|
CHECK (followed != follower),
|
||||||
|
UNIQUE (followed, follower)
|
||||||
|
);
|
||||||
2
migrations/20250609181248_composite-key.down.sql
Normal file
2
migrations/20250609181248_composite-key.down.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add down migration script here
|
||||||
|
DROP TABLE IF EXISTS UserRoles;
|
||||||
7
migrations/20250609181248_composite-key.up.sql
Normal file
7
migrations/20250609181248_composite-key.up.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
-- Add up migration script here
|
||||||
|
CREATE TABLE UserRoles (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
role_id INTEGER NOT NULL,
|
||||||
|
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_id, role_id)
|
||||||
|
);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Do not increase during a minor/patch release cycle
|
# Do not increase during a minor/patch release cycle
|
||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "1.81"
|
channel = "1.86"
|
||||||
profile = "minimal"
|
profile = "minimal"
|
||||||
|
|||||||
278
src/defaultable.rs
Normal file
278
src/defaultable.rs
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
/// Trait for creating entities with database defaults and auto-generated values.
|
||||||
|
///
|
||||||
|
/// This trait is automatically implemented on generated companion structs for entities
|
||||||
|
/// that have fields marked with `#[georm(defaultable)]`. It provides a convenient way
|
||||||
|
/// to create entities while allowing the database to provide default values for certain
|
||||||
|
/// fields.
|
||||||
|
///
|
||||||
|
/// ## Generated Implementation
|
||||||
|
///
|
||||||
|
/// When you mark fields with `#[georm(defaultable)]`, Georm automatically generates:
|
||||||
|
/// - A companion struct named `{EntityName}Default`
|
||||||
|
/// - An implementation of this trait for the companion struct
|
||||||
|
/// - Optimized SQL that omits defaultable fields when they are `None`
|
||||||
|
///
|
||||||
|
/// ## How It Works
|
||||||
|
///
|
||||||
|
/// The generated companion struct transforms defaultable fields into `Option<T>` types:
|
||||||
|
/// - `Some(value)` - Use the provided value
|
||||||
|
/// - `None` - Let the database provide the default value
|
||||||
|
///
|
||||||
|
/// Non-defaultable fields remain unchanged and are always required.
|
||||||
|
///
|
||||||
|
/// ## Database Behavior
|
||||||
|
///
|
||||||
|
/// The `create` method generates SQL that:
|
||||||
|
/// - Only includes fields where `Some(value)` is provided
|
||||||
|
/// - Omits fields that are `None`, allowing database defaults to apply
|
||||||
|
/// - Uses `RETURNING *` to capture the final entity state with all defaults applied
|
||||||
|
/// - Respects database triggers, sequences, and default value expressions
|
||||||
|
///
|
||||||
|
/// ## Usage Examples
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// use georm::{Georm, Defaultable};
|
||||||
|
///
|
||||||
|
/// #[derive(Georm)]
|
||||||
|
/// #[georm(table = "posts")]
|
||||||
|
/// pub struct Post {
|
||||||
|
/// #[georm(id, defaultable)]
|
||||||
|
/// id: i32, // Auto-generated serial
|
||||||
|
/// title: String, // Required field
|
||||||
|
/// #[georm(defaultable)]
|
||||||
|
/// published: bool, // Database default: false
|
||||||
|
/// #[georm(defaultable)]
|
||||||
|
/// created_at: chrono::DateTime<chrono::Utc>, // Database default: NOW()
|
||||||
|
/// author_id: i32, // Required field
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// // Generated automatically:
|
||||||
|
/// // pub struct PostDefault {
|
||||||
|
/// // pub id: Option<i32>,
|
||||||
|
/// // pub title: String,
|
||||||
|
/// // pub published: Option<bool>,
|
||||||
|
/// // pub created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
/// // pub author_id: i32,
|
||||||
|
/// // }
|
||||||
|
/// //
|
||||||
|
/// // impl Defaultable<i32, Post> for PostDefault { ... }
|
||||||
|
///
|
||||||
|
/// // Create with some defaults
|
||||||
|
/// let post_default = PostDefault {
|
||||||
|
/// id: None, // Let database auto-generate
|
||||||
|
/// title: "My Blog Post".to_string(),
|
||||||
|
/// published: None, // Use database default (false)
|
||||||
|
/// created_at: None, // Use database default (NOW())
|
||||||
|
/// author_id: 42,
|
||||||
|
/// };
|
||||||
|
///
|
||||||
|
/// let created_post = post_default.create(&pool).await?;
|
||||||
|
/// println!("Created post with ID: {}", created_post.id);
|
||||||
|
///
|
||||||
|
/// // Create with explicit values
|
||||||
|
/// let post_default = PostDefault {
|
||||||
|
/// id: None, // Still auto-generate ID
|
||||||
|
/// title: "Published Post".to_string(),
|
||||||
|
/// published: Some(true), // Override default
|
||||||
|
/// created_at: Some(specific_time), // Override default
|
||||||
|
/// author_id: 42,
|
||||||
|
/// };
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ## Type Parameters
|
||||||
|
///
|
||||||
|
/// - `Id` - The primary key type of the target entity (e.g., `i32`, `UserRoleId`)
|
||||||
|
/// - `Entity` - The target entity type that will be created (e.g., `Post`, `User`)
|
||||||
|
///
|
||||||
|
/// ## Comparison with Regular Creation
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// // Using regular Georm::create - must provide all values
|
||||||
|
/// let post = Post {
|
||||||
|
/// id: 0, // Ignored for auto-increment, but required
|
||||||
|
/// title: "My Post".to_string(),
|
||||||
|
/// published: false, // Must specify even if it's the default
|
||||||
|
/// created_at: chrono::Utc::now(), // Must calculate current time manually
|
||||||
|
/// author_id: 42,
|
||||||
|
/// };
|
||||||
|
/// let created = post.create(&pool).await?;
|
||||||
|
///
|
||||||
|
/// // Using Defaultable::create - let database handle defaults
|
||||||
|
/// let post_default = PostDefault {
|
||||||
|
/// id: None, // Clearer intent for auto-generation
|
||||||
|
/// title: "My Post".to_string(),
|
||||||
|
/// published: None, // Let database default apply
|
||||||
|
/// created_at: None, // Let database calculate NOW()
|
||||||
|
/// author_id: 42,
|
||||||
|
/// };
|
||||||
|
/// let created = post_default.create(&pool).await?;
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ## Field Visibility
|
||||||
|
///
|
||||||
|
/// The generated companion struct preserves the field visibility of the original entity:
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// #[derive(Georm)]
|
||||||
|
/// #[georm(table = "posts")]
|
||||||
|
/// pub struct Post {
|
||||||
|
/// #[georm(id, defaultable)]
|
||||||
|
/// pub id: i32,
|
||||||
|
/// pub title: String,
|
||||||
|
/// #[georm(defaultable)]
|
||||||
|
/// pub(crate) internal_status: String, // Crate-private field
|
||||||
|
/// #[georm(defaultable)]
|
||||||
|
/// private_field: String, // Private field
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// // Generated with preserved visibility:
|
||||||
|
/// // pub struct PostDefault {
|
||||||
|
/// // pub id: Option<i32>,
|
||||||
|
/// // pub title: String,
|
||||||
|
/// // pub(crate) internal_status: Option<String>, // Preserved
|
||||||
|
/// // private_field: Option<String>, // Preserved
|
||||||
|
/// // }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ## Limitations and Rules
|
||||||
|
///
|
||||||
|
/// - **Option fields cannot be defaultable**: Fields that are already `Option<T>` cannot
|
||||||
|
/// be marked with `#[georm(defaultable)]` to prevent `Option<Option<T>>` types
|
||||||
|
/// - **Compile-time validation**: Attempts to mark `Option<T>` fields as defaultable
|
||||||
|
/// result in compile-time errors
|
||||||
|
/// - **Requires at least one defaultable field**: The companion struct is only generated
|
||||||
|
/// if at least one field is marked as defaultable
|
||||||
|
/// - **No partial updates**: This trait only supports creating new entities, not updating
|
||||||
|
/// existing ones with defaults
|
||||||
|
///
|
||||||
|
/// ## Error Handling
|
||||||
|
///
|
||||||
|
/// The `create` method can fail for the same reasons as regular entity creation:
|
||||||
|
/// - Database connection issues
|
||||||
|
/// - Constraint violations (unique, foreign key, NOT NULL for non-defaultable fields)
|
||||||
|
/// - Permission problems
|
||||||
|
/// - Table or column doesn't exist
|
||||||
|
///
|
||||||
|
/// ## Performance Characteristics
|
||||||
|
///
|
||||||
|
/// - **Efficient SQL**: Only includes necessary fields in the INSERT statement
|
||||||
|
/// - **Single round-trip**: Uses `RETURNING *` to get the final entity state
|
||||||
|
/// - **No overhead**: Defaultable logic is resolved at compile time
|
||||||
|
/// - **Database-optimized**: Leverages database defaults rather than application logic
|
||||||
|
pub trait Defaultable<Id, Entity> {
|
||||||
|
/// Create a new entity in the database using database defaults for unspecified fields.
|
||||||
|
///
|
||||||
|
/// This method constructs and executes an `INSERT INTO table_name (...) VALUES (...) RETURNING *`
|
||||||
|
/// query that only includes fields where `Some(value)` is provided. Fields that are `None`
|
||||||
|
/// are omitted from the query, allowing the database to apply default values, auto-increment
|
||||||
|
/// sequences, or trigger-generated values.
|
||||||
|
///
|
||||||
|
/// # Parameters
|
||||||
|
/// - `pool` - Database connection pool
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// - `Ok(Entity)` - The newly created entity with all database-generated values populated
|
||||||
|
/// - `Err(sqlx::Error)` - Database constraint violations or connection errors
|
||||||
|
///
|
||||||
|
/// # Database Behavior
|
||||||
|
/// - **Selective field inclusion**: Only includes fields with `Some(value)` in the INSERT
|
||||||
|
/// - **Default value application**: Database defaults apply to omitted fields
|
||||||
|
/// - **RETURNING clause**: Captures the complete entity state after insertion
|
||||||
|
/// - **Trigger execution**: Database triggers run and their effects are captured
|
||||||
|
/// - **Sequence generation**: Auto-increment values are generated and returned
|
||||||
|
///
|
||||||
|
/// # SQL Generation
|
||||||
|
///
|
||||||
|
/// The generated SQL dynamically includes only the necessary fields:
|
||||||
|
///
|
||||||
|
/// ```sql
|
||||||
|
/// -- If id=None, published=None, created_at=None:
|
||||||
|
/// INSERT INTO posts (title, author_id) VALUES ($1, $2) RETURNING *;
|
||||||
|
///
|
||||||
|
/// -- If id=None, published=Some(true), created_at=None:
|
||||||
|
/// INSERT INTO posts (title, published, author_id) VALUES ($1, $2, $3) RETURNING *;
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// // Minimal creation - let database handle all defaults
|
||||||
|
/// let post_default = PostDefault {
|
||||||
|
/// id: None, // Auto-generated
|
||||||
|
/// title: "Hello World".to_string(),
|
||||||
|
/// published: None, // Database default
|
||||||
|
/// created_at: None, // Database default (NOW())
|
||||||
|
/// author_id: 1,
|
||||||
|
/// };
|
||||||
|
/// let post = post_default.create(&pool).await?;
|
||||||
|
///
|
||||||
|
/// // Mixed creation - some explicit values, some defaults
|
||||||
|
/// let post_default = PostDefault {
|
||||||
|
/// id: None, // Auto-generated
|
||||||
|
/// title: "Published Post".to_string(),
|
||||||
|
/// published: Some(true), // Override default
|
||||||
|
/// created_at: None, // Still use database default
|
||||||
|
/// author_id: 1,
|
||||||
|
/// };
|
||||||
|
/// let post = post_default.create(&pool).await?;
|
||||||
|
///
|
||||||
|
/// // Full control - specify all defaultable values
|
||||||
|
/// let specific_time = chrono::Utc::now() - chrono::Duration::hours(1);
|
||||||
|
/// let post_default = PostDefault {
|
||||||
|
/// id: Some(100), // Explicit ID (if not auto-increment)
|
||||||
|
/// title: "Backdated Post".to_string(),
|
||||||
|
/// published: Some(false), // Explicit value
|
||||||
|
/// created_at: Some(specific_time), // Explicit timestamp
|
||||||
|
/// author_id: 1,
|
||||||
|
/// };
|
||||||
|
/// let post = post_default.create(&pool).await?;
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # Error Conditions
|
||||||
|
///
|
||||||
|
/// Returns `sqlx::Error` for:
|
||||||
|
/// - **Unique constraint violations**: Duplicate values for unique fields
|
||||||
|
/// - **Foreign key violations**: Invalid references to other tables
|
||||||
|
/// - **NOT NULL violations**: Missing values for required non-defaultable fields
|
||||||
|
/// - **Check constraint violations**: Values that don't meet database constraints
|
||||||
|
/// - **Database connection issues**: Network or connection pool problems
|
||||||
|
/// - **Permission problems**: Insufficient privileges for the operation
|
||||||
|
/// - **Table/column errors**: Missing tables or columns (usually caught at compile time)
|
||||||
|
///
|
||||||
|
/// # Performance Notes
|
||||||
|
///
|
||||||
|
/// - **Optimal field selection**: Only transmits necessary data to the database
|
||||||
|
/// - **Single database round-trip**: INSERT and retrieval in one operation
|
||||||
|
/// - **Compile-time optimization**: Field inclusion logic resolved at compile time
|
||||||
|
/// - **Database-native defaults**: Leverages database performance for default value generation
|
||||||
|
///
|
||||||
|
/// # Comparison with Standard Creation
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// // Standard Georm::create - all fields required
|
||||||
|
/// let post = Post {
|
||||||
|
/// id: 0, // Placeholder for auto-increment
|
||||||
|
/// title: "My Post".to_string(),
|
||||||
|
/// published: false, // Must specify, even if it's the default
|
||||||
|
/// created_at: chrono::Utc::now(), // Must calculate manually
|
||||||
|
/// author_id: 1,
|
||||||
|
/// };
|
||||||
|
/// let created = post.create(&pool).await?;
|
||||||
|
///
|
||||||
|
/// // Defaultable::create - only specify what you need
|
||||||
|
/// let post_default = PostDefault {
|
||||||
|
/// id: None, // Clear intent for auto-generation
|
||||||
|
/// title: "My Post".to_string(),
|
||||||
|
/// published: None, // Let database decide
|
||||||
|
/// created_at: None, // Let database calculate
|
||||||
|
/// author_id: 1,
|
||||||
|
/// };
|
||||||
|
/// let created = post_default.create(&pool).await?;
|
||||||
|
/// ```
|
||||||
|
fn create(
|
||||||
|
&self,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> impl std::future::Future<Output = sqlx::Result<Entity>> + Send
|
||||||
|
where
|
||||||
|
Self: Sized;
|
||||||
|
}
|
||||||
83
src/entity.rs
Normal file
83
src/entity.rs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
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
|
||||||
|
/// Returns any error Postgres may have encountered
|
||||||
|
fn find(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
id: &Id,
|
||||||
|
) -> impl std::future::Future<Output = sqlx::Result<Option<Self>>> + Send
|
||||||
|
where
|
||||||
|
Self: Sized;
|
||||||
|
|
||||||
|
/// Create the entity in the database.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns any error Postgres may have encountered
|
||||||
|
fn create(
|
||||||
|
&self,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
||||||
|
where
|
||||||
|
Self: Sized;
|
||||||
|
|
||||||
|
/// Update an entity with a matching identifier in the database.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns any error Postgres may have encountered
|
||||||
|
fn update(
|
||||||
|
&self,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
||||||
|
where
|
||||||
|
Self: Sized;
|
||||||
|
|
||||||
|
/// Update an entity with a matching identifier in the database if
|
||||||
|
/// it exists, create it otherwise.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns any error Postgres may have encountered
|
||||||
|
fn create_or_update(
|
||||||
|
&self,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
||||||
|
where
|
||||||
|
Self: Sized;
|
||||||
|
|
||||||
|
/// Delete the entity from the database if it exists.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// Returns the amount of rows affected by the deletion.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns any error Postgres may have encountered
|
||||||
|
fn delete(
|
||||||
|
&self,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send;
|
||||||
|
|
||||||
|
/// Delete any entity with the identifier `id`.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// Returns the amount of rows affected by the deletion.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns any error Postgres may have encountered
|
||||||
|
fn delete_by_id(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
id: &Id,
|
||||||
|
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send;
|
||||||
|
|
||||||
|
/// Returns the identifier of the entity.
|
||||||
|
fn get_id(&self) -> &Id;
|
||||||
|
}
|
||||||
370
src/georm.rs
Normal file
370
src/georm.rs
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
/// Core database operations trait for Georm entities.
|
||||||
|
///
|
||||||
|
/// This trait is automatically implemented by the `#[derive(Georm)]` macro and provides
|
||||||
|
/// all essential CRUD operations for database entities. The trait is generic over the
|
||||||
|
/// primary key type `Id`, which can be a simple type (e.g., `i32`) or a generated
|
||||||
|
/// composite key struct (e.g., `UserRoleId`).
|
||||||
|
///
|
||||||
|
/// ## Generated Implementation
|
||||||
|
///
|
||||||
|
/// When you derive `Georm` on a struct, this trait is automatically implemented with
|
||||||
|
/// PostgreSQL-optimized queries that use:
|
||||||
|
/// - **Prepared statements** for security and performance
|
||||||
|
/// - **RETURNING clause** to capture database-generated values
|
||||||
|
/// - **ON CONFLICT** for efficient upsert operations
|
||||||
|
/// - **Compile-time verification** via SQLx macros
|
||||||
|
///
|
||||||
|
/// ## Method Categories
|
||||||
|
///
|
||||||
|
/// ### Static Methods (Query Operations)
|
||||||
|
/// - [`find_all`] - Retrieve all entities from the table
|
||||||
|
/// - [`find`] - Retrieve a single entity by primary key
|
||||||
|
/// - [`delete_by_id`] - Delete an entity by primary key
|
||||||
|
///
|
||||||
|
/// ### Instance Methods (Mutation Operations)
|
||||||
|
/// - [`create`] - Insert a new entity into the database
|
||||||
|
/// - [`update`] - Update an existing entity in the database
|
||||||
|
/// - [`create_or_update`] - Upsert (insert or update) an entity
|
||||||
|
/// - [`delete`] - Delete this entity from the database
|
||||||
|
/// - [`get_id`] - Get the primary key of this entity
|
||||||
|
///
|
||||||
|
/// ## Usage Examples
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// use georm::Georm;
|
||||||
|
///
|
||||||
|
/// #[derive(Georm)]
|
||||||
|
/// #[georm(table = "users")]
|
||||||
|
/// struct User {
|
||||||
|
/// #[georm(id)]
|
||||||
|
/// id: i32,
|
||||||
|
/// username: String,
|
||||||
|
/// email: String,
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// // Static methods
|
||||||
|
/// let all_users = User::find_all(&pool).await?;
|
||||||
|
/// let user = User::find(&pool, &1).await?;
|
||||||
|
/// let deleted_count = User::delete_by_id(&pool, &1).await?;
|
||||||
|
///
|
||||||
|
/// // Instance methods
|
||||||
|
/// let new_user = User { id: 0, username: "alice".into(), email: "alice@example.com".into() };
|
||||||
|
/// let created = new_user.create(&pool).await?;
|
||||||
|
/// let updated = created.update(&pool).await?;
|
||||||
|
/// let id = updated.get_id();
|
||||||
|
/// let deleted_count = updated.delete(&pool).await?;
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ## Composite Key Support
|
||||||
|
///
|
||||||
|
/// For entities with composite primary keys, the `Id` type parameter becomes a generated
|
||||||
|
/// struct following the pattern `{EntityName}Id`:
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// #[derive(Georm)]
|
||||||
|
/// #[georm(table = "user_roles")]
|
||||||
|
/// struct UserRole {
|
||||||
|
/// #[georm(id)]
|
||||||
|
/// user_id: i32,
|
||||||
|
/// #[georm(id)]
|
||||||
|
/// role_id: i32,
|
||||||
|
/// assigned_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// // Generated: pub struct UserRoleId { pub user_id: i32, pub role_id: i32 }
|
||||||
|
/// // Trait: impl Georm<UserRoleId> for UserRole
|
||||||
|
///
|
||||||
|
/// let id = UserRoleId { user_id: 1, role_id: 2 };
|
||||||
|
/// let user_role = UserRole::find(&pool, &id).await?;
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ## Error Handling
|
||||||
|
///
|
||||||
|
/// All methods return `sqlx::Result<T>` and may fail due to:
|
||||||
|
/// - Database connection issues
|
||||||
|
/// - Constraint violations (unique, foreign key, etc.)
|
||||||
|
/// - Invalid queries (though most are caught at compile time)
|
||||||
|
/// - Missing records (for operations expecting existing data)
|
||||||
|
///
|
||||||
|
/// [`find_all`]: Georm::find_all
|
||||||
|
/// [`find`]: Georm::find
|
||||||
|
/// [`create`]: Georm::create
|
||||||
|
/// [`update`]: Georm::update
|
||||||
|
/// [`create_or_update`]: Georm::create_or_update
|
||||||
|
/// [`delete`]: Georm::delete
|
||||||
|
/// [`delete_by_id`]: Georm::delete_by_id
|
||||||
|
/// [`get_id`]: Georm::get_id
|
||||||
|
pub trait Georm<Id> {
|
||||||
|
/// Retrieve all entities from the database table.
|
||||||
|
///
|
||||||
|
/// This method executes a `SELECT * FROM table_name` query and returns all records
|
||||||
|
/// as a vector of entities. The results are not paginated or filtered.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// - `Ok(Vec<Self>)` - All entities in the table (may be empty)
|
||||||
|
/// - `Err(sqlx::Error)` - Database connection or query execution errors
|
||||||
|
///
|
||||||
|
/// # Performance Notes
|
||||||
|
/// - Returns all records in memory - consider pagination for large tables
|
||||||
|
/// - Uses prepared statements for optimal performance
|
||||||
|
/// - No built-in ordering - results may vary between calls
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
/// ```ignore
|
||||||
|
/// let all_users = User::find_all(&pool).await?;
|
||||||
|
/// println!("Found {} users", all_users.len());
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns `sqlx::Error` for database connection issues, permission problems,
|
||||||
|
/// or if the table doesn't exist.
|
||||||
|
fn find_all(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> impl ::std::future::Future<Output = ::sqlx::Result<Vec<Self>>> + Send
|
||||||
|
where
|
||||||
|
Self: Sized;
|
||||||
|
|
||||||
|
/// Find a single entity by its primary key.
|
||||||
|
///
|
||||||
|
/// This method executes a `SELECT * FROM table_name WHERE primary_key = $1` query
|
||||||
|
/// (or equivalent for composite keys) and returns the matching entity if found.
|
||||||
|
///
|
||||||
|
/// # Parameters
|
||||||
|
/// - `pool` - Database connection pool
|
||||||
|
/// - `id` - Primary key value (simple type or composite key struct)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// - `Ok(Some(Self))` - Entity found and returned
|
||||||
|
/// - `Ok(None)` - No entity with the given ID exists
|
||||||
|
/// - `Err(sqlx::Error)` - Database connection or query execution errors
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
/// ```ignore
|
||||||
|
/// // Simple primary key
|
||||||
|
/// let user = User::find(&pool, &1).await?;
|
||||||
|
///
|
||||||
|
/// // Composite primary key
|
||||||
|
/// let id = UserRoleId { user_id: 1, role_id: 2 };
|
||||||
|
/// let user_role = UserRole::find(&pool, &id).await?;
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns `sqlx::Error` for database connection issues, type conversion errors,
|
||||||
|
/// or query execution problems. Note that not finding a record is not an error
|
||||||
|
/// - it returns `Ok(None)`.
|
||||||
|
fn find(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
id: &Id,
|
||||||
|
) -> impl std::future::Future<Output = sqlx::Result<Option<Self>>> + Send
|
||||||
|
where
|
||||||
|
Self: Sized;
|
||||||
|
|
||||||
|
/// Insert this entity as a new record in the database.
|
||||||
|
///
|
||||||
|
/// This method executes an `INSERT INTO table_name (...) VALUES (...) RETURNING *`
|
||||||
|
/// query and returns the newly created entity with any database-generated values
|
||||||
|
/// (such as auto-increment IDs, default timestamps, etc.).
|
||||||
|
///
|
||||||
|
/// # Parameters
|
||||||
|
/// - `pool` - Database connection pool
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// - `Ok(Self)` - The entity as it exists in the database after insertion
|
||||||
|
/// - `Err(sqlx::Error)` - Database constraint violations or connection errors
|
||||||
|
///
|
||||||
|
/// # Database Behavior
|
||||||
|
/// - Uses `RETURNING *` to capture database-generated values
|
||||||
|
/// - Respects database defaults for fields marked `#[georm(defaultable)]`
|
||||||
|
/// - Triggers and database-side modifications are reflected in the returned entity
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
/// ```ignore
|
||||||
|
/// let new_user = User { id: 0, username: "alice".into(), email: "alice@example.com".into() };
|
||||||
|
/// let created_user = new_user.create(&pool).await?;
|
||||||
|
/// println!("Created user with ID: {}", created_user.id);
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns `sqlx::Error` for:
|
||||||
|
/// - Unique constraint violations
|
||||||
|
/// - Foreign key constraint violations
|
||||||
|
/// - NOT NULL constraint violations
|
||||||
|
/// - Database connection issues
|
||||||
|
/// - Permission problems
|
||||||
|
fn create(
|
||||||
|
&self,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
||||||
|
where
|
||||||
|
Self: Sized;
|
||||||
|
|
||||||
|
/// Update an existing entity in the database.
|
||||||
|
///
|
||||||
|
/// This method executes an `UPDATE table_name SET ... WHERE primary_key = ... RETURNING *`
|
||||||
|
/// query using the entity's current primary key to locate the record to update.
|
||||||
|
///
|
||||||
|
/// # Parameters
|
||||||
|
/// - `pool` - Database connection pool
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// - `Ok(Self)` - The entity as it exists in the database after the update
|
||||||
|
/// - `Err(sqlx::Error)` - Database errors or if no matching record exists
|
||||||
|
///
|
||||||
|
/// # Database Behavior
|
||||||
|
/// - Uses `RETURNING *` to capture any database-side changes
|
||||||
|
/// - Updates all fields, not just changed ones
|
||||||
|
/// - Triggers and database-side modifications are reflected in the returned entity
|
||||||
|
/// - Fails if no record with the current primary key exists
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
/// ```ignore
|
||||||
|
/// let mut user = User::find(&pool, &1).await?.unwrap();
|
||||||
|
/// user.email = "newemail@example.com".into();
|
||||||
|
/// let updated_user = user.update(&pool).await?;
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns `sqlx::Error` for:
|
||||||
|
/// - No matching record found (record was deleted by another process)
|
||||||
|
/// - Constraint violations (unique, foreign key, etc.)
|
||||||
|
/// - Database connection issues
|
||||||
|
/// - Permission problems
|
||||||
|
fn update(
|
||||||
|
&self,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
||||||
|
where
|
||||||
|
Self: Sized;
|
||||||
|
|
||||||
|
/// Insert or update this entity using PostgreSQL's upsert functionality.
|
||||||
|
///
|
||||||
|
/// This method executes an `INSERT ... ON CONFLICT (...) DO UPDATE SET ... RETURNING *`
|
||||||
|
/// query that atomically inserts the entity if it doesn't exist, or updates it if
|
||||||
|
/// a record with the same primary key already exists.
|
||||||
|
///
|
||||||
|
/// # Parameters
|
||||||
|
/// - `pool` - Database connection pool
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// - `Ok(Self)` - The final entity state in the database (inserted or updated)
|
||||||
|
/// - `Err(sqlx::Error)` - Database connection or constraint violation errors
|
||||||
|
///
|
||||||
|
/// # Database Behavior
|
||||||
|
/// - Uses PostgreSQL's `ON CONFLICT` for true atomic upsert
|
||||||
|
/// - More efficient than separate find-then-create-or-update logic
|
||||||
|
/// - Uses `RETURNING *` to capture the final state
|
||||||
|
/// - Conflict resolution is based on the primary key constraint
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
/// ```ignore
|
||||||
|
/// let user = User { id: 1, username: "alice".into(), email: "alice@example.com".into() };
|
||||||
|
/// let final_user = user.create_or_update(&pool).await?;
|
||||||
|
/// // Will insert if ID 1 doesn't exist, update if it does
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns `sqlx::Error` for:
|
||||||
|
/// - Non-primary-key constraint violations
|
||||||
|
/// - Database connection issues
|
||||||
|
/// - Permission problems
|
||||||
|
fn create_or_update(
|
||||||
|
&self,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> impl ::std::future::Future<Output = sqlx::Result<Self>>
|
||||||
|
where
|
||||||
|
Self: Sized;
|
||||||
|
|
||||||
|
/// Delete this entity from the database.
|
||||||
|
///
|
||||||
|
/// This method executes a `DELETE FROM table_name WHERE primary_key = ...` query
|
||||||
|
/// using this entity's primary key to identify the record to delete.
|
||||||
|
///
|
||||||
|
/// # Parameters
|
||||||
|
/// - `pool` - Database connection pool
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// - `Ok(u64)` - Number of rows affected (0 if entity didn't exist, 1 if deleted)
|
||||||
|
/// - `Err(sqlx::Error)` - Database connection or constraint violation errors
|
||||||
|
///
|
||||||
|
/// # Database Behavior
|
||||||
|
/// - Uses the entity's current primary key for deletion
|
||||||
|
/// - Returns 0 if no matching record exists (not an error)
|
||||||
|
/// - May fail due to foreign key constraints if other records reference this entity
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
/// ```ignore
|
||||||
|
/// let user = User::find(&pool, &1).await?.unwrap();
|
||||||
|
/// let deleted_count = user.delete(&pool).await?;
|
||||||
|
/// assert_eq!(deleted_count, 1);
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns `sqlx::Error` for:
|
||||||
|
/// - Foreign key constraint violations (referenced by other tables)
|
||||||
|
/// - Database connection issues
|
||||||
|
/// - Permission problems
|
||||||
|
fn delete(
|
||||||
|
&self,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send;
|
||||||
|
|
||||||
|
/// Delete an entity by its primary key without needing an entity instance.
|
||||||
|
///
|
||||||
|
/// This method executes a `DELETE FROM table_name WHERE primary_key = ...` query
|
||||||
|
/// using the provided ID to identify the record to delete.
|
||||||
|
///
|
||||||
|
/// # Parameters
|
||||||
|
/// - `pool` - Database connection pool
|
||||||
|
/// - `id` - Primary key value (simple type or composite key struct)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// - `Ok(u64)` - Number of rows affected (0 if entity didn't exist, 1 if deleted)
|
||||||
|
/// - `Err(sqlx::Error)` - Database connection or constraint violation errors
|
||||||
|
///
|
||||||
|
/// # Database Behavior
|
||||||
|
/// - More efficient than `find().delete()` when you only have the ID
|
||||||
|
/// - Returns 0 if no matching record exists (not an error)
|
||||||
|
/// - May fail due to foreign key constraints if other records reference this entity
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
/// ```ignore
|
||||||
|
/// // Simple primary key
|
||||||
|
/// let deleted_count = User::delete_by_id(&pool, &1).await?;
|
||||||
|
///
|
||||||
|
/// // Composite primary key
|
||||||
|
/// let id = UserRoleId { user_id: 1, role_id: 2 };
|
||||||
|
/// let deleted_count = UserRole::delete_by_id(&pool, &id).await?;
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns `sqlx::Error` for:
|
||||||
|
/// - Foreign key constraint violations (referenced by other tables)
|
||||||
|
/// - Database connection issues
|
||||||
|
/// - Permission problems
|
||||||
|
fn delete_by_id(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
id: &Id,
|
||||||
|
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send;
|
||||||
|
|
||||||
|
/// Get the primary key of this entity.
|
||||||
|
///
|
||||||
|
/// For entities with simple primary keys, this returns the ID value directly.
|
||||||
|
/// For entities with composite primary keys, this returns an owned instance of
|
||||||
|
/// the generated `{EntityName}Id` struct.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// - Simple keys: The primary key value (e.g., `i32`, `String`)
|
||||||
|
/// - Composite keys: Generated ID struct (e.g., `UserRoleId`)
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
/// ```ignore
|
||||||
|
/// // Simple primary key
|
||||||
|
/// let user = User { id: 42, username: "alice".into(), email: "alice@example.com".into() };
|
||||||
|
/// let id = user.get_id(); // Returns 42
|
||||||
|
///
|
||||||
|
/// // Composite primary key
|
||||||
|
/// let user_role = UserRole { user_id: 1, role_id: 2, assigned_at: now };
|
||||||
|
/// let id = user_role.get_id(); // Returns UserRoleId { user_id: 1, role_id: 2 }
|
||||||
|
/// ```
|
||||||
|
fn get_id(&self) -> Id;
|
||||||
|
}
|
||||||
724
src/lib.rs
724
src/lib.rs
@@ -1,164 +1,354 @@
|
|||||||
//! # Georm
|
//! # Georm
|
||||||
//!
|
//!
|
||||||
//! ## Introduction
|
//! A simple, type-safe PostgreSQL ORM built on SQLx with zero runtime overhead.
|
||||||
//!
|
//!
|
||||||
//! Georm is a simple, opinionated SQLx ORM for PostgreSQL.
|
//! ## Quick Start
|
||||||
//!
|
|
||||||
//! 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
|
//! ```ignore
|
||||||
//! #[derive(sqlx::FromRow, Georm)]
|
//! use georm::Georm;
|
||||||
|
//!
|
||||||
|
//! // Note: No need to derive FromRow - Georm generates it automatically
|
||||||
|
//! #[derive(Georm)]
|
||||||
//! #[georm(table = "users")]
|
//! #[georm(table = "users")]
|
||||||
//! pub struct User {
|
//! pub struct User {
|
||||||
//! #[georm(id)]
|
//! #[georm(id)]
|
||||||
//! id: i32,
|
//! id: i32,
|
||||||
//! username: String,
|
//! username: String,
|
||||||
//! hashed_password: String,
|
//! email: String,
|
||||||
//! }
|
//! }
|
||||||
|
//!
|
||||||
|
//! // Use generated methods
|
||||||
|
//! let user = User::find(&pool, &1).await?; // Static method
|
||||||
|
//! let all_users = User::find_all(&pool).await?; // Static method
|
||||||
|
//! user.update(&pool).await?; // Instance method
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! The `User` type will now have access to all the functions declared in the
|
//! ## Core CRUD Operations
|
||||||
//! `Georm` trait.
|
|
||||||
//!
|
//!
|
||||||
//! ## One-to-one relationships
|
//! ### Static Methods (called on the struct type)
|
||||||
|
//! - `Entity::find(pool, &id)` - Find by primary key, returns `Option<Entity>`
|
||||||
|
//! - `Entity::find_all(pool)` - Get all records, returns `Vec<Entity>`
|
||||||
|
//! - `Entity::delete_by_id(pool, &id)` - Delete by ID, returns affected row count
|
||||||
//!
|
//!
|
||||||
//! You can then create relationships between different entities. For instance,
|
//! ### Instance Methods (called on entity objects)
|
||||||
//! you can use an identifier of another entity as a link to that other entity.
|
//! - `entity.create(pool)` - Insert new record, returns created entity with database-generated values
|
||||||
|
//! - `entity.update(pool)` - Update existing record, returns updated entity with fresh database state
|
||||||
|
//! - `entity.create_or_update(pool)` - True PostgreSQL upsert using `ON CONFLICT`, returns final entity
|
||||||
|
//! - `entity.delete(pool)` - Delete this record, returns affected row count
|
||||||
|
//! - `entity.get_id()` - Get reference to the entity's ID (`&Id` for simple keys, owned for composite)
|
||||||
//!
|
//!
|
||||||
//! ```ignore
|
//! ```ignore
|
||||||
//! #[derive(sqlx::FromRow, Georm)]
|
//! // Static methods
|
||||||
//! #[georm(table = "profiles")]
|
//! let user = User::find(&pool, &1).await?.unwrap();
|
||||||
//! pub struct Profile {
|
//! let all_users = User::find_all(&pool).await?;
|
||||||
|
//! let deleted_count = User::delete_by_id(&pool, &1).await?;
|
||||||
|
//!
|
||||||
|
//! // Instance methods
|
||||||
|
//! let new_user = User { id: 0, username: "alice".to_string(), email: "alice@example.com".to_string() };
|
||||||
|
//! let created = new_user.create(&pool).await?; // Returns entity with actual generated ID
|
||||||
|
//! let updated = created.update(&pool).await?; // Returns entity with fresh database state
|
||||||
|
//! let deleted_count = updated.delete(&pool).await?;
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ### PostgreSQL Optimizations
|
||||||
|
//!
|
||||||
|
//! Georm leverages PostgreSQL-specific features for performance and reliability:
|
||||||
|
//!
|
||||||
|
//! - **RETURNING clause**: All `INSERT` and `UPDATE` operations use `RETURNING *` to capture database-generated values (sequences, defaults, triggers)
|
||||||
|
//! - **True upserts**: `create_or_update()` uses `INSERT ... ON CONFLICT ... DO UPDATE` for atomic upsert operations
|
||||||
|
//! - **Prepared statements**: All queries use parameter binding for security and performance
|
||||||
|
//! - **Compile-time verification**: SQLx macros verify all generated SQL against your database schema at compile time
|
||||||
|
//!
|
||||||
|
//! ## Primary Keys and Identifiers
|
||||||
|
//!
|
||||||
|
//! ### Simple Primary Keys
|
||||||
|
//!
|
||||||
|
//! Primary key fields can have any name (not just "id"):
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! #[derive(Georm)]
|
||||||
|
//! #[georm(table = "books")]
|
||||||
|
//! pub struct Book {
|
||||||
//! #[georm(id)]
|
//! #[georm(id)]
|
||||||
//! id: i32,
|
//! ident: i32, // Custom field name for primary key
|
||||||
//! #[georm(
|
//! title: String,
|
||||||
//! relation = {
|
|
||||||
//! entity = User,
|
|
||||||
//! name = "user",
|
|
||||||
//! table = "users",
|
|
||||||
//! remote_id = "id",
|
|
||||||
//! nullable = false
|
|
||||||
//! })
|
|
||||||
//! ]
|
|
||||||
//! user_id: i32,
|
|
||||||
//! display_name: String,
|
|
||||||
//! }
|
//! }
|
||||||
|
//!
|
||||||
|
//! // Works the same way
|
||||||
|
//! let book = Book::find(&pool, &1).await?;
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! This will give access to the `Profile::get_user(&self, pool: &sqlx::PgPool)
|
//! ### Composite Primary Keys
|
||||||
//! -> User` method.
|
|
||||||
//!
|
//!
|
||||||
//! Here is an explanation of what these different values mean:
|
//! Mark multiple fields with `#[georm(id)]` for composite keys:
|
||||||
//!
|
|
||||||
//! | 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
|
//! ```ignore
|
||||||
//! #[derive(sqlx::FromRow, Georm)]
|
//! #[derive(Georm)]
|
||||||
//! #[georm(table = "profiles")]
|
//! #[georm(table = "user_roles")]
|
||||||
//! pub struct Profile {
|
//! pub struct UserRole {
|
||||||
//! #[georm(id)]
|
//! #[georm(id)]
|
||||||
//! id: i32,
|
|
||||||
//! #[georm(relation = { entity = User, table = "users", name = "user" })]
|
|
||||||
//! user_id: i32,
|
//! user_id: i32,
|
||||||
//! display_name: String,
|
//! #[georm(id)]
|
||||||
|
//! role_id: i32,
|
||||||
|
//! assigned_at: chrono::DateTime<chrono::Utc>,
|
||||||
//! }
|
//! }
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! ## One-to-many relationships
|
//! This automatically generates a composite ID struct following the `{EntityName}Id` pattern:
|
||||||
//!
|
|
||||||
//! 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
|
//! ```ignore
|
||||||
//! #[derive(sqlx::FromRow, Georm)]
|
//! // Generated automatically by the macro
|
||||||
|
//! pub struct UserRoleId {
|
||||||
|
//! pub user_id: i32,
|
||||||
|
//! pub role_id: i32,
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Usage with composite keys:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! // Static methods work with generated ID structs
|
||||||
|
//! let id = UserRoleId { user_id: 1, role_id: 2 };
|
||||||
|
//! let user_role = UserRole::find(&pool, &id).await?;
|
||||||
|
//! UserRole::delete_by_id(&pool, &id).await?;
|
||||||
|
//!
|
||||||
|
//! // Instance methods work the same way
|
||||||
|
//! let role = UserRole { user_id: 1, role_id: 2, assigned_at: chrono::Utc::now() };
|
||||||
|
//! let created = role.create(&pool).await?;
|
||||||
|
//! let id = created.get_id(); // Returns owned UserRoleId for composite keys
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ### Composite Key Limitations
|
||||||
|
//!
|
||||||
|
//! - **Relationships not supported**: Entities with composite primary keys cannot
|
||||||
|
//! yet define relationships (one-to-one, one-to-many, many-to-many)
|
||||||
|
//! - **ID struct naming**: Generated ID struct follows pattern `{EntityName}Id` (not customizable)
|
||||||
|
//!
|
||||||
|
//! ## Defaultable Fields
|
||||||
|
//!
|
||||||
|
//! Use `#[georm(defaultable)]` for fields with database defaults or auto-generated values:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! #[derive(Georm)]
|
||||||
//! #[georm(table = "posts")]
|
//! #[georm(table = "posts")]
|
||||||
//! struct Post {
|
//! pub struct Post {
|
||||||
//! #[georm(id)]
|
//! #[georm(id, defaultable)]
|
||||||
//! id: i32,
|
//! id: i32, // Auto-generated serial
|
||||||
//! #[georm(relation = { entity = User, table = "users", name = "user" })]
|
//! title: String, // Required field
|
||||||
//! author_id: i32,
|
//! #[georm(defaultable)]
|
||||||
//! content: String
|
//! published: bool, // Has database default
|
||||||
|
//! #[georm(defaultable)]
|
||||||
|
//! created_at: chrono::DateTime<chrono::Utc>, // DEFAULT NOW()
|
||||||
|
//! #[georm(defaultable)]
|
||||||
|
//! pub(crate) internal_note: String, // Field visibility preserved
|
||||||
|
//! author_id: i32, // Required field
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! This generates a companion `PostDefault` struct where defaultable fields become `Option<T>`:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! // Generated automatically by the macro
|
||||||
|
//! pub struct PostDefault {
|
||||||
|
//! pub id: Option<i32>, // Can be None for auto-generation
|
||||||
|
//! pub title: String, // Required field stays the same
|
||||||
|
//! pub published: Option<bool>, // Can be None to use database default
|
||||||
|
//! pub created_at: Option<chrono::DateTime<chrono::Utc>>, // Can be None
|
||||||
|
//! pub(crate) internal_note: Option<String>, // Visibility preserved
|
||||||
|
//! pub author_id: i32, // Required field stays the same
|
||||||
//! }
|
//! }
|
||||||
//!
|
//!
|
||||||
//! #[derive(sqlx::FromRow, Georm)]
|
//! impl Defaultable<i32, Post> for PostDefault {
|
||||||
|
//! async fn create(&self, pool: &sqlx::PgPool) -> sqlx::Result<Post>;
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ### Usage Example
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! use georm::{Georm, Defaultable};
|
||||||
|
//!
|
||||||
|
//! // Create a post with some fields using database defaults
|
||||||
|
//! let post_default = PostDefault {
|
||||||
|
//! id: None, // Let database auto-generate
|
||||||
|
//! title: "My Blog Post".to_string(),
|
||||||
|
//! published: None, // Use database default (e.g., false)
|
||||||
|
//! created_at: None, // Use database default (e.g., NOW())
|
||||||
|
//! internal_note: Some("Draft".to_string()),
|
||||||
|
//! author_id: 42,
|
||||||
|
//! };
|
||||||
|
//!
|
||||||
|
//! // Create the entity in the database (instance method on PostDefault)
|
||||||
|
//! let created_post = post_default.create(&pool).await?;
|
||||||
|
//! println!("Created post with ID: {}", created_post.id);
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ### Defaultable Rules and Limitations
|
||||||
|
//!
|
||||||
|
//! - **Option fields cannot be marked as defaultable**: If a field is already
|
||||||
|
//! `Option<T>`, you cannot mark it with `#[georm(defaultable)]`. This prevents
|
||||||
|
//! `Option<Option<T>>` types and causes a compile-time error.
|
||||||
|
//! - **Field visibility is preserved**: The generated defaultable struct maintains
|
||||||
|
//! the same field visibility (`pub`, `pub(crate)`, private) as the original struct.
|
||||||
|
//! - **ID fields can be defaultable**: It's common to mark ID fields as defaultable
|
||||||
|
//! when they are auto-generated serials in PostgreSQL.
|
||||||
|
//! - **Only generates when needed**: The defaultable struct is only generated if
|
||||||
|
//! at least one field is marked as defaultable.
|
||||||
|
//!
|
||||||
|
//! ## Relationships
|
||||||
|
//!
|
||||||
|
//! Georm supports comprehensive relationship modeling with two approaches: field-level
|
||||||
|
//! relationships for foreign keys and struct-level relationships for reverse lookups.
|
||||||
|
//! Each relationship method call executes a separate database query.
|
||||||
|
//!
|
||||||
|
//! ### Field-Level Relationships (Foreign Keys)
|
||||||
|
//!
|
||||||
|
//! Use the `relation` attribute on foreign key fields to generate lookup methods:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! #[derive(Georm)]
|
||||||
|
//! #[georm(table = "posts")]
|
||||||
|
//! pub struct Post {
|
||||||
|
//! #[georm(id)]
|
||||||
|
//! id: i32,
|
||||||
|
//! title: String,
|
||||||
|
//! #[georm(relation = {
|
||||||
|
//! entity = Author, // Target entity type
|
||||||
|
//! table = "authors", // Target table name
|
||||||
|
//! name = "author", // Method name (generates get_author)
|
||||||
|
//! remote_id = "id", // Target table's key column (default: "id")
|
||||||
|
//! nullable = false // Whether relationship can be null (default: false)
|
||||||
|
//! })]
|
||||||
|
//! author_id: i32,
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! **Generated instance method**: `post.get_author(pool).await? -> sqlx::Result<Author>`
|
||||||
|
//!
|
||||||
|
//! For nullable relationships:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! #[derive(Georm)]
|
||||||
|
//! #[georm(table = "posts")]
|
||||||
|
//! pub struct Post {
|
||||||
|
//! #[georm(id)]
|
||||||
|
//! id: i32,
|
||||||
|
//! title: String,
|
||||||
|
//! #[georm(relation = {
|
||||||
|
//! entity = Category,
|
||||||
|
//! table = "categories",
|
||||||
|
//! name = "category",
|
||||||
|
//! nullable = true // Allows NULL values
|
||||||
|
//! })]
|
||||||
|
//! category_id: Option<i32>,
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! **Generated instance method**: `post.get_category(pool).await? -> sqlx::Result<Option<Category>>`
|
||||||
|
//!
|
||||||
|
//! Since `remote_id` and `nullable` have default values, this is equivalent:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! #[georm(relation = { entity = Author, table = "authors", name = "author" })]
|
||||||
|
//! author_id: i32,
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! #### Non-Standard Primary Key References
|
||||||
|
//!
|
||||||
|
//! Use `remote_id` to reference tables with non-standard primary key names:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! #[derive(Georm)]
|
||||||
|
//! #[georm(table = "reviews")]
|
||||||
|
//! pub struct Review {
|
||||||
|
//! #[georm(id)]
|
||||||
|
//! id: i32,
|
||||||
|
//! #[georm(relation = {
|
||||||
|
//! entity = Book,
|
||||||
|
//! table = "books",
|
||||||
|
//! name = "book",
|
||||||
|
//! remote_id = "ident" // Book uses 'ident' instead of 'id'
|
||||||
|
//! })]
|
||||||
|
//! book_id: i32,
|
||||||
|
//! content: String,
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! #### Field-Level Relationship Attributes
|
||||||
|
//!
|
||||||
|
//! | Attribute | Description | Required | Default |
|
||||||
|
//! |--------------|------------------------------------------------------|----------|---------|
|
||||||
|
//! | `entity` | Target entity type | Yes | N/A |
|
||||||
|
//! | `name` | Method name (generates `get_{name}`) | Yes | N/A |
|
||||||
|
//! | `table` | Target table name | Yes | N/A |
|
||||||
|
//! | `remote_id` | Target table's key column | No | `"id"` |
|
||||||
|
//! | `nullable` | Whether relationship can be null | No | `false` |
|
||||||
|
//!
|
||||||
|
//! ### Struct-Level Relationships (Reverse Lookups)
|
||||||
|
//!
|
||||||
|
//! Define relationships at the struct level to query related entities that reference this entity.
|
||||||
|
//! These generate separate database queries for each method call.
|
||||||
|
//!
|
||||||
|
//! #### One-to-One Relationships
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! #[derive(Georm)]
|
||||||
//! #[georm(
|
//! #[georm(
|
||||||
//! table = "users",
|
//! table = "users",
|
||||||
//! one_to_many = [{
|
//! one_to_one = [{
|
||||||
//! entity = Post,
|
//! entity = Profile, // Related entity type
|
||||||
//! name = "posts",
|
//! name = "profile", // Method name (generates get_profile)
|
||||||
//! table = "posts",
|
//! table = "profiles", // Related table name
|
||||||
//! remote_id = "id"
|
//! remote_id = "user_id", // Foreign key in related table
|
||||||
//! }]
|
//! }]
|
||||||
//! )]
|
//! )]
|
||||||
//! struct User {
|
//! pub struct User {
|
||||||
//! #[georm(id)]
|
//! #[georm(id)]
|
||||||
//! id: i32,
|
//! id: i32,
|
||||||
//! username: String,
|
//! username: String,
|
||||||
//! hashed_password: String
|
|
||||||
//! }
|
//! }
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! As we’ve seen earlier, the struct `Post` has access to the method
|
//! **Generated instance method**: `user.get_profile(pool).await? -> sqlx::Result<Option<Profile>>`
|
||||||
//! `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`:
|
//! #### One-to-Many Relationships
|
||||||
//!
|
|
||||||
//! | 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
|
//! ```ignore
|
||||||
//! #[derive(sqlx::FromRow, Georm)]
|
//! #[derive(Georm)]
|
||||||
//! #[georm(
|
//! #[georm(
|
||||||
//! table = "users",
|
//! table = "authors",
|
||||||
//! one_to_many = [{ entity = Post, name = "posts", table = "posts" }]
|
//! one_to_many = [{
|
||||||
|
//! entity = Post, // Related entity type
|
||||||
|
//! name = "posts", // Method name (generates get_posts)
|
||||||
|
//! table = "posts", // Related table name
|
||||||
|
//! remote_id = "author_id" // Foreign key in related table
|
||||||
|
//! }, {
|
||||||
|
//! entity = Comment, // Multiple relationships allowed
|
||||||
|
//! name = "comments",
|
||||||
|
//! table = "comments",
|
||||||
|
//! remote_id = "author_id"
|
||||||
|
//! }]
|
||||||
//! )]
|
//! )]
|
||||||
//! struct User {
|
//! pub struct Author {
|
||||||
//! #[georm(id)]
|
//! #[georm(id)]
|
||||||
//! id: i32,
|
//! id: i32,
|
||||||
//! username: String,
|
//! name: String,
|
||||||
//! hashed_password: String
|
|
||||||
//! }
|
//! }
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! ## Many-to-many relationships
|
//! **Generated instance methods**:
|
||||||
|
//! - `author.get_posts(pool).await? -> sqlx::Result<Vec<Post>>`
|
||||||
|
//! - `author.get_comments(pool).await? -> sqlx::Result<Vec<Comment>>`
|
||||||
//!
|
//!
|
||||||
//! Many-to-many relationships between entities A and entities B with Georm rely
|
//! #### Many-to-Many Relationships
|
||||||
//! 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.
|
//! For many-to-many relationships, specify the link table that connects the entities:
|
||||||
//!
|
//!
|
||||||
//! ```sql
|
//! ```sql
|
||||||
|
//! -- Example schema for books and genres
|
||||||
//! CREATE TABLE books (
|
//! CREATE TABLE books (
|
||||||
//! id SERIAL PRIMARY KEY,
|
//! id SERIAL PRIMARY KEY,
|
||||||
//! title VARCHAR(100) NOT NULL
|
//! title VARCHAR(200) NOT NULL
|
||||||
//! );
|
//! );
|
||||||
//!
|
//!
|
||||||
//! CREATE TABLE genres (
|
//! CREATE TABLE genres (
|
||||||
@@ -166,178 +356,234 @@
|
|||||||
//! name VARCHAR(100) NOT NULL
|
//! name VARCHAR(100) NOT NULL
|
||||||
//! );
|
//! );
|
||||||
//!
|
//!
|
||||||
//! CREATE TABLE books_genres (
|
//! CREATE TABLE book_genres (
|
||||||
//! book_id INT NOT NULL,
|
//! book_id INT NOT NULL REFERENCES books(id),
|
||||||
//! genre_id INT NOT NULL,
|
//! genre_id INT NOT NULL REFERENCES genres(id),
|
||||||
//! PRIMARY KEY (book_id, genre_id),
|
//! 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
|
//! ```ignore
|
||||||
//! #[derive(sqlx::FromRow, Georm)]
|
//! #[derive(Georm)]
|
||||||
//! #[georm(
|
//! #[georm(
|
||||||
//! table = "books",
|
//! table = "books",
|
||||||
//! many_to_many = [{
|
//! many_to_many = [{
|
||||||
//! name = "genres",
|
//! entity = Genre, // Related entity type
|
||||||
//! entity = Genre,
|
//! name = "genres", // Method name (generates get_genres)
|
||||||
//! table = "genres",
|
//! table = "genres", // Related table name
|
||||||
//! remote_id = "id",
|
//! remote_id = "id", // Primary key in related table (default: "id")
|
||||||
//! link = { table = "books_genres", from = "book_id", to = "genre_id" }
|
//! link = { // Link table configuration
|
||||||
|
//! table = "book_genres", // Join table name
|
||||||
|
//! from = "book_id", // Column referencing this entity
|
||||||
|
//! to = "genre_id" // Column referencing related entity
|
||||||
|
//! }
|
||||||
//! }]
|
//! }]
|
||||||
//! )]
|
//! )]
|
||||||
//! struct Book {
|
//! pub struct Book {
|
||||||
//! #[georm(id)]
|
//! #[georm(id)]
|
||||||
//! id: i32,
|
//! id: i32,
|
||||||
//! title: String
|
//! title: String,
|
||||||
//! }
|
//! }
|
||||||
//!
|
//!
|
||||||
//! #[derive(sqlx::FromRow, Georm)]
|
//! #[derive(Georm)]
|
||||||
//! #[georm(
|
//! #[georm(
|
||||||
//! table = "genres",
|
//! table = "genres",
|
||||||
//! many_to_many = [{
|
//! many_to_many = [{
|
||||||
//! entity = Book,
|
//! entity = Book,
|
||||||
//! name = "books",
|
//! name = "books",
|
||||||
//! table = "books",
|
//! table = "books",
|
||||||
//! remote_id = "id",
|
//! link = {
|
||||||
//! link = { table = "books_genres", from = "genre_id", to = "book_id" }
|
//! table = "book_genres",
|
||||||
|
//! from = "genre_id", // Note: reversed perspective
|
||||||
|
//! to = "book_id"
|
||||||
|
//! }
|
||||||
//! }]
|
//! }]
|
||||||
//! )]
|
//! )]
|
||||||
//! struct Genre {
|
//! pub struct Genre {
|
||||||
//! #[georm(id)]
|
//! #[georm(id)]
|
||||||
//! id: i32,
|
//! id: i32,
|
||||||
//! name: String
|
//! name: String,
|
||||||
//! }
|
//! }
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! This generates two methods:
|
//! **Generated instance methods**:
|
||||||
//! - `Book::get_genres(&self, pool: &sqlx::PgPool) -> Vec<Genre>`
|
//! - `book.get_genres(pool).await? -> sqlx::Result<Vec<Genre>>`
|
||||||
//! - `Genre::get_books(&self, pool: &sqlx::PgPool) -> Vec<Book>`
|
//! - `genre.get_books(pool).await? -> sqlx::Result<Vec<Book>>`
|
||||||
//!
|
//!
|
||||||
//! As you can see, `many_to_many` is also an array, meaning we can define
|
//! #### Struct-Level Relationship Attributes
|
||||||
//! several many-to-many relationships for the same struct.
|
|
||||||
//!
|
//!
|
||||||
//! Here is an explanation of the values behind `many_to_many`:
|
//! | Attribute | Description | Required | Default |
|
||||||
|
//! |--------------|------------------------------------------------------|----------|---------|
|
||||||
|
//! | `entity` | Target entity type | Yes | N/A |
|
||||||
|
//! | `name` | Method name (generates `get_{name}`) | Yes | N/A |
|
||||||
|
//! | `table` | Target table name | Yes | N/A |
|
||||||
|
//! | `remote_id` | Target table's key column | No | `"id"` |
|
||||||
|
//! | `link.table` | Join table name (many-to-many only) | Yes* | N/A |
|
||||||
|
//! | `link.from` | Column referencing this entity (many-to-many only) | Yes* | N/A |
|
||||||
|
//! | `link.to` | Column referencing target entity (many-to-many only) | Yes* | N/A |
|
||||||
//!
|
//!
|
||||||
//! | Value Name | Explanation | Default value |
|
//! *Required for many-to-many relationships
|
||||||
//! |------------|------------------------------------------------------------------------------------------|---------------|
|
//!
|
||||||
//! | entity | Rust type of the entity found in the database | N/A |
|
//! As with field-level relationships, `remote_id` is optional and defaults to `"id"`:
|
||||||
//! | 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 |
|
//! ```ignore
|
||||||
//! | remote_id | Name of the column serving as the identifier of the entity | `"id"` |
|
//! #[georm(
|
||||||
//! | link.table | Name of the many-to-many relationship table | N/A |
|
//! table = "users",
|
||||||
//! | link.from | Column of the linking table referring to this entity | N/A |
|
//! one_to_many = [{ entity = Post, name = "posts", table = "posts" }]
|
||||||
//! | link.to | Column of the linking table referring to the remote entity | N/A |
|
//! )]
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! #### Complex Relationship Example
|
||||||
|
//!
|
||||||
|
//! Here's a comprehensive example showing multiple relationship types:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! #[derive(Georm)]
|
||||||
|
//! #[georm(
|
||||||
|
//! table = "posts",
|
||||||
|
//! one_to_many = [{
|
||||||
|
//! entity = Comment,
|
||||||
|
//! name = "comments",
|
||||||
|
//! table = "comments",
|
||||||
|
//! remote_id = "post_id"
|
||||||
|
//! }],
|
||||||
|
//! many_to_many = [{
|
||||||
|
//! entity = Tag,
|
||||||
|
//! name = "tags",
|
||||||
|
//! table = "tags",
|
||||||
|
//! link = {
|
||||||
|
//! table = "post_tags",
|
||||||
|
//! from = "post_id",
|
||||||
|
//! to = "tag_id"
|
||||||
|
//! }
|
||||||
|
//! }]
|
||||||
|
//! )]
|
||||||
|
//! pub struct Post {
|
||||||
|
//! #[georm(id)]
|
||||||
|
//! id: i32,
|
||||||
|
//! title: String,
|
||||||
|
//! content: String,
|
||||||
|
//!
|
||||||
|
//! // Field-level relationship (foreign key)
|
||||||
|
//! #[georm(relation = {
|
||||||
|
//! entity = Author,
|
||||||
|
//! table = "authors",
|
||||||
|
//! name = "author"
|
||||||
|
//! })]
|
||||||
|
//! author_id: i32,
|
||||||
|
//!
|
||||||
|
//! // Nullable field-level relationship
|
||||||
|
//! #[georm(relation = {
|
||||||
|
//! entity = Category,
|
||||||
|
//! table = "categories",
|
||||||
|
//! name = "category",
|
||||||
|
//! nullable = true
|
||||||
|
//! })]
|
||||||
|
//! category_id: Option<i32>,
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! **Generated instance methods**:
|
||||||
|
//! - `post.get_author(pool).await? -> sqlx::Result<Author>` (from field relation)
|
||||||
|
//! - `post.get_category(pool).await? -> sqlx::Result<Option<Category>>` (nullable field relation)
|
||||||
|
//! - `post.get_comments(pool).await? -> sqlx::Result<Vec<Comment>>` (one-to-many)
|
||||||
|
//! - `post.get_tags(pool).await? -> sqlx::Result<Vec<Tag>>` (many-to-many)
|
||||||
|
//!
|
||||||
|
//! ## Error Handling
|
||||||
|
//!
|
||||||
|
//! All Georm methods return `sqlx::Result<T>` which can contain:
|
||||||
|
//!
|
||||||
|
//! - **Database errors**: Connection issues, constraint violations, etc.
|
||||||
|
//! - **Not found errors**: When `find()` operations return `None`
|
||||||
|
//! - **Compile-time errors**: Invalid SQL, type mismatches, schema validation failures
|
||||||
|
//!
|
||||||
|
//! ### Compile-Time Validations
|
||||||
|
//!
|
||||||
|
//! Georm performs several validations at compile time:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! // ❌ Compile error: No ID field specified
|
||||||
|
//! #[derive(Georm)]
|
||||||
|
//! #[georm(table = "invalid")]
|
||||||
|
//! pub struct Invalid {
|
||||||
|
//! name: String, // Missing #[georm(id)]
|
||||||
|
//! }
|
||||||
|
//!
|
||||||
|
//! // ❌ Compile error: Option<T> cannot be defaultable
|
||||||
|
//! #[derive(Georm)]
|
||||||
|
//! #[georm(table = "invalid")]
|
||||||
|
//! pub struct Invalid {
|
||||||
|
//! #[georm(id)]
|
||||||
|
//! id: i32,
|
||||||
|
//! #[georm(defaultable)] // Error: would create Option<Option<String>>
|
||||||
|
//! optional_field: Option<String>,
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Attribute Reference
|
||||||
|
//!
|
||||||
|
//! ### Struct-Level Attributes
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! #[georm(
|
||||||
|
//! table = "table_name", // Required: database table name
|
||||||
|
//! one_to_one = [{ /* ... */ }], // Optional: one-to-one relationships
|
||||||
|
//! one_to_many = [{ /* ... */ }], // Optional: one-to-many relationships
|
||||||
|
//! many_to_many = [{ /* ... */ }] // Optional: many-to-many relationships
|
||||||
|
//! )]
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ### Field-Level Attributes
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! #[georm(id)] // Mark as primary key (required on at least one field)
|
||||||
|
//! #[georm(defaultable)] // Mark as defaultable field (database default/auto-generated)
|
||||||
|
//! #[georm(relation = { /* ... */ })] // Define foreign key relationship
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Performance Characteristics
|
||||||
|
//!
|
||||||
|
//! - **Zero runtime overhead**: All SQL is generated at compile time
|
||||||
|
//! - **No eager loading**: Each relationship method executes a separate query
|
||||||
|
//! - **Prepared statements**: All queries use parameter binding for optimal performance
|
||||||
|
//! - **Database round-trips**: CRUD operations use RETURNING clause to minimize round-trips
|
||||||
|
//! - **No N+1 prevention**: Built-in relationships don't prevent N+1 query patterns
|
||||||
//!
|
//!
|
||||||
//! ## Limitations
|
//! ## Limitations
|
||||||
//! ### Database
|
|
||||||
//!
|
//!
|
||||||
//! For now, Georm is limited to PostgreSQL. Other databases may be supported in
|
//! ### Database Support
|
||||||
//! the future, such as Sqlite or MySQL, but that is not the case yet.
|
|
||||||
//!
|
//!
|
||||||
//! ## Identifiers
|
//! Georm is currently 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
|
//! Identifiers, or primary keys from the point of view of the database, may
|
||||||
//! only be simple types recognized by SQLx. They also cannot be arrays, and
|
//! be simple types recognized by SQLx or composite keys (multiple fields marked
|
||||||
//! optionals are only supported in one-to-one relationships when explicitly
|
//! with `#[georm(id)]`). Single primary keys cannot be arrays, and optionals are
|
||||||
//! marked as nullables.
|
//! only supported in one-to-one relationships when explicitly marked as nullables.
|
||||||
|
//!
|
||||||
|
//! ### Current Limitations
|
||||||
|
//!
|
||||||
|
//! - **Composite key relationships**: Entities with composite primary keys cannot define relationships
|
||||||
|
//! - **Single table per entity**: No table inheritance or polymorphism support
|
||||||
|
//! - **No advanced queries**: No complex WHERE clauses or joins beyond relationships
|
||||||
|
//! - **No eager loading**: Each relationship call is a separate database query
|
||||||
|
//! - **No field-based queries**: No `find_by_{field_name}` methods generated automatically
|
||||||
|
//! - **PostgreSQL only**: No support for other database systems
|
||||||
|
//!
|
||||||
|
//! ## Generated Code
|
||||||
|
//!
|
||||||
|
//! Georm automatically generates:
|
||||||
|
//! - `sqlx::FromRow` implementation (no need to derive manually)
|
||||||
|
//! - Composite ID structs for multi-field primary keys
|
||||||
|
//! - Defaultable companion structs for entities with defaultable fields
|
||||||
|
//! - Relationship methods for accessing related entities
|
||||||
|
//! - All CRUD operations with proper PostgreSQL optimizations
|
||||||
|
|
||||||
pub use georm_macros::Georm;
|
pub use georm_macros::Georm;
|
||||||
|
|
||||||
pub trait Georm<Id> {
|
mod georm;
|
||||||
/// Find all the entities in the database.
|
pub use georm::Georm;
|
||||||
///
|
mod defaultable;
|
||||||
/// # Errors
|
pub use defaultable::Defaultable;
|
||||||
/// 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
|
|
||||||
/// Returns any error Postgres may have encountered
|
|
||||||
fn find(
|
|
||||||
pool: &sqlx::PgPool,
|
|
||||||
id: &Id,
|
|
||||||
) -> impl std::future::Future<Output = sqlx::Result<Option<Self>>> + Send
|
|
||||||
where
|
|
||||||
Self: Sized;
|
|
||||||
|
|
||||||
/// Create the entity in the database.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// Returns any error Postgres may have encountered
|
|
||||||
fn create(
|
|
||||||
&self,
|
|
||||||
pool: &sqlx::PgPool,
|
|
||||||
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
|
||||||
where
|
|
||||||
Self: Sized;
|
|
||||||
|
|
||||||
/// Update an entity with a matching identifier in the database.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// Returns any error Postgres may have encountered
|
|
||||||
fn update(
|
|
||||||
&self,
|
|
||||||
pool: &sqlx::PgPool,
|
|
||||||
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
|
||||||
where
|
|
||||||
Self: Sized;
|
|
||||||
|
|
||||||
/// Update an entity with a matching identifier in the database if
|
|
||||||
/// it exists, create it otherwise.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// Returns any error Postgres may have encountered
|
|
||||||
fn create_or_update(
|
|
||||||
&self,
|
|
||||||
pool: &sqlx::PgPool,
|
|
||||||
) -> impl ::std::future::Future<Output = sqlx::Result<Self>>
|
|
||||||
where
|
|
||||||
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.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
/// Returns the amount of rows affected by the deletion.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// Returns any error Postgres may have encountered
|
|
||||||
fn delete(
|
|
||||||
&self,
|
|
||||||
pool: &sqlx::PgPool,
|
|
||||||
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send;
|
|
||||||
|
|
||||||
/// Delete any entity with the identifier `id`.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
/// Returns the amount of rows affected by the deletion.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// Returns any error Postgres may have encountered
|
|
||||||
fn delete_by_id(
|
|
||||||
pool: &sqlx::PgPool,
|
|
||||||
id: &Id,
|
|
||||||
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send;
|
|
||||||
|
|
||||||
/// Returns the identifier of the entity.
|
|
||||||
fn get_id(&self) -> &Id;
|
|
||||||
}
|
|
||||||
|
|||||||
112
tests/composite_key.rs
Normal file
112
tests/composite_key.rs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
use georm::Georm;
|
||||||
|
|
||||||
|
mod models;
|
||||||
|
use models::{UserRole, UserRoleId};
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("composite_key"))]
|
||||||
|
async fn composite_key_find(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
// This will test the find query generation bug
|
||||||
|
let id = models::UserRoleId {
|
||||||
|
user_id: 1,
|
||||||
|
role_id: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = UserRole::find(&pool, &id).await?;
|
||||||
|
assert!(result.is_some());
|
||||||
|
|
||||||
|
let user_role = result.unwrap();
|
||||||
|
assert_eq!(1, user_role.user_id);
|
||||||
|
assert_eq!(1, user_role.role_id);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn composite_key_get_id() {
|
||||||
|
let user_role = UserRole {
|
||||||
|
user_id: 1,
|
||||||
|
role_id: 1,
|
||||||
|
assigned_at: chrono::Local::now().into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// This will test the get_id implementation bug
|
||||||
|
let id = user_role.get_id();
|
||||||
|
assert_eq!(1, id.user_id);
|
||||||
|
assert_eq!(1, id.role_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("composite_key"))]
|
||||||
|
async fn composite_key_create_or_update(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let new_user_role = UserRole {
|
||||||
|
user_id: 5,
|
||||||
|
role_id: 2,
|
||||||
|
assigned_at: chrono::Local::now().into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// This will test the upsert query generation bug
|
||||||
|
let result = new_user_role.create_or_update(&pool).await?;
|
||||||
|
assert_eq!(5, result.user_id);
|
||||||
|
assert_eq!(2, result.role_id);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("composite_key"))]
|
||||||
|
async fn composite_key_delete(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let id = models::UserRoleId {
|
||||||
|
user_id: 1,
|
||||||
|
role_id: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let rows_affected = UserRole::delete_by_id(&pool, &id).await?;
|
||||||
|
assert_eq!(1, rows_affected);
|
||||||
|
|
||||||
|
// Verify it's deleted
|
||||||
|
let result = UserRole::find(&pool, &id).await?;
|
||||||
|
assert!(result.is_none());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("composite_key"))]
|
||||||
|
async fn composite_key_find_all(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let all_user_roles = UserRole::find_all(&pool).await?;
|
||||||
|
assert_eq!(4, all_user_roles.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("composite_key"))]
|
||||||
|
async fn composite_key_create(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let new_user_role = UserRole {
|
||||||
|
user_id: 10,
|
||||||
|
role_id: 5,
|
||||||
|
assigned_at: chrono::Local::now().into(),
|
||||||
|
};
|
||||||
|
let result = new_user_role.create(&pool).await?;
|
||||||
|
assert_eq!(new_user_role.user_id, result.user_id);
|
||||||
|
assert_eq!(new_user_role.role_id, result.role_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("composite_key"))]
|
||||||
|
async fn composite_key_update(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let mut user_role = UserRole::find(
|
||||||
|
&pool,
|
||||||
|
&UserRoleId {
|
||||||
|
user_id: 1,
|
||||||
|
role_id: 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.unwrap();
|
||||||
|
let now: chrono::DateTime<chrono::Utc> = chrono::Local::now().into();
|
||||||
|
user_role.assigned_at = now;
|
||||||
|
let updated = user_role.update(&pool).await?;
|
||||||
|
assert_eq!(
|
||||||
|
now.timestamp_millis(),
|
||||||
|
updated.assigned_at.timestamp_millis()
|
||||||
|
);
|
||||||
|
assert_eq!(1, updated.user_id);
|
||||||
|
assert_eq!(1, updated.role_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
526
tests/defaultable_struct.rs
Normal file
526
tests/defaultable_struct.rs
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
use georm::Georm;
|
||||||
|
|
||||||
|
// Test struct with defaultable fields using existing table structure
|
||||||
|
#[derive(Georm, Debug)]
|
||||||
|
#[georm(table = "authors")]
|
||||||
|
struct TestAuthor {
|
||||||
|
#[georm(id, defaultable)]
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub biography_id: Option<i32>, // Don't mark Option fields as defaultable
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test struct with only ID defaultable
|
||||||
|
#[derive(Georm)]
|
||||||
|
#[georm(table = "authors")]
|
||||||
|
struct MinimalDefaultable {
|
||||||
|
#[georm(id, defaultable)]
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub biography_id: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test struct with multiple defaultable fields
|
||||||
|
#[derive(Georm)]
|
||||||
|
#[georm(table = "authors")]
|
||||||
|
struct MultiDefaultable {
|
||||||
|
#[georm(id, defaultable)]
|
||||||
|
pub id: i32,
|
||||||
|
#[georm(defaultable)]
|
||||||
|
pub name: String,
|
||||||
|
pub biography_id: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaultable_struct_should_exist() {
|
||||||
|
// This test will compile only if TestAuthorDefault struct exists
|
||||||
|
let _author_default = TestAuthorDefault {
|
||||||
|
id: Some(1), // Should be Option<i32> since ID is defaultable
|
||||||
|
name: "Test Author".to_string(), // Should remain String
|
||||||
|
biography_id: None, // Should remain Option<i32>
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn minimal_defaultable_struct_should_exist() {
|
||||||
|
// MinimalDefaultableDefault should exist because ID is marked as defaultable
|
||||||
|
let _minimal_default = MinimalDefaultableDefault {
|
||||||
|
id: None, // Should be Option<i32>
|
||||||
|
name: "testuser".to_string(), // Should remain String
|
||||||
|
biography_id: None, // Should remain Option<i32>
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaultable_fields_can_be_none() {
|
||||||
|
let _author_default = TestAuthorDefault {
|
||||||
|
id: None, // Can be None since it's defaultable (auto-generated)
|
||||||
|
name: "Test Author".to_string(),
|
||||||
|
biography_id: None, // Can remain None
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_visibility_is_preserved() {
|
||||||
|
let _author_default = TestAuthorDefault {
|
||||||
|
id: Some(1), // pub
|
||||||
|
name: "Test".to_string(), // pub
|
||||||
|
biography_id: Some(1), // pub, Option<i32>
|
||||||
|
};
|
||||||
|
|
||||||
|
// This test ensures field visibility is preserved in generated struct
|
||||||
|
}
|
||||||
|
|
||||||
|
mod defaultable_tests {
|
||||||
|
use super::*;
|
||||||
|
use georm::Defaultable;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||||
|
async fn test_create_entity_from_defaultable_with_id(pool: PgPool) {
|
||||||
|
// Test creating entity from defaultable struct with explicit ID
|
||||||
|
let author_default = TestAuthorDefault {
|
||||||
|
id: Some(999),
|
||||||
|
name: "John Doe".to_string(),
|
||||||
|
biography_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let created_author = author_default.create(&pool).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(created_author.id, 999);
|
||||||
|
assert_eq!(created_author.name, "John Doe");
|
||||||
|
assert_eq!(created_author.biography_id, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||||
|
async fn test_create_entity_from_defaultable_without_id(pool: PgPool) {
|
||||||
|
// Test creating entity from defaultable struct with auto-generated ID
|
||||||
|
let author_default = TestAuthorDefault {
|
||||||
|
id: None, // Let database generate the ID
|
||||||
|
name: "Jane Smith".to_string(),
|
||||||
|
biography_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let created_author = author_default.create(&pool).await.unwrap();
|
||||||
|
|
||||||
|
// ID should be auto-generated (positive value)
|
||||||
|
assert!(created_author.id > 0);
|
||||||
|
assert_eq!(created_author.name, "Jane Smith");
|
||||||
|
assert_eq!(created_author.biography_id, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||||
|
async fn test_create_entity_from_minimal_defaultable(pool: PgPool) {
|
||||||
|
// Test creating entity from minimal defaultable struct
|
||||||
|
let minimal_default = MinimalDefaultableDefault {
|
||||||
|
id: None,
|
||||||
|
name: "Alice Wonder".to_string(),
|
||||||
|
biography_id: Some(1), // Reference existing biography
|
||||||
|
};
|
||||||
|
|
||||||
|
let created_author = minimal_default.create(&pool).await.unwrap();
|
||||||
|
|
||||||
|
assert!(created_author.id > 0);
|
||||||
|
assert_eq!(created_author.name, "Alice Wonder");
|
||||||
|
assert_eq!(created_author.biography_id, Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||||
|
async fn test_create_multiple_entities_from_defaultable(pool: PgPool) {
|
||||||
|
// Test creating multiple entities to ensure ID generation works properly
|
||||||
|
let author1_default = TestAuthorDefault {
|
||||||
|
id: None,
|
||||||
|
name: "Author One".to_string(),
|
||||||
|
biography_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let author2_default = TestAuthorDefault {
|
||||||
|
id: None,
|
||||||
|
name: "Author Two".to_string(),
|
||||||
|
biography_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let created_author1 = author1_default.create(&pool).await.unwrap();
|
||||||
|
let created_author2 = author2_default.create(&pool).await.unwrap();
|
||||||
|
|
||||||
|
// Both should have unique IDs
|
||||||
|
assert!(created_author1.id > 0);
|
||||||
|
assert!(created_author2.id > 0);
|
||||||
|
assert_ne!(created_author1.id, created_author2.id);
|
||||||
|
|
||||||
|
assert_eq!(created_author1.name, "Author One");
|
||||||
|
assert_eq!(created_author2.name, "Author Two");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||||
|
async fn test_multiple_defaultable_fields_all_none(pool: PgPool) {
|
||||||
|
// Test with multiple defaultable fields all set to None
|
||||||
|
let multi_default = MultiDefaultableDefault {
|
||||||
|
id: None,
|
||||||
|
name: None, // This should use database default or be handled gracefully
|
||||||
|
biography_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = multi_default.create(&pool).await;
|
||||||
|
|
||||||
|
// This might fail if database doesn't have a default for name
|
||||||
|
// That's expected behavior - test documents the current behavior
|
||||||
|
match result {
|
||||||
|
Ok(created) => {
|
||||||
|
assert!(created.id > 0);
|
||||||
|
// If successful, name should have some default value
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Expected if no database default for name column
|
||||||
|
assert!(e.to_string().contains("null") || e.to_string().contains("NOT NULL"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||||
|
async fn test_multiple_defaultable_fields_mixed(pool: PgPool) {
|
||||||
|
// Test with some defaultable fields set and others None
|
||||||
|
let multi_default = MultiDefaultableDefault {
|
||||||
|
id: None, // Let database generate
|
||||||
|
name: Some("Explicit Name".to_string()), // Explicit value
|
||||||
|
biography_id: Some(1), // Reference existing biography
|
||||||
|
};
|
||||||
|
|
||||||
|
let created = multi_default.create(&pool).await.unwrap();
|
||||||
|
|
||||||
|
assert!(created.id > 0);
|
||||||
|
assert_eq!(created.name, "Explicit Name");
|
||||||
|
assert_eq!(created.biography_id, Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||||
|
async fn test_multiple_defaultable_fields_all_explicit(pool: PgPool) {
|
||||||
|
// Test with all defaultable fields having explicit values
|
||||||
|
let multi_default = MultiDefaultableDefault {
|
||||||
|
id: Some(888),
|
||||||
|
name: Some("All Explicit".to_string()),
|
||||||
|
biography_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let created = multi_default.create(&pool).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(created.id, 888);
|
||||||
|
assert_eq!(created.name, "All Explicit");
|
||||||
|
assert_eq!(created.biography_id, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||||
|
async fn test_error_duplicate_id(pool: PgPool) {
|
||||||
|
// Test error handling for duplicate ID constraint violation
|
||||||
|
let author1 = TestAuthorDefault {
|
||||||
|
id: Some(777),
|
||||||
|
name: "First Author".to_string(),
|
||||||
|
biography_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let author2 = TestAuthorDefault {
|
||||||
|
id: Some(777), // Same ID - should cause constraint violation
|
||||||
|
name: "Second Author".to_string(),
|
||||||
|
biography_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// First creation should succeed
|
||||||
|
let _created1 = author1.create(&pool).await.unwrap();
|
||||||
|
|
||||||
|
// Second creation should fail due to duplicate key
|
||||||
|
let result2 = author2.create(&pool).await;
|
||||||
|
assert!(result2.is_err());
|
||||||
|
|
||||||
|
let error = result2.unwrap_err();
|
||||||
|
let error_str = error.to_string();
|
||||||
|
assert!(
|
||||||
|
error_str.contains("duplicate")
|
||||||
|
|| error_str.contains("unique")
|
||||||
|
|| error_str.contains("UNIQUE")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||||
|
async fn test_error_invalid_foreign_key(pool: PgPool) {
|
||||||
|
// Test error handling for invalid foreign key reference
|
||||||
|
let author_default = TestAuthorDefault {
|
||||||
|
id: None,
|
||||||
|
name: "Test Author".to_string(),
|
||||||
|
biography_id: Some(99999), // Non-existent biography ID
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = author_default.create(&pool).await;
|
||||||
|
|
||||||
|
// This should fail if there's a foreign key constraint
|
||||||
|
// If no constraint exists, it will succeed (documents current behavior)
|
||||||
|
match result {
|
||||||
|
Ok(created) => {
|
||||||
|
// No foreign key constraint - this is valid behavior
|
||||||
|
assert!(created.id > 0);
|
||||||
|
assert_eq!(created.biography_id, Some(99999));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Foreign key constraint violation
|
||||||
|
let error_str = e.to_string();
|
||||||
|
assert!(
|
||||||
|
error_str.contains("foreign")
|
||||||
|
|| error_str.contains("constraint")
|
||||||
|
|| error_str.contains("violates")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||||
|
async fn test_error_connection_handling(pool: PgPool) {
|
||||||
|
// Test behavior with a closed/invalid pool
|
||||||
|
// Note: This is tricky to test without actually closing the pool
|
||||||
|
// Instead, we test with extremely long string that might cause issues
|
||||||
|
let author_default = TestAuthorDefault {
|
||||||
|
id: None,
|
||||||
|
name: "A".repeat(10000), // Very long string - might hit database limits
|
||||||
|
biography_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = author_default.create(&pool).await;
|
||||||
|
|
||||||
|
// This documents current behavior - might succeed or fail depending on DB limits
|
||||||
|
match result {
|
||||||
|
Ok(created) => {
|
||||||
|
assert!(created.id > 0);
|
||||||
|
assert_eq!(created.name.len(), 10000);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Some kind of database limit hit
|
||||||
|
assert!(!e.to_string().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod sql_validation_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||||
|
async fn test_sql_generation_no_defaultable_fields(pool: PgPool) {
|
||||||
|
// Test SQL generation when no defaultable fields have None values
|
||||||
|
let author_default = TestAuthorDefault {
|
||||||
|
id: Some(100),
|
||||||
|
name: "Test Name".to_string(),
|
||||||
|
biography_id: Some(1),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Capture the SQL by creating a custom query that logs the generated SQL
|
||||||
|
// Since we can't directly inspect the generated SQL from the macro,
|
||||||
|
// we test the behavior indirectly by ensuring all fields are included
|
||||||
|
let created = author_default.create(&pool).await.unwrap();
|
||||||
|
|
||||||
|
// Verify all fields were properly inserted
|
||||||
|
assert_eq!(created.id, 100);
|
||||||
|
assert_eq!(created.name, "Test Name");
|
||||||
|
assert_eq!(created.biography_id, Some(1));
|
||||||
|
|
||||||
|
// Verify the record exists in database with all expected values
|
||||||
|
let found: TestAuthor = sqlx::query_as!(
|
||||||
|
TestAuthor,
|
||||||
|
"SELECT id, name, biography_id FROM authors WHERE id = $1",
|
||||||
|
100
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(found.id, 100);
|
||||||
|
assert_eq!(found.name, "Test Name");
|
||||||
|
assert_eq!(found.biography_id, Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||||
|
async fn test_sql_generation_with_defaultable_none(pool: PgPool) {
|
||||||
|
// Test SQL generation when defaultable fields are None (should be excluded)
|
||||||
|
let author_default = TestAuthorDefault {
|
||||||
|
id: None, // This should be excluded from INSERT
|
||||||
|
name: "Auto ID Test".to_string(),
|
||||||
|
biography_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let created = author_default.create(&pool).await.unwrap();
|
||||||
|
|
||||||
|
// ID should be auto-generated (not explicitly set)
|
||||||
|
assert!(created.id > 0);
|
||||||
|
assert_eq!(created.name, "Auto ID Test");
|
||||||
|
assert_eq!(created.biography_id, None);
|
||||||
|
|
||||||
|
// Verify the generated ID is actually from database auto-increment
|
||||||
|
// by checking it's different from any manually set values
|
||||||
|
assert_ne!(created.id, 100); // Different from previous test
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||||
|
async fn test_sql_generation_mixed_defaultable_fields(pool: PgPool) {
|
||||||
|
// Test SQL with multiple defaultable fields where some are None
|
||||||
|
let multi_default = MultiDefaultableDefault {
|
||||||
|
id: None, // Should be excluded
|
||||||
|
name: Some("Explicit Name".to_string()), // Should be included
|
||||||
|
biography_id: Some(1), // Should be included
|
||||||
|
};
|
||||||
|
|
||||||
|
let created = multi_default.create(&pool).await.unwrap();
|
||||||
|
|
||||||
|
// Verify the mixed field inclusion worked correctly
|
||||||
|
assert!(created.id > 0); // Auto-generated
|
||||||
|
assert_eq!(created.name, "Explicit Name"); // Explicitly set
|
||||||
|
assert_eq!(created.biography_id, Some(1)); // Explicitly set
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||||
|
async fn test_placeholder_ordering_consistency(pool: PgPool) {
|
||||||
|
// Test that placeholders are ordered correctly when fields are dynamically included
|
||||||
|
// Create multiple records with different field combinations
|
||||||
|
|
||||||
|
// First: only non-defaultable fields
|
||||||
|
let record1 = MultiDefaultableDefault {
|
||||||
|
id: None,
|
||||||
|
name: None,
|
||||||
|
biography_id: Some(1),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Second: all fields explicit
|
||||||
|
let record2 = MultiDefaultableDefault {
|
||||||
|
id: Some(201),
|
||||||
|
name: Some("Full Record".to_string()),
|
||||||
|
biography_id: Some(1),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Third: mixed combination
|
||||||
|
let record3 = MultiDefaultableDefault {
|
||||||
|
id: None,
|
||||||
|
name: Some("Mixed Record".to_string()),
|
||||||
|
biography_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// All should succeed with correct placeholder ordering
|
||||||
|
let result1 = record1.create(&pool).await;
|
||||||
|
let result2 = record2.create(&pool).await;
|
||||||
|
let result3 = record3.create(&pool).await;
|
||||||
|
|
||||||
|
// Handle record1 based on whether name has a database default
|
||||||
|
match result1 {
|
||||||
|
Ok(created1) => {
|
||||||
|
assert!(created1.id > 0);
|
||||||
|
assert_eq!(created1.biography_id, Some(1));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// Expected if name field has no database default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let created2 = result2.unwrap();
|
||||||
|
assert_eq!(created2.id, 201);
|
||||||
|
assert_eq!(created2.name, "Full Record");
|
||||||
|
assert_eq!(created2.biography_id, Some(1));
|
||||||
|
|
||||||
|
let created3 = result3.unwrap();
|
||||||
|
assert!(created3.id > 0);
|
||||||
|
assert_eq!(created3.name, "Mixed Record");
|
||||||
|
assert_eq!(created3.biography_id, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||||
|
async fn test_field_inclusion_logic(pool: PgPool) {
|
||||||
|
// Test that the field inclusion logic works correctly
|
||||||
|
// by creating records that should result in different SQL queries
|
||||||
|
|
||||||
|
let minimal = TestAuthorDefault {
|
||||||
|
id: None,
|
||||||
|
name: "Minimal".to_string(),
|
||||||
|
biography_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let maximal = TestAuthorDefault {
|
||||||
|
id: Some(300),
|
||||||
|
name: "Maximal".to_string(),
|
||||||
|
biography_id: Some(1),
|
||||||
|
};
|
||||||
|
|
||||||
|
let created_minimal = minimal.create(&pool).await.unwrap();
|
||||||
|
let created_maximal = maximal.create(&pool).await.unwrap();
|
||||||
|
|
||||||
|
// Minimal should have auto-generated ID, explicit name, NULL biography_id
|
||||||
|
assert!(created_minimal.id > 0);
|
||||||
|
assert_eq!(created_minimal.name, "Minimal");
|
||||||
|
assert_eq!(created_minimal.biography_id, None);
|
||||||
|
|
||||||
|
// Maximal should have all explicit values
|
||||||
|
assert_eq!(created_maximal.id, 300);
|
||||||
|
assert_eq!(created_maximal.name, "Maximal");
|
||||||
|
assert_eq!(created_maximal.biography_id, Some(1));
|
||||||
|
|
||||||
|
// Verify they are different records
|
||||||
|
assert_ne!(created_minimal.id, created_maximal.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||||
|
async fn test_returning_clause_functionality(pool: PgPool) {
|
||||||
|
// Test that the RETURNING * clause works correctly with dynamic fields
|
||||||
|
let author_default = TestAuthorDefault {
|
||||||
|
id: None, // Should be populated by RETURNING clause
|
||||||
|
name: "Return Test".to_string(),
|
||||||
|
biography_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let created = author_default.create(&pool).await.unwrap();
|
||||||
|
|
||||||
|
// Verify RETURNING clause populated all fields correctly
|
||||||
|
assert!(created.id > 0); // Database-generated ID returned
|
||||||
|
assert_eq!(created.name, "Return Test"); // Explicit value returned
|
||||||
|
assert_eq!(created.biography_id, None); // NULL value returned correctly
|
||||||
|
|
||||||
|
// Double-check by querying the database directly
|
||||||
|
let verified: TestAuthor = sqlx::query_as!(
|
||||||
|
TestAuthor,
|
||||||
|
"SELECT id, name, biography_id FROM authors WHERE id = $1",
|
||||||
|
created.id
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(verified.id, created.id);
|
||||||
|
assert_eq!(verified.name, created.name);
|
||||||
|
assert_eq!(verified.biography_id, created.biography_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
|
||||||
|
async fn test_query_parameter_binding_order(pool: PgPool) {
|
||||||
|
// Test that query parameters are bound in the correct order
|
||||||
|
// This is critical for the dynamic SQL generation
|
||||||
|
|
||||||
|
// Create a record where the parameter order matters
|
||||||
|
let test_record = MultiDefaultableDefault {
|
||||||
|
id: Some(400), // This should be bound first (if included)
|
||||||
|
name: Some("Param Order Test".to_string()), // This should be bound second (if included)
|
||||||
|
biography_id: Some(1), // This should be bound last
|
||||||
|
};
|
||||||
|
|
||||||
|
let created = test_record.create(&pool).await.unwrap();
|
||||||
|
|
||||||
|
// Verify all parameters were bound correctly
|
||||||
|
assert_eq!(created.id, 400);
|
||||||
|
assert_eq!(created.name, "Param Order Test");
|
||||||
|
assert_eq!(created.biography_id, Some(1));
|
||||||
|
|
||||||
|
// Test with different parameter inclusion order
|
||||||
|
let test_record2 = MultiDefaultableDefault {
|
||||||
|
id: None, // Excluded - should not affect parameter order
|
||||||
|
name: Some("No ID Test".to_string()), // Should be bound first now
|
||||||
|
biography_id: Some(1), // Should be bound second now
|
||||||
|
};
|
||||||
|
|
||||||
|
let created2 = test_record2.create(&pool).await.unwrap();
|
||||||
|
|
||||||
|
assert!(created2.id > 0); // Auto-generated
|
||||||
|
assert_eq!(created2.name, "No ID Test");
|
||||||
|
assert_eq!(created2.biography_id, Some(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
tests/fixtures/composite_key.sql
vendored
Normal file
6
tests/fixtures/composite_key.sql
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
INSERT INTO UserRoles (user_id, role_id, assigned_at)
|
||||||
|
VALUES
|
||||||
|
(1, 1, '2024-01-01 10:00:00+00:00'),
|
||||||
|
(1, 2, '2024-01-02 11:00:00+00:00'),
|
||||||
|
(2, 1, '2024-01-03 12:00:00+00:00'),
|
||||||
|
(3, 3, '2024-01-04 13:00:00+00:00');
|
||||||
3
tests/fixtures/simple_struct.sql
vendored
3
tests/fixtures/simple_struct.sql
vendored
@@ -1,6 +1,7 @@
|
|||||||
INSERT INTO biographies (content)
|
INSERT INTO biographies (content)
|
||||||
VALUES ('Some text'),
|
VALUES ('Some text'),
|
||||||
('Some other text');
|
('Some other text'),
|
||||||
|
('Biography for no one');
|
||||||
|
|
||||||
INSERT INTO authors (name, biography_id)
|
INSERT INTO authors (name, biography_id)
|
||||||
VALUES ('J.R.R. Tolkien', 2),
|
VALUES ('J.R.R. Tolkien', 2),
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
use georm::Georm;
|
use georm::Georm;
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq, Default)]
|
#[derive(Debug, Georm, PartialEq, Eq, Default)]
|
||||||
#[georm(table = "biographies")]
|
#[georm(
|
||||||
|
table = "biographies",
|
||||||
|
one_to_one = [{
|
||||||
|
name = "author", remote_id = "biography_id", table = "authors", entity = Author
|
||||||
|
}]
|
||||||
|
)]
|
||||||
pub struct Biography {
|
pub struct Biography {
|
||||||
#[georm(id)]
|
#[georm(id)]
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq, Default)]
|
#[derive(Debug, Georm, PartialEq, Eq, Default)]
|
||||||
#[georm(table = "authors")]
|
#[georm(table = "authors")]
|
||||||
pub struct Author {
|
pub struct Author {
|
||||||
#[georm(id)]
|
#[georm(id)]
|
||||||
@@ -30,7 +35,7 @@ impl Ord for Author {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq, Default)]
|
#[derive(Debug, Georm, PartialEq, Eq, Default)]
|
||||||
#[georm(
|
#[georm(
|
||||||
table = "books",
|
table = "books",
|
||||||
one_to_many = [
|
one_to_many = [
|
||||||
@@ -63,7 +68,7 @@ impl Ord for Book {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq)]
|
#[derive(Debug, Georm, PartialEq, Eq)]
|
||||||
#[georm(table = "reviews")]
|
#[georm(table = "reviews")]
|
||||||
pub struct Review {
|
pub struct Review {
|
||||||
#[georm(id)]
|
#[georm(id)]
|
||||||
@@ -73,7 +78,7 @@ pub struct Review {
|
|||||||
pub review: String,
|
pub review: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq)]
|
#[derive(Debug, Georm, PartialEq, Eq)]
|
||||||
#[georm(
|
#[georm(
|
||||||
table = "genres",
|
table = "genres",
|
||||||
many_to_many = [{
|
many_to_many = [{
|
||||||
@@ -89,3 +94,14 @@ pub struct Genre {
|
|||||||
id: i32,
|
id: i32,
|
||||||
name: String,
|
name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Georm, PartialEq, Eq, Default)]
|
||||||
|
#[georm(table = "UserRoles")]
|
||||||
|
pub struct UserRole {
|
||||||
|
#[georm(id)]
|
||||||
|
pub user_id: i32,
|
||||||
|
#[georm(id)]
|
||||||
|
pub role_id: i32,
|
||||||
|
#[georm(defaultable)]
|
||||||
|
pub assigned_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,3 +53,24 @@ async fn books_are_found_despite_nonstandard_id_name(pool: sqlx::PgPool) -> sqlx
|
|||||||
assert_eq!(tolkien, book.get_author(&pool).await?);
|
assert_eq!(tolkien, book.get_author(&pool).await?);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("simple_struct"))]
|
||||||
|
async fn biographies_should_find_remote_o2o_author(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let london = Author::find(&pool, &3).await?.unwrap();
|
||||||
|
let london_biography = Biography::find(&pool, &1).await?.unwrap();
|
||||||
|
let result = london_biography.get_author(&pool).await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let result = result.unwrap();
|
||||||
|
assert!(result.is_some());
|
||||||
|
let result = result.unwrap();
|
||||||
|
assert_eq!(london, result);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("simple_struct"))]
|
||||||
|
async fn biographies_may_not_have_corresponding_author(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||||
|
let biography = Biography::find(&pool, &3).await?.unwrap();
|
||||||
|
let result = biography.get_author(&pool).await?;
|
||||||
|
assert!(result.is_none());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ async fn create_fails_if_already_exists(pool: sqlx::PgPool) -> sqlx::Result<()>
|
|||||||
let result = author.create(&pool).await;
|
let result = author.create(&pool).await;
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
let error = result.err().unwrap();
|
let error = result.err().unwrap();
|
||||||
assert_eq!("error returned from database: duplicate key value violates unique constraint \"authors_pkey\"", error.to_string());
|
assert_eq!(
|
||||||
|
"error returned from database: duplicate key value violates unique constraint \"authors_pkey\"",
|
||||||
|
error.to_string()
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,12 +143,12 @@ async fn delete_by_id_should_delete_only_one_entry(pool: sqlx::PgPool) -> sqlx::
|
|||||||
let id = 2;
|
let id = 2;
|
||||||
let all_authors = Author::find_all(&pool).await?;
|
let all_authors = Author::find_all(&pool).await?;
|
||||||
assert_eq!(3, all_authors.len());
|
assert_eq!(3, all_authors.len());
|
||||||
assert!(all_authors.iter().any(|author| author.get_id() == &id));
|
assert!(all_authors.iter().any(|author| author.get_id() == id));
|
||||||
let result = Author::delete_by_id(&pool, &id).await?;
|
let result = Author::delete_by_id(&pool, &id).await?;
|
||||||
assert_eq!(1, result);
|
assert_eq!(1, result);
|
||||||
let all_authors = Author::find_all(&pool).await?;
|
let all_authors = Author::find_all(&pool).await?;
|
||||||
assert_eq!(2, all_authors.len());
|
assert_eq!(2, all_authors.len());
|
||||||
assert!(all_authors.iter().all(|author| author.get_id() != &id));
|
assert!(all_authors.iter().all(|author| author.get_id() != id));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user