mirror of
https://github.com/Phundrak/georm.git
synced 2025-12-15 09:11:53 +01:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d8a1b1917 | |||
| 49c7d86102 | |||
| 3307aa679d | |||
|
545dfa066d
|
|||
|
8468c3cd61
|
|||
|
13c7a413d7
|
|||
|
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
|
||||
dotenv_if_exists
|
||||
export DIRENV_WARN_TIMEOUT=20s
|
||||
|
||||
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:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v27
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
uses: cachix/install-nix-action@v31
|
||||
- name: Install devenv
|
||||
run: nix profile install nixpkgs#devenv
|
||||
- name: Migrate database
|
||||
run: nix develop --command -- just migrate
|
||||
run: devenv shell just migrate
|
||||
- name: Formatting check
|
||||
run: nix develop --command -- just format-check
|
||||
run: devenv shell just format-check
|
||||
- name: Lint
|
||||
run: nix develop --command -- just lint
|
||||
run: devenv shell just lint
|
||||
- name: Audit
|
||||
run: nix develop --command -- just audit
|
||||
run: devenv shell just audit
|
||||
- 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
|
||||
/coverage
|
||||
/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
|
||||
._*
|
||||
|
||||
1076
Cargo.lock
generated
1076
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
22
Cargo.toml
22
Cargo.toml
@@ -1,9 +1,13 @@
|
||||
[workspace]
|
||||
members = [".", "georm-macros"]
|
||||
members = [
|
||||
".",
|
||||
"georm-macros",
|
||||
"examples/postgres/*"
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.1"
|
||||
edition = "2021"
|
||||
version = "0.2.1"
|
||||
edition = "2024"
|
||||
authors = ["Lucien Cartier-Tilet <lucien@phundrak.com>"]
|
||||
homepage = "https://github.com/Phundrak/georm"
|
||||
repository = "https://github.com/Phundrak/georm"
|
||||
@@ -23,20 +27,26 @@ repository.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[workspace.dependencies]
|
||||
georm-macros = { version = "=0.1.1", path = "georm-macros" }
|
||||
georm-macros = { version = "=0.2.1", path = "georm-macros" }
|
||||
|
||||
[workspace.dependencies.sqlx]
|
||||
version = "0.8.3"
|
||||
version = "0.8.6"
|
||||
default-features = false
|
||||
features = ["postgres", "runtime-tokio", "macros", "migrate"]
|
||||
features = ["postgres", "runtime-tokio", "macros", "migrate", "bigdecimal"]
|
||||
|
||||
[dependencies]
|
||||
sqlx = { workspace = true }
|
||||
georm-macros = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
rand = "0.9"
|
||||
|
||||
[dev-dependencies.sqlx]
|
||||
version = "0.8.6"
|
||||
default-features = false
|
||||
features = ["postgres", "runtime-tokio", "macros", "migrate", "chrono"]
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
|
||||
887
README.md
887
README.md
@@ -1,116 +1,377 @@
|
||||
<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>
|
||||
<div align="center">
|
||||
<strong>
|
||||
A simple, opinionated SQLx ORM for PostgreSQL
|
||||
A simple, type-safe SQLx ORM for PostgreSQL
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<!-- Github Actions -->
|
||||
<a href="https://github.com/phundrak/georm/actions/workflows/ci.yaml?query=branch%3Amain">
|
||||
<img src="https://img.shields.io/github/actions/workflow/status/phundrak/georm/ci.yaml?branch=main&style=flat-square" alt="actions status" /></a>
|
||||
<img src="https://img.shields.io/github/actions/workflow/status/phundrak/georm/ci.yaml?branch=main&style=flat-square" alt="actions status" />
|
||||
</a>
|
||||
<!-- Version -->
|
||||
<a href="https://crates.io/crates/georm">
|
||||
<img src="https://img.shields.io/crates/v/georm.svg?style=flat-square"
|
||||
alt="Crates.io version" /></a>
|
||||
<!-- Discord -->
|
||||
<img src="https://img.shields.io/crates/v/georm.svg?style=flat-square" alt="Crates.io version" />
|
||||
</a>
|
||||
<!-- Docs -->
|
||||
<a href="https://docs.rs/georm">
|
||||
<img src="https://img.shields.io/badge/docs-latest-blue.svg?style=flat-square" alt="docs.rs docs" /></a>
|
||||
<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 align="center">
|
||||
<h4>What is Georm?</h4>
|
||||
</div>
|
||||
## Overview
|
||||
|
||||
Georm is a quite simple ORM built around
|
||||
[SQLx](https://crates.io/crates/sqlx) that gives access to a few
|
||||
useful functions when interacting with a database, implementing
|
||||
automatically the most basic SQL interactions you’re tired of writing.
|
||||
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.
|
||||
|
||||
<div align="center">
|
||||
<h4>Why is Georm?</h4>
|
||||
</div>
|
||||
### Key Features
|
||||
|
||||
I wanted an ORM that’s easy and straightforward to use. I am aware
|
||||
some other projects exist, such as
|
||||
[SeaORM](https://www.sea-ql.org/SeaORM/), but they generally don’t fit
|
||||
my needs and/or my wants of a simple interface. I ended up writing the
|
||||
ORM I wanted to use.
|
||||
- **Type Safety**: Compile-time verified SQL queries using SQLx macros
|
||||
- **Flexible Executor**: Works with both `PgPool` and `Transaction` for atomic operations.
|
||||
- **Zero Runtime Cost**: No reflection or runtime query building
|
||||
- **Simple API**: Intuitive derive macros for common operations
|
||||
- **Relationship Support**: One-to-one, one-to-many, and many-to-many relationships
|
||||
- **Composite Primary Keys**: Support for multi-field primary keys
|
||||
- **Defaultable Fields**: Easy entity creation with database defaults and auto-generated values
|
||||
- **PostgreSQL Native**: Optimized for PostgreSQL features and data types
|
||||
|
||||
<div align="center">
|
||||
<h4>How is Georm?</h4>
|
||||
</div>
|
||||
## Quick Start
|
||||
|
||||
I use it in a few projects, and I’m quite happy with it right now. But
|
||||
of course, I’m open to constructive criticism and suggestions!
|
||||
### Installation
|
||||
|
||||
<div align="center">
|
||||
<h4>How can I use it?</h4>
|
||||
</div>
|
||||
Add Georm and SQLx to your `Cargo.toml`:
|
||||
|
||||
Georm works with SQLx, but does not re-export it itself. To get
|
||||
started, install both Georm and SQLx in your Rust project:
|
||||
|
||||
```sh
|
||||
cargo add sqlx --features postgres,macros # and any other feature you might want
|
||||
cargo add georm
|
||||
```toml
|
||||
[dependencies]
|
||||
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "macros"] }
|
||||
georm = "0.1"
|
||||
```
|
||||
|
||||
As Georm relies heavily on the macro
|
||||
[`query_as!`](https://docs.rs/sqlx/latest/sqlx/macro.query_as.html),
|
||||
the `macros` feature is not optional. Declare your tables in your
|
||||
Postgres database (you may want to use SQLx’s `migrate` feature for
|
||||
this), and then declare their equivalent in Rust.
|
||||
### Basic Usage
|
||||
|
||||
1. **Define your database schema**:
|
||||
|
||||
```sql
|
||||
CREATE TABLE biographies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
content TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE authors (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
biography_id INT,
|
||||
FOREIGN KEY (biography_id) REFERENCES biographies(id)
|
||||
email VARCHAR(255) UNIQUE NOT NULL
|
||||
);
|
||||
|
||||
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
|
||||
pub struct Author {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
}
|
||||
```
|
||||
2. **Define your Rust entities**:
|
||||
|
||||
To link a struct to a table in your database, derive the
|
||||
`sqlx::FromRow` and the `georm::Georm` traits.
|
||||
```rust
|
||||
#[derive(sqlx::FromRow, Georm)]
|
||||
pub struct Author {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
}
|
||||
```
|
||||
use georm::Georm;
|
||||
|
||||
Now, indicate with the `georm` proc-macro which table they refer to.
|
||||
```rust
|
||||
#[derive(sqlx::FromRow, Georm)]
|
||||
#[derive(Georm)]
|
||||
#[georm(table = "authors")]
|
||||
pub struct Author {
|
||||
#[georm(id)]
|
||||
pub id: i32,
|
||||
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
|
||||
is the primary key in your database.
|
||||
3. **Use the generated methods**:
|
||||
|
||||
```rust
|
||||
#[derive(sqlx::FromRow, Georm)]
|
||||
#[georm(table = "authors")]
|
||||
use sqlx::PgPool;
|
||||
|
||||
async fn example(pool: &PgPool) -> sqlx::Result<()> {
|
||||
// Start a transaction
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// 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(&mut *tx).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(&mut *tx).await?;
|
||||
|
||||
// Commit the transaction
|
||||
tx.commit().await?;
|
||||
|
||||
// Find all posts (using the pool directly)
|
||||
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 and Generated Fields
|
||||
|
||||
Georm provides three attributes for handling fields with database-managed values:
|
||||
|
||||
#### `#[georm(defaultable)]` - Optional Override Fields
|
||||
|
||||
For fields with database defaults that can be manually overridden:
|
||||
|
||||
```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,
|
||||
}
|
||||
```
|
||||
|
||||
#### `#[georm(generated)]` - Generated by Default
|
||||
|
||||
For PostgreSQL `GENERATED BY DEFAULT` columns that can be overridden but are typically auto-generated:
|
||||
|
||||
```rust
|
||||
#[derive(Georm)]
|
||||
#[georm(table = "products")]
|
||||
pub struct Product {
|
||||
#[georm(id, generated_always)]
|
||||
pub id: i32, // GENERATED ALWAYS AS IDENTITY
|
||||
#[georm(generated)]
|
||||
pub sku_number: i32, // GENERATED BY DEFAULT AS IDENTITY
|
||||
pub name: String,
|
||||
pub price: sqlx::types::BigDecimal,
|
||||
}
|
||||
```
|
||||
|
||||
#### `#[georm(generated_always)]` - Always Generated
|
||||
|
||||
For PostgreSQL `GENERATED ALWAYS` columns that are strictly managed by the database:
|
||||
|
||||
```rust
|
||||
#[derive(Georm)]
|
||||
#[georm(table = "products")]
|
||||
pub struct Product {
|
||||
#[georm(id, generated_always)]
|
||||
pub id: i32, // GENERATED ALWAYS AS IDENTITY
|
||||
pub name: String,
|
||||
pub price: sqlx::types::BigDecimal,
|
||||
pub discount_percent: i32,
|
||||
#[georm(generated_always)]
|
||||
pub final_price: Option<sqlx::types::BigDecimal>, // GENERATED ALWAYS AS (expression) STORED
|
||||
}
|
||||
```
|
||||
|
||||
#### Generated Structs and Behavior
|
||||
|
||||
Both `defaultable` and `generated` fields create a companion `<Entity>Default` struct:
|
||||
|
||||
```rust
|
||||
use georm::Defaultable;
|
||||
|
||||
let product_default = ProductDefault {
|
||||
name: "Laptop".to_string(),
|
||||
price: BigDecimal::from(999),
|
||||
discount_percent: 10,
|
||||
sku_number: None, // Let database auto-generate
|
||||
// Note: generated_always fields are excluded from this struct
|
||||
};
|
||||
|
||||
let created_product = product_default.create(pool).await?;
|
||||
```
|
||||
|
||||
#### Key Differences
|
||||
|
||||
| Attribute | INSERT Behavior | UPDATE Behavior | Use Case |
|
||||
|--------------------|------------------------------------|-----------------|----------------------------------------------|
|
||||
| `defaultable` | Optional (can override defaults) | Included | Fields with database defaults |
|
||||
| `generated` | Optional (can override generation) | Included | `GENERATED BY DEFAULT` columns |
|
||||
| `generated_always` | **Excluded** (always generated) | **Excluded** | `GENERATED ALWAYS` columns, computed columns |
|
||||
|
||||
#### Important Notes
|
||||
|
||||
- **`generated_always` fields are completely excluded** from INSERT and UPDATE statements to prevent database errors
|
||||
- **`generated` and `generated_always` cannot be used together** on the same field
|
||||
- **`generated` fields behave like `defaultable` fields** but are semantically distinct for future enhancements
|
||||
- **Option types cannot be marked as `defaultable`** to prevent `Option<Option<T>>` situations
|
||||
|
||||
### 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 {
|
||||
#[georm(id)]
|
||||
pub id: i32,
|
||||
@@ -118,38 +379,482 @@ pub struct Author {
|
||||
}
|
||||
```
|
||||
|
||||
Congratulations, your struct `Author` now has access to all the
|
||||
functions described in the `Georm` trait!
|
||||
**Generated methods**:
|
||||
- `author.get_posts(pool).await? -> Vec<Post>`
|
||||
- `author.get_comments(pool).await? -> Vec<Comment>`
|
||||
|
||||
<div align="center">
|
||||
<h4>Entity relationship</h4>
|
||||
</div>
|
||||
##### Many-to-Many Relationships
|
||||
|
||||
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
|
||||
#[derive(sqlx::FromRow, Georm)]
|
||||
#[derive(Georm)]
|
||||
#[georm(
|
||||
table = "books",
|
||||
one_to_many = [
|
||||
{ name = "reviews", remote_id = "book_id", table = "reviews", entity = Review }
|
||||
],
|
||||
many_to_many = [{
|
||||
name = "genres",
|
||||
table = "genres",
|
||||
entity = Genre,
|
||||
link = { table = "book_genres", from = "book_id", to = "genre_id" }
|
||||
entity = Genre, // Related entity type
|
||||
name = "genres", // Method name (generates get_genres)
|
||||
table = "genres", // Related table name
|
||||
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 {
|
||||
#[georm(id)]
|
||||
ident: i32,
|
||||
title: String,
|
||||
#[georm(relation = {entity = Author, table = "authors", name = "author"})]
|
||||
author_id: i32,
|
||||
pub id: i32,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[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
|
||||
documentation](https://docs.rs/georm/).
|
||||
**Generated methods**:
|
||||
- `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. All database operations now accept any `sqlx`-compatible executor, which can be a connection pool (`&PgPool`) or a transaction (`&mut Transaction<'_, Postgres>`).
|
||||
|
||||
```rust
|
||||
async fn example(pool: &PgPool, post_id: i32) -> sqlx::Result<()> {
|
||||
// Operations on the pool
|
||||
let all_posts = Post::find_all(pool).await?;
|
||||
|
||||
// Operations within a transaction
|
||||
let mut tx = pool.begin().await?;
|
||||
let post = Post::find(&mut *tx, &post_id).await?;
|
||||
if let Some(post) = post {
|
||||
post.delete(&mut *tx).await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
The available methods are:
|
||||
|
||||
```rust
|
||||
// Query operations
|
||||
Post::find_all(executor).await?;
|
||||
Post::find(executor, &post_id).await?;
|
||||
|
||||
// Mutation operations
|
||||
post.create(executor).await?;
|
||||
post.update(executor).await?;
|
||||
post.upsert(executor).await?;
|
||||
post.delete(executor).await?;
|
||||
Post::delete_by_id(executor, &post_id).await?;
|
||||
|
||||
// Utility
|
||||
post.get_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(generated)] // Mark as generated by default field
|
||||
#[georm(generated_always)] // Mark as always generated 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 | ✅ | ✅ | ⚠️[^1] |
|
||||
| Learning curve | Low | Medium | High |
|
||||
| Macro simplicity | ✅ | ❌ | ❌ |
|
||||
| Advanced queries | ❌ | ✅ | ✅ |
|
||||
|
||||
[^1]: Requires `diesel-async`
|
||||
|
||||
## Roadmap
|
||||
|
||||
### High Priority
|
||||
- **Simplified Relationship Syntax**: Remove redundant table/remote_id specifications by inferring them from target entity metadata
|
||||
- **Multi-Database Support**: MySQL and SQLite support with feature flags
|
||||
|
||||
### Medium Priority
|
||||
- **Composite Key Relationships**: Add relationship support (one-to-one, one-to-many, many-to-many) for entities with composite primary keys
|
||||
- **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
|
||||
- **Automatic Table Name Inference**: Infer table names from struct names (PascalCase → snake_case plural), eliminating the need for explicit `#[georm(table = "...")]` attributes
|
||||
- **Alternative Attribute Syntax**: Introduce path-based attribute syntax as sugar (e.g., `#[table("users")]`, `#[id]`) while maintaining backward compatibility
|
||||
|
||||
### Lower Priority
|
||||
- **Migration Support**: Schema generation and evolution utilities
|
||||
- **Soft Delete**: Optional soft delete with `deleted_at` timestamps
|
||||
- **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,135 @@
|
||||
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 mut tx = pool.begin().await?;
|
||||
let user = User::get_user_by_username_or_select(username.as_deref(), prompt, &mut *tx).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(&mut *tx).await?;
|
||||
tx.commit().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 mut tx = pool.begin().await?;
|
||||
let comment = match id {
|
||||
Some(id) => Comment::find(&mut *tx, &id)
|
||||
.await
|
||||
.map_err(UserInputError::DatabaseError)?
|
||||
.ok_or(UserInputError::CommentDoesNotExist)?,
|
||||
None => Comment::select_comment(prompt, &mut *tx).await?,
|
||||
};
|
||||
comment.delete(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_user_comment(username: Option<String>, pool: &sqlx::PgPool) -> Result {
|
||||
let mut tx = pool.begin().await?;
|
||||
let prompt = "Select user whose comment you want to delete:";
|
||||
let user = User::get_user_by_username_or_select(username.as_deref(), prompt, &mut *tx).await?;
|
||||
let comments: HashMap<String, Comment> = user
|
||||
.get_comments(&mut *tx)
|
||||
.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(&mut *tx).await?;
|
||||
tx.commit().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,141 @@
|
||||
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 mut tx = pool.begin().await?;
|
||||
let follower = User::get_user_by_username_or_select(
|
||||
follower.as_deref(),
|
||||
"Select who will be following someone:",
|
||||
&mut *tx,
|
||||
)
|
||||
.await?;
|
||||
let followed = User::get_user_by_username_or_select(
|
||||
followed.as_deref(),
|
||||
"Select who will be followed:",
|
||||
&mut *tx,
|
||||
)
|
||||
.await?;
|
||||
let follow = FollowerDefault {
|
||||
id: None,
|
||||
follower: follower.id,
|
||||
followed: followed.id,
|
||||
};
|
||||
follow.create(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
println!("User {follower} now follows {followed}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn unfollow_user(follower: Option<String>, pool: &sqlx::PgPool) -> Result {
|
||||
let mut tx = pool.begin().await?;
|
||||
let follower = User::get_user_by_username_or_select(
|
||||
follower.as_deref(),
|
||||
"Select who is following",
|
||||
&mut *tx,
|
||||
)
|
||||
.await?;
|
||||
let followed_list: HashMap<String, User> = follower
|
||||
.get_followed(&mut *tx)
|
||||
.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(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().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,46 @@
|
||||
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<'e, E>(prompt: &str, executor: E) -> Result<Self>
|
||||
where
|
||||
E: sqlx::Executor<'e, Database = sqlx::Postgres>,
|
||||
{
|
||||
let comments: HashMap<String, Self> = Self::find_all(executor)
|
||||
.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,72 @@
|
||||
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<'e, E>(user_id: i32, executor: E) -> Result<Self>
|
||||
where
|
||||
E: sqlx::Executor<'e, Database = sqlx::Postgres>,
|
||||
{
|
||||
let profile = ProfileDefault {
|
||||
user_id,
|
||||
id: None,
|
||||
bio: None,
|
||||
display_name: None,
|
||||
};
|
||||
profile
|
||||
.create(executor)
|
||||
.await
|
||||
.map_err(UserInputError::DatabaseError)
|
||||
}
|
||||
|
||||
pub async fn update_interactive<'e, E>(
|
||||
&mut self,
|
||||
display_name: Option<String>,
|
||||
bio: Option<String>,
|
||||
executor: E,
|
||||
) -> Result<Self>
|
||||
where
|
||||
E: sqlx::Executor<'e, Database = sqlx::Postgres>,
|
||||
{
|
||||
self.display_name = display_name;
|
||||
self.bio = bio;
|
||||
self.update(executor)
|
||||
.await
|
||||
.map_err(UserInputError::DatabaseError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
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<'e, E>(prompt: &str, executor: E) -> Result<Self>
|
||||
where
|
||||
E: sqlx::Executor<'e, Database = sqlx::Postgres>,
|
||||
{
|
||||
let users: HashMap<String, Self> = Self::find_all(executor)
|
||||
.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<'e, E>(
|
||||
id: Option<i32>,
|
||||
prompt: &str,
|
||||
executor: E,
|
||||
) -> Result<Self>
|
||||
where
|
||||
E: sqlx::Executor<'e, Database = sqlx::Postgres>,
|
||||
{
|
||||
let user = match id {
|
||||
Some(id) => Self::find(executor, &id)
|
||||
.await?
|
||||
.ok_or(UserInputError::UserDoesNotExist)?,
|
||||
None => Self::select_user(prompt, executor).await?,
|
||||
};
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn get_user_by_username_or_select<'e, E>(
|
||||
username: Option<&str>,
|
||||
prompt: &str,
|
||||
executor: E,
|
||||
) -> Result<Self>
|
||||
where
|
||||
E: sqlx::Executor<'e, Database = sqlx::Postgres>,
|
||||
{
|
||||
let user = match username {
|
||||
Some(username) => Self::find_by_username(username, executor)
|
||||
.await?
|
||||
.ok_or(UserInputError::UserDoesNotExist)?,
|
||||
None => Self::select_user(prompt, executor).await?,
|
||||
};
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn find_by_username<'e, E>(username: &str, executor: E) -> Result<Option<Self>>
|
||||
where
|
||||
E: sqlx::Executor<'e, Database = sqlx::Postgres>,
|
||||
{
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
"SELECT * FROM Users u WHERE u.username = $1",
|
||||
username
|
||||
)
|
||||
.fetch_optional(executor)
|
||||
.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.is_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.is_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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
169
georm-macros/src/georm/defaultable_struct.rs
Normal file
169
georm-macros/src/georm/defaultable_struct.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
//! 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 crate::georm::ir::GeneratedType;
|
||||
|
||||
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.is_defaultable_behavior() {
|
||||
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.is_id)
|
||||
.expect("Must have an ID field");
|
||||
let id_type = &id_field.ty;
|
||||
|
||||
// Remove always generated fields
|
||||
let fields: Vec<&GeormField> = fields
|
||||
.iter()
|
||||
.filter(|field| !matches!(field.generated_type, GeneratedType::Always))
|
||||
.collect();
|
||||
|
||||
// Separate defaultable and non-defaultable fields
|
||||
let non_defaultable_fields: Vec<_> = fields
|
||||
.iter()
|
||||
.filter(|f| !f.is_defaultable_behavior())
|
||||
.collect();
|
||||
let defaultable_fields: Vec<_> = fields
|
||||
.iter()
|
||||
.filter(|f| f.is_defaultable_behavior())
|
||||
.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<'e, E>(&self, mut executor: E) -> ::sqlx::Result<#struct_name>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
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(executor).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.is_defaultable_behavior()) {
|
||||
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()
|
||||
.flat_map(|field| match field.generated_type {
|
||||
GeneratedType::Always => None,
|
||||
_ => Some(create_defaultable_field(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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
82
georm-macros/src/georm/ir/m2m_relationship.rs
Normal file
82
georm-macros/src/georm/ir/m2m_relationship.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
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<'e, E>(&self, mut executor: E) -> ::sqlx::Result<Vec<#entity>>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_all(executor).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
183
georm-macros/src/georm/ir/mod.rs
Normal file
183
georm-macros/src/georm/ir/mod.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
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,
|
||||
#[deluxe(default = false)]
|
||||
pub generated: bool,
|
||||
#[deluxe(default = false)]
|
||||
pub generated_always: 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(Debug, Clone)]
|
||||
pub enum GeneratedType {
|
||||
None,
|
||||
ByDefault, // #[georm(generated)] - BY DEFAULT behaviour
|
||||
Always, // #[georm(generated_always)] - ALWAYS behaviour
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GeormField {
|
||||
pub ident: syn::Ident,
|
||||
pub field: syn::Field,
|
||||
pub ty: syn::Type,
|
||||
pub is_id: bool,
|
||||
pub is_defaultable: bool,
|
||||
pub generated_type: GeneratedType,
|
||||
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,
|
||||
defaultable,
|
||||
generated,
|
||||
generated_always,
|
||||
} = 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
|
||||
);
|
||||
}
|
||||
|
||||
if generated && generated_always {
|
||||
panic!(
|
||||
"Field '{}' cannot have both the #[georm(generated)] and \
|
||||
#[georm(generated_always)] attributes at the same time. Remove one\
|
||||
of them before continuing.",
|
||||
ident
|
||||
);
|
||||
}
|
||||
|
||||
Self {
|
||||
ident,
|
||||
field: field.to_owned(),
|
||||
is_id: id,
|
||||
ty,
|
||||
relation,
|
||||
is_defaultable: defaultable,
|
||||
generated_type: if generated_always {
|
||||
GeneratedType::Always
|
||||
} else if generated {
|
||||
GeneratedType::ByDefault
|
||||
} else {
|
||||
GeneratedType::None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if field should be excluded from INSERT statements
|
||||
pub fn exclude_from_insert(&self) -> bool {
|
||||
matches!(self.generated_type, GeneratedType::Always)
|
||||
}
|
||||
|
||||
/// Check if field should be excluded from UPDATE statements
|
||||
pub fn exclude_from_update(&self) -> bool {
|
||||
matches!(self.generated_type, GeneratedType::Always)
|
||||
}
|
||||
|
||||
/// Check if field should behave like a defaultable field
|
||||
pub fn is_defaultable_behavior(&self) -> bool {
|
||||
self.is_defaultable || matches!(self.generated_type, GeneratedType::ByDefault)
|
||||
}
|
||||
|
||||
/// Check if field is any type of generated field
|
||||
pub fn is_any_generated(&self) -> bool {
|
||||
!matches!(self.generated_type, GeneratedType::None)
|
||||
}
|
||||
}
|
||||
|
||||
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<'e, E>(&self, mut executor: E) -> ::sqlx::Result<#return_type>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
::sqlx::query_as!(#entity, #query, self.#local_ident).#fetch(executor).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
72
georm-macros/src/georm/ir/simple_relationship.rs
Normal file
72
georm-macros/src/georm/ir/simple_relationship.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
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<'e, E>(&self, mut executor: E) -> ::sqlx::Result<Option<#entity>>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_optional(executor).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<'e, E>(&self, mut executor: E) -> ::sqlx::Result<Vec<#entity>>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_all(executor).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
use ir::GeormField;
|
||||
use quote::quote;
|
||||
|
||||
mod composite_keys;
|
||||
mod defaultable_struct;
|
||||
mod ir;
|
||||
pub(crate) use ir::GeormField;
|
||||
mod relationships;
|
||||
mod trait_implementation;
|
||||
mod traits;
|
||||
pub(crate) use composite_keys::IdType;
|
||||
|
||||
fn extract_georm_field_attrs(
|
||||
ast: &mut syn::DeriveInput,
|
||||
) -> deluxe::Result<(Vec<GeormField>, GeormField)> {
|
||||
fn extract_georm_field_attrs(ast: &mut syn::DeriveInput) -> deluxe::Result<Vec<GeormField>> {
|
||||
let syn::Data::Struct(s) = &mut ast.data else {
|
||||
return Err(syn::Error::new_spanned(
|
||||
ast,
|
||||
@@ -23,22 +24,15 @@ fn extract_georm_field_attrs(
|
||||
let identifiers: Vec<GeormField> = fields
|
||||
.clone()
|
||||
.into_iter()
|
||||
.filter(|field| field.id)
|
||||
.filter(|field| field.is_id)
|
||||
.collect();
|
||||
match identifiers.len() {
|
||||
0 => Err(syn::Error::new_spanned(
|
||||
if identifiers.is_empty() {
|
||||
Err(syn::Error::new_spanned(
|
||||
ast,
|
||||
"Struct {name} must have one identifier",
|
||||
)),
|
||||
1 => Ok((fields, identifiers.first().unwrap().clone())),
|
||||
_ => {
|
||||
let id1 = identifiers.first().unwrap();
|
||||
let id2 = identifiers.get(1).unwrap();
|
||||
Err(syn::Error::new_spanned(id2.field.clone(), format!(
|
||||
"Field {} cannot be an identifier, {} already is one.\nOnly one identifier is supported.",
|
||||
id1.ident, id2.ident
|
||||
)))
|
||||
}
|
||||
))
|
||||
} else {
|
||||
Ok(fields)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,12 +42,43 @@ pub fn georm_derive_macro2(
|
||||
let mut ast: syn::DeriveInput = syn::parse2(item).expect("Failed to parse input");
|
||||
let struct_attrs: ir::GeormStructAttributes =
|
||||
deluxe::extract_attributes(&mut ast).expect("Could not extract attributes from struct");
|
||||
let (fields, id) = extract_georm_field_attrs(&mut ast)?;
|
||||
let relationships = relationships::derive_relationships(&ast, &struct_attrs, &fields, &id);
|
||||
let trait_impl = trait_implementation::derive_trait(&ast, &struct_attrs.table, &fields, &id);
|
||||
let fields = extract_georm_field_attrs(&mut ast)?;
|
||||
let defaultable_struct =
|
||||
defaultable_struct::derive_defaultable_struct(&ast, &struct_attrs, &fields);
|
||||
let from_row_impl = generate_from_row_impl(&ast, &fields);
|
||||
|
||||
let (identifier, id_struct) = composite_keys::create_primary_key(&ast, &fields);
|
||||
|
||||
let relationships =
|
||||
relationships::derive_relationships(&ast, &struct_attrs, &fields, &identifier);
|
||||
let trait_impl = traits::derive_trait(&ast, &struct_attrs.table, &fields, &identifier);
|
||||
|
||||
let code = quote! {
|
||||
#id_struct
|
||||
#defaultable_struct
|
||||
#relationships
|
||||
#trait_impl
|
||||
#from_row_impl
|
||||
};
|
||||
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 crate::georm::ir::M2MRelationshipComplete;
|
||||
use crate::georm::ir::m2m_relationship::M2MRelationshipComplete;
|
||||
|
||||
use super::composite_keys::IdType;
|
||||
use super::ir::GeormField;
|
||||
use proc_macro2::TokenStream;
|
||||
use quote::quote;
|
||||
@@ -15,16 +16,12 @@ fn join_token_streams(token_streams: &[TokenStream]) -> TokenStream {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn derive<T, P>(relationships: &[T], condition: P) -> TokenStream
|
||||
fn derive<T>(relationships: &[T]) -> TokenStream
|
||||
where
|
||||
for<'a> &'a T: Into<TokenStream>,
|
||||
P: FnMut(&&T) -> bool,
|
||||
{
|
||||
let implementations: Vec<TokenStream> = relationships
|
||||
.iter()
|
||||
.filter(condition)
|
||||
.map(std::convert::Into::into)
|
||||
.collect();
|
||||
let implementations: Vec<TokenStream> =
|
||||
relationships.iter().map(std::convert::Into::into).collect();
|
||||
join_token_streams(&implementations)
|
||||
}
|
||||
|
||||
@@ -32,21 +29,39 @@ pub fn derive_relationships(
|
||||
ast: &syn::DeriveInput,
|
||||
struct_attrs: &super::ir::GeormStructAttributes,
|
||||
fields: &[GeormField],
|
||||
id: &GeormField,
|
||||
id: &IdType,
|
||||
) -> TokenStream {
|
||||
let id = match id {
|
||||
IdType::Simple {
|
||||
field_name,
|
||||
field_type: _,
|
||||
} => field_name.to_string(),
|
||||
IdType::Composite {
|
||||
fields: _,
|
||||
field_type: _,
|
||||
} => {
|
||||
eprintln!(
|
||||
"Warning: entity {}: Relationships are not supported for entities with composite primary keys yet",
|
||||
ast.ident
|
||||
);
|
||||
return quote! {};
|
||||
}
|
||||
};
|
||||
let struct_name = &ast.ident;
|
||||
let one_to_one = derive(fields, |field| field.relation.is_some());
|
||||
let one_to_many = derive(&struct_attrs.one_to_many, |_| true);
|
||||
let one_to_one_local = derive(fields);
|
||||
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
|
||||
.many_to_many
|
||||
.iter()
|
||||
.map(|v| M2MRelationshipComplete::new(v, &struct_attrs.table, id.ident.to_string()))
|
||||
.map(|v| M2MRelationshipComplete::new(v, &struct_attrs.table, &id))
|
||||
.collect();
|
||||
let many_to_many = derive(&many_to_many, |_| true);
|
||||
let many_to_many = derive(&many_to_many);
|
||||
|
||||
quote! {
|
||||
impl #struct_name {
|
||||
#one_to_one
|
||||
#one_to_one_local
|
||||
#one_to_one_remote
|
||||
#one_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
|
||||
}
|
||||
}
|
||||
}
|
||||
37
georm-macros/src/georm/traits/create.rs
Normal file
37
georm-macros/src/georm/traits/create.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use crate::georm::GeormField;
|
||||
use quote::quote;
|
||||
|
||||
pub fn generate_create_query(table_name: &str, fields: &[GeormField]) -> proc_macro2::TokenStream {
|
||||
let insert_fields: Vec<&GeormField> = fields
|
||||
.iter()
|
||||
.filter(|field| !field.exclude_from_insert())
|
||||
.collect();
|
||||
let field_names: Vec<String> = insert_fields
|
||||
.iter()
|
||||
.map(|field| field.ident.to_string())
|
||||
.collect();
|
||||
let field_idents: Vec<syn::Ident> = insert_fields
|
||||
.iter()
|
||||
.map(|field| field.ident.clone())
|
||||
.collect();
|
||||
let placeholders: Vec<String> = (1..=insert_fields.len()).map(|i| format!("${i}")).collect();
|
||||
let query = format!(
|
||||
"INSERT INTO {table_name} ({}) VALUES ({}) RETURNING *",
|
||||
field_names.join(", "),
|
||||
placeholders.join(", ")
|
||||
);
|
||||
quote! {
|
||||
async fn create<'e, E>(&self, mut executor: E) -> ::sqlx::Result<Self>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
::sqlx::query_as!(
|
||||
Self,
|
||||
#query,
|
||||
#(self.#field_idents),*
|
||||
)
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
45
georm-macros/src/georm/traits/delete.rs
Normal file
45
georm-macros/src/georm/traits/delete.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
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<'e, E>(&self, mut executor: E) -> ::sqlx::Result<u64>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
Self::delete_by_id(executor, &self.get_id()).await
|
||||
}
|
||||
|
||||
async fn delete_by_id<'e, E>(mut executor: E, id: &#id_type) -> ::sqlx::Result<u64>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
let rows_affected = ::sqlx::query!(#delete_string, #query_args)
|
||||
.execute(executor)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(rows_affected)
|
||||
}
|
||||
}
|
||||
}
|
||||
56
georm-macros/src/georm/traits/find.rs
Normal file
56
georm-macros/src/georm/traits/find.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
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<'e, E>(mut executor: E) -> ::sqlx::Result<Vec<Self>>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
::sqlx::query_as!(Self, #find_string).fetch_all(executor).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<'e, E>(mut executor: E, id: &#field_type) -> ::sqlx::Result<Option<Self>>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
::sqlx::query_as!(Self, #find_string, id)
|
||||
.fetch_optional(executor)
|
||||
.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<'e, E>(mut executor: E, id: &#field_type) -> ::sqlx::Result<Option<Self>>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
::sqlx::query_as!(Self, #find_string, #(id.#id_members),*)
|
||||
.fetch_optional(executor)
|
||||
.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);
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
45
georm-macros/src/georm/traits/update.rs
Normal file
45
georm-macros/src/georm/traits/update.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use crate::georm::GeormField;
|
||||
use quote::quote;
|
||||
|
||||
pub fn generate_update_query(table_name: &str, fields: &[GeormField]) -> proc_macro2::TokenStream {
|
||||
let update_fields: Vec<&GeormField> = fields
|
||||
.iter()
|
||||
.filter(|field| !field.is_id && !field.exclude_from_update())
|
||||
.collect();
|
||||
let update_idents: Vec<syn::Ident> = update_fields
|
||||
.iter()
|
||||
.map(|field| field.ident.clone())
|
||||
.collect();
|
||||
let id_fields: Vec<&GeormField> = fields.iter().filter(|field| field.is_id).collect();
|
||||
let id_idents: Vec<syn::Ident> = id_fields.iter().map(|f| f.ident.clone()).collect();
|
||||
let set_clauses: Vec<String> = update_fields
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, field)| format!("{} = ${}", field.ident, i + 1))
|
||||
.collect();
|
||||
let where_clauses: Vec<String> = id_fields
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, field)| format!("{} = ${}", field.ident, update_fields.len() + i + 1))
|
||||
.collect();
|
||||
let query = format!(
|
||||
"UPDATE {table_name} SET {} WHERE {} RETURNING *",
|
||||
set_clauses.join(", "),
|
||||
where_clauses.join(" AND ")
|
||||
);
|
||||
quote! {
|
||||
async fn update<'e, E>(&self, mut executor: E) -> ::sqlx::Result<Self>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
::sqlx::query_as!(
|
||||
Self,
|
||||
#query,
|
||||
#(self.#update_idents),*,
|
||||
#(self.#id_idents),*
|
||||
)
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
60
georm-macros/src/georm/traits/upsert.rs
Normal file
60
georm-macros/src/georm/traits/upsert.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use crate::georm::{GeormField, IdType, ir::GeneratedType};
|
||||
use quote::quote;
|
||||
|
||||
pub fn generate_upsert_query(
|
||||
table: &str,
|
||||
fields: &[GeormField],
|
||||
id: &IdType,
|
||||
) -> proc_macro2::TokenStream {
|
||||
let fields: Vec<&GeormField> = fields
|
||||
.iter()
|
||||
.filter(|field| !matches!(field.generated_type, GeneratedType::Always))
|
||||
.collect();
|
||||
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.is_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 upsert<'e, E>(&self, mut executor: E) -> ::sqlx::Result<Self>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
::sqlx::query_as!(
|
||||
Self,
|
||||
#upsert_string,
|
||||
#(self.#field_idents),*
|
||||
)
|
||||
.fetch_one(executor)
|
||||
.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
migrations/20250807202945_generated-columns.down.sql
Normal file
1
migrations/20250807202945_generated-columns.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS products;
|
||||
12
migrations/20250807202945_generated-columns.up.sql
Normal file
12
migrations/20250807202945_generated-columns.up.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE products (
|
||||
-- strictly autogenerated
|
||||
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
-- auto-generated but allows manual override
|
||||
sku_number INTEGER GENERATED BY DEFAULT AS IDENTITY,
|
||||
|
||||
name VARCHAR(100) NOT NULL,
|
||||
price DECIMAL(10, 2) NOT NULL,
|
||||
discount_percent INTEGER DEFAULT 0 NOT NULL,
|
||||
|
||||
final_price DECIMAL(10, 2) GENERATED ALWAYS AS (price * (1 - discount_percent / 100.0)) STORED
|
||||
);
|
||||
@@ -1,4 +1,4 @@
|
||||
# Do not increase during a minor/patch release cycle
|
||||
[toolchain]
|
||||
channel = "1.81"
|
||||
channel = "1.86"
|
||||
profile = "minimal"
|
||||
|
||||
281
src/defaultable.rs
Normal file
281
src/defaultable.rs
Normal file
@@ -0,0 +1,281 @@
|
||||
use sqlx::{Executor, Postgres};
|
||||
|
||||
/// 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<'e, E>(
|
||||
&self,
|
||||
executor: E,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Entity>> + Send
|
||||
where
|
||||
Self: Sized,
|
||||
E: Executor<'e, Database = Postgres>;
|
||||
}
|
||||
390
src/georm.rs
Normal file
390
src/georm.rs
Normal file
@@ -0,0 +1,390 @@
|
||||
use sqlx::{Executor, Postgres};
|
||||
|
||||
/// 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
|
||||
/// - [`upsert`] - 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
|
||||
/// [`upsert`]: Georm::upsert
|
||||
/// [`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<'e, E>(
|
||||
executor: E,
|
||||
) -> impl ::std::future::Future<Output = ::sqlx::Result<Vec<Self>>> + Send
|
||||
where
|
||||
Self: Sized,
|
||||
E: Executor<'e, Database = Postgres>;
|
||||
|
||||
/// 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<'e, E>(
|
||||
executor: E,
|
||||
id: &Id,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Option<Self>>> + Send
|
||||
where
|
||||
Self: Sized,
|
||||
E: Executor<'e, Database = Postgres>;
|
||||
|
||||
/// 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<'e, E>(
|
||||
&self,
|
||||
executor: E,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
||||
where
|
||||
Self: Sized,
|
||||
E: Executor<'e, Database = Postgres>;
|
||||
|
||||
/// 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<'e, E>(
|
||||
&self,
|
||||
executor: E,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
||||
where
|
||||
Self: Sized,
|
||||
E: Executor<'e, Database = Postgres>;
|
||||
|
||||
/// 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.upsert(&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 upsert<'e, E>(&self, executor: E) -> impl ::std::future::Future<Output = sqlx::Result<Self>>
|
||||
where
|
||||
Self: Sized,
|
||||
E: Executor<'e, Database = Postgres>;
|
||||
|
||||
#[deprecated(since = "0.3.0", note = "Please use `upsert` instead")]
|
||||
fn create_or_update<'e, E>(
|
||||
&self,
|
||||
executor: E,
|
||||
) -> impl ::std::future::Future<Output = sqlx::Result<Self>>
|
||||
where
|
||||
Self: Sized,
|
||||
E: Executor<'e, Database = Postgres>,
|
||||
{
|
||||
self.upsert(executor)
|
||||
}
|
||||
|
||||
/// 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<'e, E>(
|
||||
&self,
|
||||
executor: E,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send
|
||||
where
|
||||
E: Executor<'e, Database = Postgres>;
|
||||
|
||||
/// 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<'e, E>(
|
||||
executor: E,
|
||||
id: &Id,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send
|
||||
where
|
||||
E: Executor<'e, Database = Postgres>;
|
||||
|
||||
/// 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;
|
||||
}
|
||||
789
src/lib.rs
789
src/lib.rs
@@ -1,164 +1,408 @@
|
||||
//! # Georm
|
||||
//!
|
||||
//! ## Introduction
|
||||
//! A simple, type-safe PostgreSQL ORM built on SQLx with zero runtime overhead.
|
||||
//!
|
||||
//! Georm is a simple, opinionated SQLx ORM for PostgreSQL.
|
||||
//!
|
||||
//! To automatically implement the `Georm` trait, you need at least:
|
||||
//! - to derive the `Georm` and `sqlx::FromRow` traits
|
||||
//! - use the `georm` proc-macro to indicate the table in which your entity
|
||||
//! lives
|
||||
//! - use the `georm` proc-macro again to indicate which field of your struct is
|
||||
//! the identifier of your entity.
|
||||
//!
|
||||
//! ## Simple usage
|
||||
//! Here is a minimal use of Georm with a struct:
|
||||
//! ## Quick Start
|
||||
//!
|
||||
//! ```ignore
|
||||
//! #[derive(sqlx::FromRow, Georm)]
|
||||
//! use georm::Georm;
|
||||
//!
|
||||
//! // Note: No need to derive FromRow - Georm generates it automatically
|
||||
//! #[derive(Georm)]
|
||||
//! #[georm(table = "users")]
|
||||
//! pub struct User {
|
||||
//! #[georm(id)]
|
||||
//! id: i32,
|
||||
//! 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
|
||||
//! `Georm` trait.
|
||||
//! ## Core CRUD Operations
|
||||
//!
|
||||
//! ## 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,
|
||||
//! you can use an identifier of another entity as a link to that other entity.
|
||||
//! ### Instance Methods (called on entity objects)
|
||||
//! - `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.upsert(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
|
||||
//! #[derive(sqlx::FromRow, Georm)]
|
||||
//! #[georm(table = "profiles")]
|
||||
//! pub struct Profile {
|
||||
//! // Static methods
|
||||
//! let user = User::find(&pool, &1).await?.unwrap();
|
||||
//! 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**: `upsert()` 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)]
|
||||
//! id: i32,
|
||||
//! #[georm(
|
||||
//! relation = {
|
||||
//! entity = User,
|
||||
//! name = "user",
|
||||
//! table = "users",
|
||||
//! remote_id = "id",
|
||||
//! nullable = false
|
||||
//! })
|
||||
//! ]
|
||||
//! user_id: i32,
|
||||
//! display_name: String,
|
||||
//! ident: i32, // Custom field name for primary key
|
||||
//! title: 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)
|
||||
//! -> User` method.
|
||||
//! ### Composite Primary Keys
|
||||
//!
|
||||
//! Here is an explanation of what these different values mean:
|
||||
//!
|
||||
//! | Value Name | Explanation | Default value |
|
||||
//! |------------|-----------------------------------------------------------------------------------------|---------------|
|
||||
//! | entity | Rust type of the entity found in the database | N/A |
|
||||
//! | name | Name of the remote entity within the local entity; generates a method named `get_{name}` | N/A |
|
||||
//! | table | Database table where the entity is stored | N/A |
|
||||
//! | remote_id | Name of the column serving as the identifier of the entity | `"id"` |
|
||||
//! | nullable | Whether the relationship can be broken | `false` |
|
||||
//!
|
||||
//! Note that in this instance, the `remote_id` and `nullable` values can be
|
||||
//! omitted as this is their default value. This below is a strict equivalent:
|
||||
//! Mark multiple fields with `#[georm(id)]` for composite keys:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! #[derive(sqlx::FromRow, Georm)]
|
||||
//! #[georm(table = "profiles")]
|
||||
//! pub struct Profile {
|
||||
//! #[derive(Georm)]
|
||||
//! #[georm(table = "user_roles")]
|
||||
//! pub struct UserRole {
|
||||
//! #[georm(id)]
|
||||
//! id: i32,
|
||||
//! #[georm(relation = { entity = User, table = "users", name = "user" })]
|
||||
//! user_id: i32,
|
||||
//! display_name: String,
|
||||
//! #[georm(id)]
|
||||
//! role_id: i32,
|
||||
//! assigned_at: chrono::DateTime<chrono::Utc>,
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ## One-to-many relationships
|
||||
//!
|
||||
//! Sometimes, our entity is the one being referenced to by multiple entities,
|
||||
//! but we have no internal reference to these remote entities in our local
|
||||
//! entity. Fortunately, we have a way to indicate to Georm how to find these.
|
||||
//! This automatically generates a composite ID struct following the `{EntityName}Id` pattern:
|
||||
//!
|
||||
//! ```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 and Generated Fields
|
||||
//!
|
||||
//! Georm provides three attributes for handling fields with database-managed values:
|
||||
//!
|
||||
//! ### `#[georm(defaultable)]` - Optional Override Fields
|
||||
//!
|
||||
//! For fields with database defaults that can be manually overridden:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! #[derive(Georm)]
|
||||
//! #[georm(table = "posts")]
|
||||
//! struct Post {
|
||||
//! #[georm(id)]
|
||||
//! id: i32,
|
||||
//! #[georm(relation = { entity = User, table = "users", name = "user" })]
|
||||
//! author_id: i32,
|
||||
//! content: String
|
||||
//! pub struct Post {
|
||||
//! #[georm(id, defaultable)]
|
||||
//! id: i32, // Auto-generated serial
|
||||
//! title: String, // Required field
|
||||
//! #[georm(defaultable)]
|
||||
//! 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
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ### `#[georm(generated)]` - Generated by Default
|
||||
//!
|
||||
//! For PostgreSQL `GENERATED BY DEFAULT` columns that can be overridden but are typically auto-generated:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! #[derive(Georm)]
|
||||
//! #[georm(table = "products")]
|
||||
//! pub struct Product {
|
||||
//! #[georm(id, generated_always)]
|
||||
//! id: i32, // GENERATED ALWAYS AS IDENTITY
|
||||
//! #[georm(generated)]
|
||||
//! sku_number: i32, // GENERATED BY DEFAULT AS IDENTITY
|
||||
//! name: String,
|
||||
//! price: sqlx::types::BigDecimal,
|
||||
//! discount_percent: i32,
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ### `#[georm(generated_always)]` - Always Generated
|
||||
//!
|
||||
//! For PostgreSQL `GENERATED ALWAYS` columns that are strictly managed by the database:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! #[derive(Georm)]
|
||||
//! #[georm(table = "products")]
|
||||
//! pub struct Product {
|
||||
//! #[georm(id, generated_always)]
|
||||
//! id: i32, // GENERATED ALWAYS AS IDENTITY
|
||||
//! name: String,
|
||||
//! price: sqlx::types::BigDecimal,
|
||||
//! discount_percent: i32,
|
||||
//! #[georm(generated_always)]
|
||||
//! final_price: Option<sqlx::types::BigDecimal>, // GENERATED ALWAYS AS (expression) STORED
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ### Generated Structs and Behavior
|
||||
//!
|
||||
//! Both `defaultable` and `generated` fields create a companion `<Entity>Default` struct where these fields become `Option<T>`:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! // Generated automatically by the macro for the Product example above
|
||||
//! pub struct ProductDefault {
|
||||
//! pub name: String, // Required field stays the same
|
||||
//! pub price: sqlx::types::BigDecimal, // Required field stays the same
|
||||
//! pub discount_percent: i32, // Required field stays the same
|
||||
//! pub sku_number: Option<i32>, // Can be None for auto-generation
|
||||
//! // Note: generated_always fields are completely excluded from this struct
|
||||
//! }
|
||||
//!
|
||||
//! #[derive(sqlx::FromRow, Georm)]
|
||||
//! impl Defaultable<i32, Product> for ProductDefault {
|
||||
//! async fn create<'e, E>(&self, pool: E) -> sqlx::Result<Post>
|
||||
//! where
|
||||
//! E: sqlx::Executor<'e, Database = sqlx::Postgres>;
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ### Usage Example
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use georm::{Georm, Defaultable};
|
||||
//!
|
||||
//! // Create a product with auto-generated values
|
||||
//! let product_default = ProductDefault {
|
||||
//! name: "Laptop".to_string(),
|
||||
//! price: sqlx::types::BigDecimal::from(999),
|
||||
//! discount_percent: 10,
|
||||
//! sku_number: None, // Let database auto-generate
|
||||
//! // Note: id and final_price are excluded (generated_always)
|
||||
//! };
|
||||
//!
|
||||
//! // Create the entity in the database (instance method on ProductDefault)
|
||||
//! let created_product = product_default.create(&pool).await?;
|
||||
//! println!("Created product with ID: {}", created_product.id);
|
||||
//! println!("Final price: ${}", created_product.final_price.unwrap_or_default());
|
||||
//! ```
|
||||
//!
|
||||
//! ### Key Differences
|
||||
//!
|
||||
//! | Attribute | INSERT Behavior | UPDATE Behavior | Use Case |
|
||||
//! |--------------------|------------------------------------|-----------------|----------------------------------------------|
|
||||
//! | `defaultable` | Optional (can override defaults) | Included | Fields with database defaults |
|
||||
//! | `generated` | Optional (can override generation) | Included | `GENERATED BY DEFAULT` columns |
|
||||
//! | `generated_always` | **Excluded** (always generated) | **Excluded** | `GENERATED ALWAYS` columns, computed columns |
|
||||
//!
|
||||
//! ### Rules and Limitations
|
||||
//!
|
||||
//! - **`generated_always` fields are completely excluded** from INSERT and UPDATE statements to prevent database errors
|
||||
//! - **`generated` and `generated_always` cannot be used together** on the same field - this causes a compile-time error
|
||||
//! - **`generated` fields behave like `defaultable` fields** but are semantically distinct for future enhancements
|
||||
//! - **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 or generated**: It's common to mark ID fields as defaultable
|
||||
//! or generated 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 or generated.
|
||||
//!
|
||||
//! ## 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(
|
||||
//! table = "users",
|
||||
//! one_to_many = [{
|
||||
//! entity = Post,
|
||||
//! name = "posts",
|
||||
//! table = "posts",
|
||||
//! remote_id = "id"
|
||||
//! 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
|
||||
//! }]
|
||||
//! )]
|
||||
//! struct User {
|
||||
//! pub struct User {
|
||||
//! #[georm(id)]
|
||||
//! id: i32,
|
||||
//! username: String,
|
||||
//! hashed_password: String
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! As we’ve seen earlier, the struct `Post` has access to the method
|
||||
//! `Post::get_user(&self, pool: &sqlx::PgPool) -> User` thanks to the
|
||||
//! proc-macro used on `author_id`. However, `User` now has also access to
|
||||
//! `User::get_posts(&self, pool: &sqlx::PgPool) -> Vec<Post>`. And as you can
|
||||
//! see, `one_to_many` is an array, meaning you can define several one-to-many
|
||||
//! relationships for `User`.
|
||||
//! **Generated instance method**: `user.get_profile(pool).await? -> sqlx::Result<Option<Profile>>`
|
||||
//!
|
||||
//! Here is an explanation of the values of `one_to_many`:
|
||||
//!
|
||||
//! | Value Name | Explanaion | Default Value |
|
||||
//! |------------|------------------------------------------------------------------------------------------|---------------|
|
||||
//! | entity | Rust type of the entity found in the database | N/A |
|
||||
//! | name | Name of the remote entity within the local entity; generates a method named `get_{name}` | N/A |
|
||||
//! | table | Database table where the entity is stored | N/A |
|
||||
//! | remote_id | Name of the column serving as the identifier of the entity | `"id"` |
|
||||
//!
|
||||
//! As with one-to-one relationships, `remote_id` is optional. The following
|
||||
//! `User` struct is strictly equivalent.
|
||||
//! #### One-to-Many Relationships
|
||||
//!
|
||||
//! ```ignore
|
||||
//! #[derive(sqlx::FromRow, Georm)]
|
||||
//! #[derive(Georm)]
|
||||
//! #[georm(
|
||||
//! table = "users",
|
||||
//! one_to_many = [{ entity = Post, name = "posts", table = "posts" }]
|
||||
//! 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"
|
||||
//! }]
|
||||
//! )]
|
||||
//! struct User {
|
||||
//! pub struct Author {
|
||||
//! #[georm(id)]
|
||||
//! id: i32,
|
||||
//! username: String,
|
||||
//! hashed_password: String
|
||||
//! name: 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
|
||||
//! 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.
|
||||
//! #### Many-to-Many Relationships
|
||||
//!
|
||||
//! 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(100) NOT NULL
|
||||
//! title VARCHAR(200) NOT NULL
|
||||
//! );
|
||||
//!
|
||||
//! CREATE TABLE genres (
|
||||
@@ -166,178 +410,245 @@
|
||||
//! name VARCHAR(100) NOT NULL
|
||||
//! );
|
||||
//!
|
||||
//! CREATE TABLE books_genres (
|
||||
//! book_id INT NOT NULL,
|
||||
//! genre_id INT NOT NULL,
|
||||
//! PRIMARY KEY (book_id, genre_id),
|
||||
//! FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE CASCADE,
|
||||
//! FOREIGN KEY (genre_id) REFERENCES genres(id) ON DELETE CASCADE
|
||||
//! 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)
|
||||
//! );
|
||||
//! ```
|
||||
//!
|
||||
//! The table `books_genres` is the one defining the many-to-many relationship
|
||||
//! between the table `books` and the table `genres`. With Georm, this gives us
|
||||
//! the following code:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! #[derive(sqlx::FromRow, Georm)]
|
||||
//! #[derive(Georm)]
|
||||
//! #[georm(
|
||||
//! table = "books",
|
||||
//! many_to_many = [{
|
||||
//! name = "genres",
|
||||
//! entity = Genre,
|
||||
//! table = "genres",
|
||||
//! remote_id = "id",
|
||||
//! link = { table = "books_genres", from = "book_id", to = "genre_id" }
|
||||
//! entity = Genre, // Related entity type
|
||||
//! name = "genres", // Method name (generates get_genres)
|
||||
//! table = "genres", // Related table name
|
||||
//! 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
|
||||
//! }
|
||||
//! }]
|
||||
//! )]
|
||||
//! struct Book {
|
||||
//! pub struct Book {
|
||||
//! #[georm(id)]
|
||||
//! id: i32,
|
||||
//! title: String
|
||||
//! title: String,
|
||||
//! }
|
||||
//!
|
||||
//! #[derive(sqlx::FromRow, Georm)]
|
||||
//! #[derive(Georm)]
|
||||
//! #[georm(
|
||||
//! table = "genres",
|
||||
//! many_to_many = [{
|
||||
//! entity = Book,
|
||||
//! name = "books",
|
||||
//! table = "books",
|
||||
//! remote_id = "id",
|
||||
//! link = { table = "books_genres", from = "genre_id", to = "book_id" }
|
||||
//! link = {
|
||||
//! table = "book_genres",
|
||||
//! from = "genre_id", // Note: reversed perspective
|
||||
//! to = "book_id"
|
||||
//! }
|
||||
//! }]
|
||||
//! )]
|
||||
//! struct Genre {
|
||||
//! pub struct Genre {
|
||||
//! #[georm(id)]
|
||||
//! id: i32,
|
||||
//! name: String
|
||||
//! name: String,
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! This generates two methods:
|
||||
//! - `Book::get_genres(&self, pool: &sqlx::PgPool) -> Vec<Genre>`
|
||||
//! - `Genre::get_books(&self, pool: &sqlx::PgPool) -> Vec<Book>`
|
||||
//! **Generated instance methods**:
|
||||
//! - `book.get_genres(pool).await? -> sqlx::Result<Vec<Genre>>`
|
||||
//! - `genre.get_books(pool).await? -> sqlx::Result<Vec<Book>>`
|
||||
//!
|
||||
//! As you can see, `many_to_many` is also an array, meaning we can define
|
||||
//! several many-to-many relationships for the same struct.
|
||||
//! #### Struct-Level Relationship Attributes
|
||||
//!
|
||||
//! 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 |
|
||||
//! |------------|------------------------------------------------------------------------------------------|---------------|
|
||||
//! | entity | Rust type of the entity found in the database | N/A |
|
||||
//! | name | Name of the remote entity within the local entity; generates a method named `get_{name}` | N/A |
|
||||
//! | table | Database table where the entity is stored | N/A |
|
||||
//! | remote_id | Name of the column serving as the identifier of the entity | `"id"` |
|
||||
//! | link.table | Name of the many-to-many relationship table | N/A |
|
||||
//! | link.from | Column of the linking table referring to this entity | N/A |
|
||||
//! | link.to | Column of the linking table referring to the remote entity | N/A |
|
||||
//! *Required for many-to-many relationships
|
||||
//!
|
||||
//! As with field-level relationships, `remote_id` is optional and defaults to `"id"`:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! #[georm(
|
||||
//! table = "users",
|
||||
//! one_to_many = [{ entity = Post, name = "posts", table = "posts" }]
|
||||
//! )]
|
||||
//! ```
|
||||
//!
|
||||
//! #### 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>,
|
||||
//! }
|
||||
//!
|
||||
//! // ❌ Compile error: Cannot use both generated attributes on same field
|
||||
//! #[derive(Georm)]
|
||||
//! #[georm(table = "invalid")]
|
||||
//! pub struct Invalid {
|
||||
//! #[georm(id, generated, generated_always)] // Error: conflicting attributes
|
||||
//! id: i32,
|
||||
//! name: 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(generated)] // Mark as generated by default field (GENERATED BY DEFAULT)
|
||||
//! #[georm(generated_always)] // Mark as always generated field (GENERATED ALWAYS)
|
||||
//! #[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
|
||||
//! ### Database
|
||||
//!
|
||||
//! For now, Georm is limited to PostgreSQL. Other databases may be supported in
|
||||
//! the future, such as Sqlite or MySQL, but that is not the case yet.
|
||||
//! ### Database Support
|
||||
//!
|
||||
//! ## 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
|
||||
//! only be simple types recognized by SQLx. They also cannot be arrays, and
|
||||
//! optionals are only supported in one-to-one relationships when explicitly
|
||||
//! marked as nullables.
|
||||
//! be simple types recognized by SQLx or composite keys (multiple fields marked
|
||||
//! with `#[georm(id)]`). Single primary keys cannot be arrays, and optionals are
|
||||
//! only supported in one-to-one relationships when explicitly marked as nullables.
|
||||
//!
|
||||
//! ### 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 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>>
|
||||
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;
|
||||
}
|
||||
mod georm;
|
||||
pub use georm::Georm;
|
||||
mod defaultable;
|
||||
pub use defaultable::Defaultable;
|
||||
|
||||
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_upsert(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.upsert(&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');
|
||||
8
tests/fixtures/generated.sql
vendored
Normal file
8
tests/fixtures/generated.sql
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
INSERT INTO products (name, price, discount_percent)
|
||||
VALUES ('Laptop', 999.99, 10);
|
||||
|
||||
INSERT INTO products (sku_number, name, price, discount_percent)
|
||||
VALUES (5000, 'Mouse', 29.99, 5);
|
||||
|
||||
INSERT INTO products (name, price)
|
||||
VALUES ('Keyboard', 79.99);
|
||||
3
tests/fixtures/simple_struct.sql
vendored
3
tests/fixtures/simple_struct.sql
vendored
@@ -1,6 +1,7 @@
|
||||
INSERT INTO biographies (content)
|
||||
VALUES ('Some text'),
|
||||
('Some other text');
|
||||
('Some other text'),
|
||||
('Biography for no one');
|
||||
|
||||
INSERT INTO authors (name, biography_id)
|
||||
VALUES ('J.R.R. Tolkien', 2),
|
||||
|
||||
64
tests/generated.rs
Normal file
64
tests/generated.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use georm::{Defaultable, Georm};
|
||||
use sqlx::types::BigDecimal;
|
||||
|
||||
mod models;
|
||||
use models::{Product, ProductDefault};
|
||||
|
||||
#[sqlx::test()]
|
||||
async fn create_without_generated_values(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let base = ProductDefault {
|
||||
name: "Desktop".to_owned(),
|
||||
price: BigDecimal::from(2000),
|
||||
discount_percent: 5,
|
||||
sku_number: None,
|
||||
};
|
||||
let result = base.create(&pool).await?;
|
||||
assert_eq!(BigDecimal::from(1900), result.final_price.unwrap());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test()]
|
||||
async fn create_with_manual_generated_value(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let base = ProductDefault {
|
||||
name: "Monitor".to_owned(),
|
||||
price: BigDecimal::from(750),
|
||||
discount_percent: 10,
|
||||
sku_number: Some(12345),
|
||||
};
|
||||
let result = base.create(&pool).await?;
|
||||
assert_eq!(12345, result.sku_number);
|
||||
assert_eq!("Monitor", result.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("generated"))]
|
||||
async fn update_does_not_change_generated_always_field(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let products = Product::find_all(&pool).await?;
|
||||
dbg!(&products);
|
||||
let mut product = products.first().unwrap().clone();
|
||||
let original_final_price = product.clone().final_price;
|
||||
product.name = "Gaming Laptop".to_owned();
|
||||
product.final_price = Some(BigDecimal::from(1000000));
|
||||
dbg!(&product);
|
||||
let updated = product.update(&pool).await?;
|
||||
assert_eq!(original_final_price, updated.final_price);
|
||||
assert_eq!("Gaming Laptop", updated.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("generated"))]
|
||||
async fn upsert_handles_generated_fields(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let product = Product::find_by_name("Laptop".to_owned(), &pool).await?;
|
||||
let mut modified_product = product.clone();
|
||||
modified_product.price = BigDecimal::from(1200);
|
||||
|
||||
let upserted = modified_product.upsert(&pool).await?;
|
||||
|
||||
// price is updated
|
||||
assert_eq!(upserted.price, BigDecimal::from(1200));
|
||||
// final_price is re-calculated by the DB
|
||||
// 1200 * (1 - 10 / 100.0) = 1080
|
||||
assert_eq!(BigDecimal::from(1080), upserted.final_price.unwrap());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
use georm::Georm;
|
||||
use sqlx::{Postgres, types::BigDecimal};
|
||||
|
||||
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq, Default)]
|
||||
#[georm(table = "biographies")]
|
||||
#[derive(Debug, Georm, PartialEq, Eq, Default)]
|
||||
#[georm(
|
||||
table = "biographies",
|
||||
one_to_one = [{
|
||||
name = "author", remote_id = "biography_id", table = "authors", entity = Author
|
||||
}]
|
||||
)]
|
||||
pub struct Biography {
|
||||
#[georm(id)]
|
||||
pub id: i32,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq, Default)]
|
||||
#[derive(Debug, Georm, PartialEq, Eq, Default)]
|
||||
#[georm(table = "authors")]
|
||||
pub struct Author {
|
||||
#[georm(id)]
|
||||
@@ -30,7 +36,7 @@ impl Ord for Author {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq, Default)]
|
||||
#[derive(Debug, Georm, PartialEq, Eq, Default)]
|
||||
#[georm(
|
||||
table = "books",
|
||||
one_to_many = [
|
||||
@@ -63,7 +69,7 @@ impl Ord for Book {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq)]
|
||||
#[derive(Debug, Georm, PartialEq, Eq)]
|
||||
#[georm(table = "reviews")]
|
||||
pub struct Review {
|
||||
#[georm(id)]
|
||||
@@ -73,7 +79,7 @@ pub struct Review {
|
||||
pub review: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq)]
|
||||
#[derive(Debug, Georm, PartialEq, Eq)]
|
||||
#[georm(
|
||||
table = "genres",
|
||||
many_to_many = [{
|
||||
@@ -89,3 +95,40 @@ pub struct Genre {
|
||||
id: i32,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Georm, PartialEq, Eq, Default)]
|
||||
#[georm(table = "UserRoles")]
|
||||
pub struct UserRole {
|
||||
#[georm(id)]
|
||||
pub user_id: i32,
|
||||
#[georm(id)]
|
||||
pub role_id: i32,
|
||||
#[georm(defaultable)]
|
||||
pub assigned_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Georm, PartialEq, Default, Clone)]
|
||||
#[georm(table = "products")]
|
||||
pub struct Product {
|
||||
#[georm(id, generated_always)]
|
||||
pub id: i32,
|
||||
#[georm(generated)]
|
||||
pub sku_number: i32,
|
||||
pub name: String,
|
||||
pub price: BigDecimal,
|
||||
pub discount_percent: i32,
|
||||
#[georm(generated_always)]
|
||||
pub final_price: Option<BigDecimal>, // Apparently this can be null ?
|
||||
}
|
||||
|
||||
impl Product {
|
||||
#[allow(dead_code)]
|
||||
pub async fn find_by_name<'e, E>(name: String, executor: E) -> ::sqlx::Result<Self>
|
||||
where
|
||||
E: sqlx::Executor<'e, Database = Postgres>,
|
||||
{
|
||||
::sqlx::query_as!(Self, "SELECT * FROM products WHERE name = $1", name)
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?);
|
||||
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;
|
||||
assert!(result.is_err());
|
||||
let error = result.err().unwrap();
|
||||
assert_eq!("error returned from database: duplicate key value violates unique constraint \"authors_pkey\"", error.to_string());
|
||||
assert_eq!(
|
||||
"error returned from database: duplicate key value violates unique constraint \"authors_pkey\"",
|
||||
error.to_string()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -112,7 +115,7 @@ async fn should_create_if_does_not_exist(pool: sqlx::PgPool) -> sqlx::Result<()>
|
||||
name: "Miura Kentaro".into(),
|
||||
..Default::default()
|
||||
};
|
||||
author.create_or_update(&pool).await?;
|
||||
author.upsert(&pool).await?;
|
||||
let all_authors = Author::find_all(&pool).await?;
|
||||
assert_eq!(1, all_authors.len());
|
||||
Ok(())
|
||||
@@ -127,7 +130,7 @@ async fn should_update_if_exist(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
name: "Miura Kentaro".into(),
|
||||
..Default::default()
|
||||
};
|
||||
author.create_or_update(&pool).await?;
|
||||
author.upsert(&pool).await?;
|
||||
let mut all_authors = Author::find_all(&pool).await?;
|
||||
all_authors.sort();
|
||||
assert_eq!(3, all_authors.len());
|
||||
@@ -140,12 +143,12 @@ async fn delete_by_id_should_delete_only_one_entry(pool: sqlx::PgPool) -> sqlx::
|
||||
let id = 2;
|
||||
let all_authors = Author::find_all(&pool).await?;
|
||||
assert_eq!(3, all_authors.len());
|
||||
assert!(all_authors.iter().any(|author| author.get_id() == &id));
|
||||
assert!(all_authors.iter().any(|author| author.get_id() == id));
|
||||
let result = Author::delete_by_id(&pool, &id).await?;
|
||||
assert_eq!(1, result);
|
||||
let all_authors = Author::find_all(&pool).await?;
|
||||
assert_eq!(2, all_authors.len());
|
||||
assert!(all_authors.iter().all(|author| author.get_id() != &id));
|
||||
assert!(all_authors.iter().all(|author| author.get_id() != id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user