Compare commits
9 Commits
Author | SHA1 | Date | |
---|---|---|---|
c9dd2242e7 | |||
8ffa8eb3ac | |||
298d3b28a7 | |||
ea89fab0e8 | |||
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
|
||||
._*
|
||||
|
828
Cargo.lock
generated
828
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
10
Cargo.toml
10
Cargo.toml
@ -1,9 +1,13 @@
|
||||
[workspace]
|
||||
members = [".", "georm-macros"]
|
||||
members = [
|
||||
".",
|
||||
"georm-macros",
|
||||
"examples/postgres/*"
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.1"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
authors = ["Lucien Cartier-Tilet <lucien@phundrak.com>"]
|
||||
homepage = "https://github.com/Phundrak/georm"
|
||||
repository = "https://github.com/Phundrak/georm"
|
||||
@ -26,7 +30,7 @@ version.workspace = true
|
||||
georm-macros = { version = "=0.1.1", path = "georm-macros" }
|
||||
|
||||
[workspace.dependencies.sqlx]
|
||||
version = "0.8.3"
|
||||
version = "0.8.6"
|
||||
default-features = false
|
||||
features = ["postgres", "runtime-tokio", "macros", "migrate"]
|
||||
|
||||
|
766
README.md
766
README.md
@ -1,116 +1,281 @@
|
||||
<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
|
||||
- **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
|
||||
- **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<()> {
|
||||
// Create an author
|
||||
let author = Author {
|
||||
id: 0, // Will be auto-generated
|
||||
name: "Jane Doe".to_string(),
|
||||
email: "jane@example.com".to_string(),
|
||||
};
|
||||
let author = author.create(pool).await?;
|
||||
|
||||
// Create a post
|
||||
let post = Post {
|
||||
id: 0,
|
||||
title: "Hello, Georm!".to_string(),
|
||||
content: "This is my first post using Georm.".to_string(),
|
||||
published: false,
|
||||
author_id: author.id,
|
||||
created_at: chrono::Utc::now(),
|
||||
};
|
||||
let post = post.create(pool).await?;
|
||||
|
||||
// Find all posts
|
||||
let all_posts = Post::find_all(pool).await?;
|
||||
|
||||
// Get the post's author
|
||||
let post_author = post.get_author(pool).await?;
|
||||
|
||||
println!("Post '{}' by {}", post.title, post_author.name);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Defaultable Fields
|
||||
|
||||
For fields with database defaults or auto-generated values, use the `defaultable` attribute:
|
||||
|
||||
```rust
|
||||
#[derive(Georm)]
|
||||
#[georm(table = "posts")]
|
||||
pub struct Post {
|
||||
#[georm(id, defaultable)]
|
||||
pub id: i32, // Auto-generated serial
|
||||
pub title: String,
|
||||
#[georm(defaultable)]
|
||||
pub published: bool, // Has database default (false)
|
||||
#[georm(defaultable)]
|
||||
pub created_at: chrono::DateTime<chrono::Utc>, // DEFAULT NOW()
|
||||
pub author_id: i32,
|
||||
}
|
||||
```
|
||||
|
||||
This generates a `PostDefault` struct for easier creation:
|
||||
|
||||
```rust
|
||||
use georm::Defaultable;
|
||||
|
||||
let post_default = PostDefault {
|
||||
id: None, // Let database auto-generate
|
||||
title: "My Post".to_string(),
|
||||
published: None, // Use database default
|
||||
created_at: None, // Use database default (NOW())
|
||||
author_id: 42,
|
||||
};
|
||||
|
||||
let created_post = post_default.create(pool).await?;
|
||||
```
|
||||
|
||||
### Relationships
|
||||
|
||||
Georm supports comprehensive relationship modeling with two approaches: field-level relationships for foreign keys and struct-level relationships for reverse lookups.
|
||||
|
||||
#### Field-Level Relationships (Foreign Keys)
|
||||
|
||||
Use the `relation` attribute on foreign key fields to generate lookup methods:
|
||||
|
||||
```rust
|
||||
#[derive(Georm)]
|
||||
#[georm(table = "posts")]
|
||||
pub struct Post {
|
||||
#[georm(id)]
|
||||
pub id: i32,
|
||||
pub title: String,
|
||||
#[georm(relation = {
|
||||
entity = Author, // Target entity type
|
||||
table = "authors", // Target table name
|
||||
name = "author", // Method name (generates get_author)
|
||||
remote_id = "id", // Target table's key column (default: "id")
|
||||
nullable = false // Whether relationship can be null (default: false)
|
||||
})]
|
||||
pub author_id: i32,
|
||||
}
|
||||
```
|
||||
|
||||
**Generated method**: `post.get_author(pool).await? -> Author`
|
||||
|
||||
For nullable relationships:
|
||||
|
||||
```rust
|
||||
#[derive(Georm)]
|
||||
#[georm(table = "posts")]
|
||||
pub struct Post {
|
||||
#[georm(id)]
|
||||
pub id: i32,
|
||||
pub title: String,
|
||||
#[georm(relation = {
|
||||
entity = Category,
|
||||
table = "categories",
|
||||
name = "category",
|
||||
nullable = true // Allows NULL values
|
||||
})]
|
||||
pub category_id: Option<i32>,
|
||||
}
|
||||
```
|
||||
|
||||
**Generated method**: `post.get_category(pool).await? -> Option<Category>`
|
||||
|
||||
#### Struct-Level Relationships (Reverse Lookups)
|
||||
|
||||
Define relationships at the struct level to query related entities that reference this entity:
|
||||
|
||||
##### One-to-One Relationships
|
||||
|
||||
```rust
|
||||
#[derive(Georm)]
|
||||
#[georm(
|
||||
table = "users",
|
||||
one_to_one = [{
|
||||
entity = Profile, // Related entity type
|
||||
name = "profile", // Method name (generates get_profile)
|
||||
table = "profiles", // Related table name
|
||||
remote_id = "user_id", // Foreign key in related table
|
||||
}]
|
||||
)]
|
||||
pub struct User {
|
||||
#[georm(id)]
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
}
|
||||
```
|
||||
|
||||
**Generated method**: `user.get_profile(pool).await? -> Option<Profile>`
|
||||
|
||||
##### One-to-Many Relationships
|
||||
|
||||
```rust
|
||||
#[derive(Georm)]
|
||||
#[georm(
|
||||
table = "authors",
|
||||
one_to_many = [{
|
||||
entity = Post, // Related entity type
|
||||
name = "posts", // Method name (generates get_posts)
|
||||
table = "posts", // Related table name
|
||||
remote_id = "author_id" // Foreign key in related table
|
||||
}, {
|
||||
entity = Comment, // Multiple relationships allowed
|
||||
name = "comments",
|
||||
table = "comments",
|
||||
remote_id = "author_id"
|
||||
}]
|
||||
)]
|
||||
pub struct Author {
|
||||
#[georm(id)]
|
||||
pub id: i32,
|
||||
@ -118,38 +283,457 @@ 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:
|
||||
|
||||
```rust
|
||||
// Query operations
|
||||
Post::find_all(pool).await?; // Find all posts
|
||||
Post::find(pool, &post_id).await?; // Find by ID
|
||||
|
||||
// Mutation operations
|
||||
post.create(pool).await?; // Insert new record
|
||||
post.update(pool).await?; // Update existing record
|
||||
post.create_or_update(pool).await?; // Upsert operation
|
||||
post.delete(pool).await?; // Delete this record
|
||||
Post::delete_by_id(pool, &post_id).await?; // Delete by ID
|
||||
|
||||
// Utility
|
||||
post.get_id(); // Get entity ID
|
||||
```
|
||||
|
||||
### Defaultable Operations
|
||||
|
||||
Entities with defaultable fields get a companion `<Entity>Default` struct:
|
||||
|
||||
```rust
|
||||
// Create with defaults
|
||||
post_default.create(pool).await?;
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Attributes Reference
|
||||
|
||||
#### Struct-level attributes
|
||||
|
||||
```rust
|
||||
#[georm(
|
||||
table = "table_name", // Required: database table name
|
||||
one_to_one = [{ /* ... */ }], // Optional: one-to-one relationships
|
||||
one_to_many = [{ /* ... */ }], // Optional: one-to-many relationships
|
||||
many_to_many = [{ /* ... */ }] // Optional: many-to-many relationships
|
||||
)]
|
||||
```
|
||||
|
||||
#### Field-level attributes
|
||||
|
||||
```rust
|
||||
#[georm(id)] // Mark as primary key
|
||||
#[georm(defaultable)] // Mark as defaultable field
|
||||
#[georm(relation = { /* ... */ })] // Define relationship
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
Georm is designed for zero runtime overhead:
|
||||
|
||||
- **Compile-time queries**: All SQL is verified at compile time
|
||||
- **No reflection**: Direct field access, no runtime introspection
|
||||
- **Minimal allocations**: Efficient use of owned vs borrowed data
|
||||
- **SQLx integration**: Leverages SQLx's optimized PostgreSQL driver
|
||||
|
||||
## Examples
|
||||
|
||||
### Comprehensive Example
|
||||
|
||||
For an example showcasing user management, comments, and follower relationships, see the example in `examples/postgres/users-comments-and-followers/`. This example demonstrates:
|
||||
|
||||
- User management and profile management
|
||||
- Comment system with user associations
|
||||
- Follower/following relationships (many-to-many)
|
||||
- Interactive CLI interface with CRUD operations
|
||||
- Database migrations and schema setup
|
||||
|
||||
To run the example:
|
||||
|
||||
```bash
|
||||
# Set up your database
|
||||
export DATABASE_URL="postgres://username:password@localhost/georm_example"
|
||||
|
||||
# Run migrations
|
||||
cargo sqlx migrate run
|
||||
|
||||
# Run the example
|
||||
cd examples/postgres/users-comments-and-followers
|
||||
cargo run help # For a list of all available actions
|
||||
```
|
||||
|
||||
## Comparison
|
||||
|
||||
| Feature | Georm | SeaORM | Diesel |
|
||||
|----------------------|-------|--------|--------|
|
||||
| Compile-time safety | ✅ | ✅ | ✅ |
|
||||
| Relationship support | ✅ | ✅ | ✅ |
|
||||
| Async support | ✅ | ✅ | ⚠️ |
|
||||
| Learning curve | Low | Medium | High |
|
||||
| Macro simplicity | ✅ | ❌ | ❌ |
|
||||
| Advanced queries | ❌ | ✅ | ✅ |
|
||||
|
||||
## Roadmap
|
||||
|
||||
### High Priority
|
||||
- **Transaction Support**: Comprehensive transaction handling with atomic operations
|
||||
|
||||
### Medium Priority
|
||||
- **Multi-Database Support**: MySQL and SQLite support with feature flags
|
||||
- **Field-Based Queries**: Generate `find_by_{field_name}` methods that return `Vec<T>` for regular fields or `Option<T>` for unique fields
|
||||
- **Relationship Optimization**: Eager loading and N+1 query prevention
|
||||
- **Composite Primary Keys**: Multi-field primary key support
|
||||
- **Soft Delete**: Optional soft delete with `deleted_at` timestamps
|
||||
|
||||
### Lower Priority
|
||||
- **Migration Support**: Schema generation and evolution utilities
|
||||
- **Enhanced Error Handling**: Custom error types with better context
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
|
||||
|
||||
### Development Setup
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- **Rust 1.86+**: Georm uses modern Rust features and follows the MSRV specified in `rust-toolchain.toml`
|
||||
- **PostgreSQL 12+**: Required for running tests and development
|
||||
- **Git**: For version control
|
||||
- **Jujutsu**: For version control (alternative to Git)
|
||||
|
||||
#### Required Tools
|
||||
|
||||
The following tools are used in the development workflow:
|
||||
|
||||
- **[just](https://github.com/casey/just)**: Task runner for common development commands
|
||||
- **[cargo-deny](https://github.com/EmbarkStudios/cargo-deny)**: License and security auditing
|
||||
- **[sqlx-cli](https://github.com/launchbadge/sqlx/tree/main/sqlx-cli)**: Database migrations and management
|
||||
- **[bacon](https://github.com/Canop/bacon)**: Background code checker (optional but recommended)
|
||||
|
||||
Install these tools:
|
||||
|
||||
```bash
|
||||
# Install just (task runner)
|
||||
cargo install just
|
||||
|
||||
# Install cargo-deny (for auditing)
|
||||
cargo install cargo-deny
|
||||
|
||||
# Install sqlx-cli (for database management)
|
||||
cargo install sqlx-cli --no-default-features --features native-tls,postgres
|
||||
|
||||
# Install bacon (optional, for live feedback)
|
||||
cargo install bacon
|
||||
```
|
||||
|
||||
#### Quick Start
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/Phundrak/georm.git
|
||||
cd georm
|
||||
|
||||
# Set up your PostgreSQL database and set DATABASE_URL
|
||||
export DATABASE_URL="postgres://username:password@localhost/georm_test"
|
||||
|
||||
# Run migrations
|
||||
just migrate
|
||||
|
||||
# Run all tests
|
||||
just test
|
||||
|
||||
# Run linting
|
||||
just lint
|
||||
|
||||
# Run security audit
|
||||
just audit
|
||||
|
||||
# Run all checks (format, lint, audit, test)
|
||||
just check-all
|
||||
```
|
||||
|
||||
#### Available Commands (via just)
|
||||
|
||||
```bash
|
||||
just # Default: run linting
|
||||
just build # Build the project
|
||||
just build-release # Build in release mode
|
||||
just test # Run all tests
|
||||
just lint # Run clippy linting
|
||||
just audit # Run security and license audit
|
||||
just migrate # Run database migrations
|
||||
just format # Format all code
|
||||
just format-check # Check code formatting
|
||||
just check-all # Run all checks (format, lint, audit, test)
|
||||
just clean # Clean build artifacts
|
||||
```
|
||||
|
||||
#### Running Specific Tests
|
||||
|
||||
```bash
|
||||
# Run tests for a specific module
|
||||
cargo test --test simple_struct
|
||||
cargo test --test defaultable_struct
|
||||
cargo test --test m2m_relationship
|
||||
|
||||
# Run tests with output
|
||||
cargo test -- --nocapture
|
||||
|
||||
# Run a specific test function
|
||||
cargo test defaultable_struct_should_exist
|
||||
```
|
||||
|
||||
#### Development with Bacon (Optional)
|
||||
|
||||
For continuous feedback during development:
|
||||
|
||||
```bash
|
||||
# Run clippy continuously
|
||||
bacon
|
||||
|
||||
# Run tests continuously
|
||||
bacon test
|
||||
|
||||
# Build docs continuously
|
||||
bacon doc
|
||||
```
|
||||
|
||||
#### Devenv Development Environment (Optional)
|
||||
|
||||
If you use [Nix](https://nixos.org/), you can use the provided devenv configuration for a reproducible development environment:
|
||||
|
||||
```bash
|
||||
# Enter the development shell with all tools pre-installed
|
||||
devenv shell
|
||||
|
||||
# Or use direnv for automatic environment activation
|
||||
direnv allow
|
||||
```
|
||||
|
||||
The devenv configuration provides:
|
||||
- Exact Rust version (1.86) with required components
|
||||
- All development tools (just, cargo-deny, sqlx-cli, bacon)
|
||||
- LSP support (rust-analyzer)
|
||||
- SQL tooling (sqls for SQL language server)
|
||||
- PostgreSQL database for development
|
||||
|
||||
**Devenv configuration:**
|
||||
- **Rust toolchain**: Specified version with rustfmt, clippy, and rust-analyzer
|
||||
- **Development tools**: just, cargo-deny, sqlx-cli, bacon
|
||||
- **SQL tools**: sqls (SQL language server)
|
||||
- **Database**: PostgreSQL with automatic setup
|
||||
- **Platform support**: Cross-platform (Linux, macOS, etc.)
|
||||
|
||||
#### Database Setup for Tests
|
||||
|
||||
Tests require a PostgreSQL database. Set up a test database:
|
||||
|
||||
```sql
|
||||
-- Connect to PostgreSQL as superuser
|
||||
CREATE DATABASE georm_test;
|
||||
CREATE USER georm_user WITH PASSWORD 'georm_password';
|
||||
GRANT ALL PRIVILEGES ON DATABASE georm_test TO georm_user;
|
||||
```
|
||||
|
||||
Set the environment variable:
|
||||
|
||||
```bash
|
||||
export DATABASE_URL="postgres://georm_user:georm_password@localhost/georm_test"
|
||||
```
|
||||
|
||||
#### IDE Setup
|
||||
|
||||
- Ensure `rust-analyzer` is configured
|
||||
- Set up PostgreSQL connection for SQL syntax highlighting
|
||||
|
||||
#### Code Style
|
||||
|
||||
The project uses standard Rust formatting:
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
just format
|
||||
|
||||
# Check formatting (CI)
|
||||
just format-check
|
||||
```
|
||||
|
||||
Clippy linting is enforced:
|
||||
|
||||
```bash
|
||||
# Run linting
|
||||
just lint
|
||||
|
||||
# Fix auto-fixable lints
|
||||
cargo clippy --fix
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Licensed under either of
|
||||
|
||||
* MIT License ([LICENSE-MIT](LICENSE-MIT.md) or http://opensource.org/licenses/MIT)
|
||||
* GNU General Public License v3.0 ([LICENSE-GPL](LICENSE-GPL.md) or https://www.gnu.org/licenses/gpl-3.0.html)
|
||||
|
||||
at your option.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- Built on top of the excellent [SQLx](https://github.com/launchbadge/sqlx) library
|
||||
- Inspired by [Hibernate](https://hibernate.org/)
|
||||
|
BIN
assets/logo.png
Normal file
BIN
assets/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 64 KiB |
1272
assets/logo.svg
Normal file
1272
assets/logo.svg
Normal file
File diff suppressed because it is too large
Load Diff
After Width: | Height: | Size: 59 KiB |
123
devenv.lock
Normal file
123
devenv.lock
Normal file
@ -0,0 +1,123 @@
|
||||
{
|
||||
"nodes": {
|
||||
"devenv": {
|
||||
"locked": {
|
||||
"dir": "src/modules",
|
||||
"lastModified": 1749054588,
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "b6be42d9e6f6053be1d180e4a4fb95e0aa9a8424",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"dir": "src/modules",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1747046372,
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1747372754,
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "80479b6ec16fefd9c1db3ea13aeb038c60530f46",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"git-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1746807397,
|
||||
"owner": "cachix",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"rev": "c5208b594838ea8e6cca5997fbf784b7cca1ca90",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "rolling",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"git-hooks": "git-hooks",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"pre-commit-hooks": [
|
||||
"git-hooks"
|
||||
],
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1749091064,
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "12419593ce78f2e8e1e89a373c6515885e218acb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
36
devenv.nix
Normal file
36
devenv.nix
Normal file
@ -0,0 +1,36 @@
|
||||
{ pkgs, nixpkgs, rust-overlay, ... }:
|
||||
let
|
||||
overlays = [ (import rust-overlay) ];
|
||||
system = pkgs.stdenv.system;
|
||||
rustPkgs = import nixpkgs { inherit system overlays; };
|
||||
rustVersion = (rustPkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml);
|
||||
in {
|
||||
dotenv.enable = true;
|
||||
|
||||
packages = with rustPkgs; [
|
||||
bacon
|
||||
cargo-deny
|
||||
just
|
||||
postgresql
|
||||
sqls
|
||||
sqlx-cli
|
||||
(rustVersion.override {
|
||||
extensions = [
|
||||
"rust-src"
|
||||
"rustfmt"
|
||||
"clippy"
|
||||
"rust-analyzer"
|
||||
];
|
||||
})
|
||||
];
|
||||
|
||||
services.postgres = {
|
||||
enable = true;
|
||||
listen_addresses = "localhost";
|
||||
initialScript = ''
|
||||
CREATE USER georm WITH PASSWORD 'georm' SUPERUSER;
|
||||
CREATE DATABASE georm OWNER georm;
|
||||
GRANT ALL PRIVILEGES ON DATABASE georm TO georm;
|
||||
'';
|
||||
};
|
||||
}
|
8
devenv.yaml
Normal file
8
devenv.yaml
Normal file
@ -0,0 +1,8 @@
|
||||
inputs:
|
||||
rust-overlay:
|
||||
url: github:oxalica/rust-overlay
|
||||
inputs:
|
||||
nixpkgs:
|
||||
follows: nixpkgs
|
||||
nixpkgs:
|
||||
url: github:cachix/devenv-nixpkgs/rolling
|
@ -1,33 +0,0 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
container_name: georm-backend-db
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
ports:
|
||||
- 127.0.0.1:5432:5432
|
||||
volumes:
|
||||
- georm_backend_db_data:/var/lib/postgresql/data
|
||||
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:8
|
||||
restart: unless-stopped
|
||||
container_name: georm-backend-pgadmin
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: admin@example.com
|
||||
PGADMIN_DEFAULT_PASSWORD: password
|
||||
PGADMIN_DISABLE_POSTFIX: true
|
||||
PGADMIN_CONFIG_SERVER_MODE: 'False'
|
||||
ports:
|
||||
- 127.0.0.1:8080:80
|
||||
volumes:
|
||||
- georm_backend_pgadmin_data:/var/lib/pgadmin
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
volumes:
|
||||
georm_backend_db_data:
|
||||
georm_backend_pgadmin_data:
|
@ -1,14 +0,0 @@
|
||||
default: start
|
||||
|
||||
start:
|
||||
docker compose -f compose.dev.yml up -d
|
||||
|
||||
stop:
|
||||
docker compose -f compose.dev.yml down
|
||||
|
||||
logs:
|
||||
docker compose -f compose.dev.yml logs -f
|
||||
|
||||
## Local Variables:
|
||||
## mode: makefile
|
||||
## End:
|
14
examples/postgres/users-comments-and-followers/Cargo.toml
Normal file
14
examples/postgres/users-comments-and-followers/Cargo.toml
Normal file
@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "georm-users-comments-and-followers"
|
||||
workspace = "../../../"
|
||||
publish = false
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
georm = { path = "../../.." }
|
||||
sqlx = { workspace = true }
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
inquire = "0.7.5"
|
||||
thiserror = "2.0.11"
|
||||
tokio = { version = "1.43.0", features = ["full"] }
|
@ -0,0 +1,129 @@
|
||||
use super::{Executable, Result};
|
||||
use crate::{
|
||||
errors::UserInputError,
|
||||
models::{Comment, CommentDefault, User},
|
||||
};
|
||||
use clap::{Args, Subcommand};
|
||||
use georm::{Defaultable, Georm};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Args, Clone)]
|
||||
pub struct CommentArgs {
|
||||
#[command(subcommand)]
|
||||
pub command: CommentCommand,
|
||||
}
|
||||
|
||||
impl Executable for CommentArgs {
|
||||
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
|
||||
self.command.execute(pool).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum CommentCommand {
|
||||
Create {
|
||||
text: Option<String>,
|
||||
username: Option<String>,
|
||||
},
|
||||
Remove {
|
||||
id: Option<i32>,
|
||||
},
|
||||
RemoveFromUser {
|
||||
username: Option<String>,
|
||||
},
|
||||
ListFromUser {
|
||||
username: Option<String>,
|
||||
},
|
||||
List,
|
||||
}
|
||||
|
||||
impl Executable for CommentCommand {
|
||||
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
|
||||
match self {
|
||||
CommentCommand::Create { text, username } => {
|
||||
create_comment(username.clone(), text.clone(), pool).await
|
||||
}
|
||||
CommentCommand::Remove { id } => remove_comment(*id, pool).await,
|
||||
CommentCommand::RemoveFromUser { username } => {
|
||||
remove_user_comment(username.clone(), pool).await
|
||||
}
|
||||
CommentCommand::ListFromUser { username } => {
|
||||
list_user_comments(username.clone(), pool).await
|
||||
}
|
||||
CommentCommand::List => list_comments(pool).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_comment(
|
||||
username: Option<String>,
|
||||
text: Option<String>,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result {
|
||||
let prompt = "Who is creating the comment?";
|
||||
let user = User::get_user_by_username_or_select(username.as_deref(), prompt, pool).await?;
|
||||
let content = match text {
|
||||
Some(text) => text,
|
||||
None => inquire::Text::new("Content of the comment:")
|
||||
.prompt()
|
||||
.map_err(UserInputError::InquireError)?,
|
||||
};
|
||||
let comment = CommentDefault {
|
||||
author_id: user.id,
|
||||
content,
|
||||
id: None,
|
||||
};
|
||||
let comment = comment.create(pool).await?;
|
||||
println!("Successfuly created comment:\n{comment}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_comment(id: Option<i32>, pool: &sqlx::PgPool) -> Result {
|
||||
let prompt = "Select the comment to remove:";
|
||||
let comment = match id {
|
||||
Some(id) => Comment::find(pool, &id)
|
||||
.await
|
||||
.map_err(UserInputError::DatabaseError)?
|
||||
.ok_or(UserInputError::CommentDoesNotExist)?,
|
||||
None => Comment::select_comment(prompt, pool).await?,
|
||||
};
|
||||
comment.delete(pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_user_comment(username: Option<String>, pool: &sqlx::PgPool) -> Result {
|
||||
let prompt = "Select user whose comment you want to delete:";
|
||||
let user = User::get_user_by_username_or_select(username.as_deref(), prompt, pool).await?;
|
||||
let comments: HashMap<String, Comment> = user
|
||||
.get_comments(pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|comment| (comment.content.clone(), comment))
|
||||
.collect();
|
||||
let selected_comment_content =
|
||||
inquire::Select::new(prompt, comments.clone().into_keys().collect())
|
||||
.prompt()
|
||||
.map_err(UserInputError::InquireError)?;
|
||||
let comment: &Comment = comments.get(&selected_comment_content).unwrap();
|
||||
comment.delete(pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_user_comments(username: Option<String>, pool: &sqlx::PgPool) -> Result {
|
||||
let prompt = "User whose comment you want to list:";
|
||||
let user = User::get_user_by_username_or_select(username.as_deref(), prompt, pool).await?;
|
||||
println!("List of comments from user:\n");
|
||||
for comment in user.get_comments(pool).await? {
|
||||
println!("{comment}\n");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_comments(pool: &sqlx::PgPool) -> Result {
|
||||
let comments = Comment::find_all(pool).await?;
|
||||
println!("List of all comments:\n");
|
||||
for comment in comments {
|
||||
println!("{comment}\n")
|
||||
}
|
||||
Ok(())
|
||||
}
|
@ -0,0 +1,134 @@
|
||||
use super::{Executable, Result};
|
||||
use crate::models::{FollowerDefault, User};
|
||||
use clap::{Args, Subcommand};
|
||||
use georm::Defaultable;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Args, Clone)]
|
||||
pub struct FollowersArgs {
|
||||
#[command(subcommand)]
|
||||
pub command: FollowersCommand,
|
||||
}
|
||||
|
||||
impl Executable for FollowersArgs {
|
||||
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
|
||||
self.command.execute(pool).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum FollowersCommand {
|
||||
Follow {
|
||||
follower: Option<String>,
|
||||
followed: Option<String>,
|
||||
},
|
||||
Unfollow {
|
||||
follower: Option<String>,
|
||||
},
|
||||
ListFollowers {
|
||||
user: Option<String>,
|
||||
},
|
||||
ListFollowed {
|
||||
user: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Executable for FollowersCommand {
|
||||
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
|
||||
match self {
|
||||
FollowersCommand::Follow { follower, followed } => {
|
||||
follow_user(follower.clone(), followed.clone(), pool).await
|
||||
}
|
||||
FollowersCommand::Unfollow { follower } => unfollow_user(follower.clone(), pool).await,
|
||||
FollowersCommand::ListFollowers { user } => {
|
||||
list_user_followers(user.clone(), pool).await
|
||||
}
|
||||
FollowersCommand::ListFollowed { user } => list_user_followed(user.clone(), pool).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn follow_user(
|
||||
follower: Option<String>,
|
||||
followed: Option<String>,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result {
|
||||
let follower = User::get_user_by_username_or_select(
|
||||
follower.as_deref(),
|
||||
"Select who will be following someone:",
|
||||
pool,
|
||||
)
|
||||
.await?;
|
||||
let followed = User::get_user_by_username_or_select(
|
||||
followed.as_deref(),
|
||||
"Select who will be followed:",
|
||||
pool,
|
||||
)
|
||||
.await?;
|
||||
let follow = FollowerDefault {
|
||||
id: None,
|
||||
follower: follower.id,
|
||||
followed: followed.id,
|
||||
};
|
||||
follow.create(pool).await?;
|
||||
println!("User {follower} now follows {followed}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn unfollow_user(follower: Option<String>, pool: &sqlx::PgPool) -> Result {
|
||||
let follower =
|
||||
User::get_user_by_username_or_select(follower.as_deref(), "Select who is following", pool)
|
||||
.await?;
|
||||
let followed_list: HashMap<String, User> = follower
|
||||
.get_followed(pool)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|person| (person.username.clone(), person.clone()))
|
||||
.collect();
|
||||
let followed = inquire::Select::new(
|
||||
"Who to unfollow?",
|
||||
followed_list.clone().into_keys().collect(),
|
||||
)
|
||||
.prompt()
|
||||
.unwrap();
|
||||
let followed = followed_list.get(&followed).unwrap();
|
||||
sqlx::query!(
|
||||
"DELETE FROM Followers WHERE follower = $1 AND followed = $2",
|
||||
follower.id,
|
||||
followed.id
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
println!("User {follower} unfollowed {followed}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_user_followers(user: Option<String>, pool: &sqlx::PgPool) -> Result {
|
||||
let user = User::get_user_by_username_or_select(
|
||||
user.as_deref(),
|
||||
"Whose followers do you want to display?",
|
||||
pool,
|
||||
)
|
||||
.await?;
|
||||
println!("List of followers of {user}:\n");
|
||||
user.get_followers(pool)
|
||||
.await?
|
||||
.iter()
|
||||
.for_each(|person| println!("{person}"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_user_followed(user: Option<String>, pool: &sqlx::PgPool) -> Result {
|
||||
let user = User::get_user_by_username_or_select(
|
||||
user.as_deref(),
|
||||
"Whose follows do you want to display?",
|
||||
pool,
|
||||
)
|
||||
.await?;
|
||||
println!("List of people followed by {user}:\n");
|
||||
user.get_followed(pool)
|
||||
.await?
|
||||
.iter()
|
||||
.for_each(|person| println!("{person}"));
|
||||
Ok(())
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
mod comments;
|
||||
mod followers;
|
||||
mod users;
|
||||
|
||||
type Result = crate::Result<()>;
|
||||
|
||||
pub trait Executable {
|
||||
async fn execute(&self, pool: &sqlx::PgPool) -> Result;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Parser)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
}
|
||||
|
||||
impl Executable for Cli {
|
||||
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
|
||||
self.command.execute(pool).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum Commands {
|
||||
Users(users::UserArgs),
|
||||
Followers(followers::FollowersArgs),
|
||||
Comments(comments::CommentArgs),
|
||||
}
|
||||
|
||||
impl Executable for Commands {
|
||||
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
|
||||
match self {
|
||||
Commands::Users(user_args) => user_args.execute(pool).await,
|
||||
Commands::Followers(followers_args) => followers_args.execute(pool).await,
|
||||
Commands::Comments(comment_args) => comment_args.execute(pool).await,
|
||||
}
|
||||
}
|
||||
}
|
113
examples/postgres/users-comments-and-followers/src/cli/users.rs
Normal file
113
examples/postgres/users-comments-and-followers/src/cli/users.rs
Normal file
@ -0,0 +1,113 @@
|
||||
use super::{Executable, Result};
|
||||
use crate::{errors::UserInputError, models::User};
|
||||
use clap::{Args, Subcommand};
|
||||
use georm::Georm;
|
||||
use inquire::{max_length, min_length, required};
|
||||
|
||||
#[derive(Debug, Args, Clone)]
|
||||
pub struct UserArgs {
|
||||
#[command(subcommand)]
|
||||
pub command: UserCommand,
|
||||
}
|
||||
|
||||
impl Executable for UserArgs {
|
||||
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
|
||||
self.command.execute(pool).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum UserCommand {
|
||||
Add { username: Option<String> },
|
||||
Remove { id: Option<i32> },
|
||||
UpdateProfile { id: Option<i32> },
|
||||
List,
|
||||
}
|
||||
|
||||
impl Executable for UserCommand {
|
||||
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
|
||||
match self {
|
||||
UserCommand::Add { username } => add_user(username.clone(), pool).await,
|
||||
UserCommand::Remove { id } => remove_user(*id, pool).await,
|
||||
UserCommand::UpdateProfile { id } => update_profile(*id, pool).await,
|
||||
UserCommand::List => list_all(pool).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_user(username: Option<String>, pool: &sqlx::PgPool) -> Result {
|
||||
let username = match username {
|
||||
Some(username) => username,
|
||||
None => inquire::Text::new("Enter a username:")
|
||||
.prompt()
|
||||
.map_err(|_| UserInputError::InputRequired)?,
|
||||
};
|
||||
let user = User::try_new(&username, pool).await?;
|
||||
println!("The user {user} has been created!");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_user(id: Option<i32>, pool: &sqlx::PgPool) -> Result {
|
||||
let user = User::remove_interactive(id, pool).await?;
|
||||
println!("Removed user {user} from database");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_profile(id: Option<i32>, pool: &sqlx::PgPool) -> Result {
|
||||
let (user, mut profile) = User::update_profile(id, pool).await?;
|
||||
let update_display_name = inquire::Confirm::new(
|
||||
format!(
|
||||
"Your current display name is \"{}\", do you want to update it?",
|
||||
profile.get_display_name()
|
||||
)
|
||||
.as_str(),
|
||||
)
|
||||
.with_default(false)
|
||||
.prompt()
|
||||
.map_err(UserInputError::InquireError)?;
|
||||
let display_name = if update_display_name {
|
||||
Some(
|
||||
inquire::Text::new("New display name:")
|
||||
.with_help_message("Your display name should not exceed 100 characters")
|
||||
.with_validator(min_length!(3))
|
||||
.with_validator(max_length!(100))
|
||||
.with_validator(required!())
|
||||
.prompt()
|
||||
.map_err(UserInputError::InquireError)?,
|
||||
)
|
||||
} else {
|
||||
Some(profile.get_display_name())
|
||||
};
|
||||
let update_bio = inquire::Confirm::new(
|
||||
format!(
|
||||
"Your current bio is:\n===\n{}\n===\nDo you want to update it?",
|
||||
profile.get_bio()
|
||||
)
|
||||
.as_str(),
|
||||
)
|
||||
.with_default(false)
|
||||
.prompt()
|
||||
.map_err(UserInputError::InquireError)?;
|
||||
let bio = if update_bio {
|
||||
Some(
|
||||
inquire::Text::new("New bio:")
|
||||
.with_validator(min_length!(0))
|
||||
.prompt()
|
||||
.map_err(UserInputError::InquireError)?,
|
||||
)
|
||||
} else {
|
||||
Some(profile.get_bio())
|
||||
};
|
||||
let profile = profile.update_interactive(display_name, bio, pool).await?;
|
||||
println!("Profile of {user} updated:\n{profile}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_all(pool: &sqlx::PgPool) -> Result {
|
||||
let users = User::find_all(pool).await?;
|
||||
println!("List of users:\n");
|
||||
for user in users {
|
||||
println!("{user}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
15
examples/postgres/users-comments-and-followers/src/errors.rs
Normal file
15
examples/postgres/users-comments-and-followers/src/errors.rs
Normal file
@ -0,0 +1,15 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum UserInputError {
|
||||
#[error("Input required")]
|
||||
InputRequired,
|
||||
#[error("User ID does not exist")]
|
||||
UserDoesNotExist,
|
||||
#[error("Comment does not exist")]
|
||||
CommentDoesNotExist,
|
||||
#[error("Unexpected error, please try again")]
|
||||
InquireError(#[from] inquire::error::InquireError),
|
||||
#[error("Error from database: {0}")]
|
||||
DatabaseError(#[from] sqlx::Error),
|
||||
}
|
20
examples/postgres/users-comments-and-followers/src/main.rs
Normal file
20
examples/postgres/users-comments-and-followers/src/main.rs
Normal file
@ -0,0 +1,20 @@
|
||||
mod cli;
|
||||
mod errors;
|
||||
mod models;
|
||||
|
||||
use clap::Parser;
|
||||
use cli::{Cli, Executable};
|
||||
|
||||
type Result<T> = std::result::Result<T, errors::UserInputError>;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let args = Cli::parse();
|
||||
let url = std::env::var("DATABASE_URL").expect("Environment variable DATABASE_URL must be set");
|
||||
let pool =
|
||||
sqlx::PgPool::connect_lazy(url.as_str()).expect("Failed to create database connection");
|
||||
match args.command.execute(&pool).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => eprintln!("Error: {e}"),
|
||||
}
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
use super::User;
|
||||
use crate::{Result, errors::UserInputError};
|
||||
use georm::Georm;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Georm, Clone)]
|
||||
#[georm(table = "Comments")]
|
||||
pub struct Comment {
|
||||
#[georm(id, defaultable)]
|
||||
pub id: i32,
|
||||
#[georm(relation = {
|
||||
entity = User,
|
||||
table = "Users",
|
||||
name = "author"
|
||||
})]
|
||||
pub author_id: i32,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
impl Comment {
|
||||
pub async fn select_comment(prompt: &str, pool: &sqlx::PgPool) -> Result<Self> {
|
||||
let comments: HashMap<String, Self> = Self::find_all(pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|comment| (comment.content.clone(), comment))
|
||||
.collect();
|
||||
let comment_content = inquire::Select::new(prompt, comments.clone().into_keys().collect())
|
||||
.prompt()
|
||||
.map_err(UserInputError::InquireError)?;
|
||||
let comment: &Self = comments.get(&comment_content).unwrap();
|
||||
Ok(comment.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Comment {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"Comment:\nID:\t{}\nAuthor:\t{}\nContent:\t{}",
|
||||
self.id, self.author_id, self.content
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
use super::User;
|
||||
use georm::Georm;
|
||||
|
||||
#[derive(Debug, Clone, Georm)]
|
||||
#[georm(table = "Followers")]
|
||||
pub struct Follower {
|
||||
#[georm(id, defaultable)]
|
||||
pub id: i32,
|
||||
#[georm(relation = {
|
||||
entity = User,
|
||||
table = "Users",
|
||||
name = "followed"
|
||||
})]
|
||||
pub followed: i32,
|
||||
#[georm(relation = {
|
||||
entity = User,
|
||||
table = "Users",
|
||||
name = "follower"
|
||||
})]
|
||||
pub follower: i32,
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
mod users;
|
||||
pub use users::*;
|
||||
mod profiles;
|
||||
pub use profiles::*;
|
||||
mod comments;
|
||||
pub use comments::*;
|
||||
mod followers;
|
||||
pub use followers::*;
|
@ -0,0 +1,66 @@
|
||||
use super::User;
|
||||
use crate::{Result, errors::UserInputError};
|
||||
use georm::{Defaultable, Georm};
|
||||
|
||||
#[derive(Debug, Georm, Default)]
|
||||
#[georm(table = "Profiles")]
|
||||
pub struct Profile {
|
||||
#[georm(id, defaultable)]
|
||||
pub id: i32,
|
||||
#[georm(relation = {
|
||||
entity = User,
|
||||
table = "Users",
|
||||
name = "user",
|
||||
nullable = false
|
||||
})]
|
||||
pub user_id: i32,
|
||||
pub bio: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Profile {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"Display Name:\t{}\nBiography:\n{}\n",
|
||||
self.get_display_name(),
|
||||
self.get_bio()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub fn get_display_name(&self) -> String {
|
||||
self.display_name.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn get_bio(&self) -> String {
|
||||
self.bio.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub async fn try_new(user_id: i32, pool: &sqlx::PgPool) -> Result<Self> {
|
||||
let profile = ProfileDefault {
|
||||
user_id,
|
||||
id: None,
|
||||
bio: None,
|
||||
display_name: None,
|
||||
};
|
||||
profile
|
||||
.create(pool)
|
||||
.await
|
||||
.map_err(UserInputError::DatabaseError)
|
||||
}
|
||||
|
||||
pub async fn update_interactive(
|
||||
&mut self,
|
||||
display_name: Option<String>,
|
||||
bio: Option<String>,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result<Self> {
|
||||
self.display_name = display_name;
|
||||
self.bio = bio;
|
||||
self.update(pool)
|
||||
.await
|
||||
.map_err(UserInputError::DatabaseError)
|
||||
}
|
||||
}
|
@ -0,0 +1,128 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{Result, errors::UserInputError};
|
||||
use georm::{Defaultable, Georm};
|
||||
|
||||
use super::{Comment, Profile};
|
||||
|
||||
#[derive(Debug, Georm, Clone)]
|
||||
#[georm(
|
||||
table = "Users",
|
||||
one_to_one = [{
|
||||
name = "profile", remote_id = "user_id", table = "Profiles", entity = Profile
|
||||
}],
|
||||
one_to_many = [{
|
||||
name = "comments", remote_id = "author_id", table = "Comments", entity = Comment
|
||||
}],
|
||||
many_to_many = [{
|
||||
name = "followers",
|
||||
table = "Users",
|
||||
entity = User,
|
||||
link = { table = "Followers", from = "followed", to = "follower" }
|
||||
},
|
||||
{
|
||||
name = "followed",
|
||||
table = "Users",
|
||||
entity = User,
|
||||
link = { table = "Followers", from = "follower", to = "followed" }
|
||||
}
|
||||
]
|
||||
)]
|
||||
pub struct User {
|
||||
#[georm(id, defaultable)]
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for User {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{} (ID: {})", self.username, self.id)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for UserDefault {
|
||||
fn from(value: &str) -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
username: value.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl User {
|
||||
async fn select_user(prompt: &str, pool: &sqlx::PgPool) -> Result<Self> {
|
||||
let users: HashMap<String, Self> = Self::find_all(pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|user| (user.username.clone(), user))
|
||||
.collect();
|
||||
let username = inquire::Select::new(prompt, users.clone().into_keys().collect())
|
||||
.prompt()
|
||||
.map_err(UserInputError::InquireError)?;
|
||||
let user: &Self = users.get(&username).unwrap();
|
||||
Ok(user.clone())
|
||||
}
|
||||
|
||||
pub async fn get_user_by_id_or_select(
|
||||
id: Option<i32>,
|
||||
prompt: &str,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result<Self> {
|
||||
let user = match id {
|
||||
Some(id) => Self::find(pool, &id)
|
||||
.await?
|
||||
.ok_or(UserInputError::UserDoesNotExist)?,
|
||||
None => Self::select_user(prompt, pool).await?,
|
||||
};
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn get_user_by_username_or_select(
|
||||
username: Option<&str>,
|
||||
prompt: &str,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result<Self> {
|
||||
let user = match username {
|
||||
Some(username) => Self::find_by_username(username, pool)
|
||||
.await?
|
||||
.ok_or(UserInputError::UserDoesNotExist)?,
|
||||
None => Self::select_user(prompt, pool).await?,
|
||||
};
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn find_by_username(username: &str, pool: &sqlx::PgPool) -> Result<Option<Self>> {
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
"SELECT * FROM Users u WHERE u.username = $1",
|
||||
username
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(UserInputError::DatabaseError)
|
||||
}
|
||||
|
||||
pub async fn try_new(username: &str, pool: &sqlx::PgPool) -> Result<Self> {
|
||||
let user = UserDefault::from(username);
|
||||
user.create(pool)
|
||||
.await
|
||||
.map_err(UserInputError::DatabaseError)
|
||||
}
|
||||
|
||||
pub async fn remove_interactive(id: Option<i32>, pool: &sqlx::PgPool) -> Result<Self> {
|
||||
let prompt = "Select a user to delete:";
|
||||
let user = Self::get_user_by_id_or_select(id, prompt, pool).await?;
|
||||
let _ = user.clone().delete(pool).await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn update_profile(id: Option<i32>, pool: &sqlx::PgPool) -> Result<(User, Profile)> {
|
||||
let prompt = "Select the user whose profile you want to update";
|
||||
let user = Self::get_user_by_id_or_select(id, prompt, pool).await?;
|
||||
let profile = match user.get_profile(pool).await? {
|
||||
Some(profile) => profile,
|
||||
None => Profile::try_new(user.id, pool).await?,
|
||||
};
|
||||
Ok((user, profile))
|
||||
}
|
||||
}
|
96
flake.lock
generated
96
flake.lock
generated
@ -1,96 +0,0 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1738142207,
|
||||
"narHash": "sha256-NGqpVVxNAHwIicXpgaVqJEJWeyqzoQJ9oc8lnK9+WC4=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "9d3ae807ebd2981d593cddd0080856873139aa40",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1736320768,
|
||||
"narHash": "sha256-nIYdTAiKIGnFNugbomgBJR+Xv5F1ZQU+HfaBqJKroC0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "4bc9c909d9ac828a039f288cf872d16d38185db8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1738290352,
|
||||
"narHash": "sha256-YKOHUmc0Clm4tMV8grnxYL4IIwtjTayoq/3nqk0QM7k=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "b031b584125d33d23a0182f91ddbaf3ab4880236",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
37
flake.nix
37
flake.nix
@ -1,37 +0,0 @@
|
||||
{
|
||||
description = "Georm, a simple, opiniated SQLx ORM for PostgreSQL";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils, rust-overlay }:
|
||||
flake-utils.lib.eachSystem ["x86_64-linux"] (system:
|
||||
let
|
||||
overlays = [ (import rust-overlay) ];
|
||||
pkgs = import nixpkgs { inherit system overlays; };
|
||||
rustVersion = (pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml);
|
||||
in {
|
||||
devShell = with pkgs; mkShell {
|
||||
buildInputs = [
|
||||
bacon
|
||||
cargo
|
||||
cargo-deny
|
||||
just
|
||||
rust-analyzer
|
||||
(rustVersion.override {
|
||||
extensions = [
|
||||
"rust-src"
|
||||
"rustfmt"
|
||||
"clippy"
|
||||
"rust-analyzer"
|
||||
];
|
||||
})
|
||||
sqls
|
||||
sqlx-cli
|
||||
];
|
||||
};
|
||||
});
|
||||
}
|
148
georm-macros/src/georm/defaultable_struct.rs
Normal file
148
georm-macros/src/georm/defaultable_struct.rs
Normal file
@ -0,0 +1,148 @@
|
||||
//! This module creates the defaultable version of a structured derived with
|
||||
//! Georm. It creates a new struct named `<StructName>Default` where the fields
|
||||
//! marked as defaultable become an `Option<T>`, where `T` is the initial type
|
||||
//! of the field.
|
||||
//!
|
||||
//! The user does not have to mark a field defaultable if the field already has
|
||||
//! a type `Option<T>`. It is intended only for fields marked as `NOT NULL` in
|
||||
//! the database, but not required when creating the entity due to a `DEFAULT`
|
||||
//! or something similar. The type `<StructName>Default` implements the
|
||||
//! `Defaultable` trait.
|
||||
|
||||
use super::ir::{GeormField, GeormStructAttributes};
|
||||
use quote::quote;
|
||||
|
||||
fn create_defaultable_field(field: &GeormField) -> proc_macro2::TokenStream {
|
||||
let ident = &field.ident;
|
||||
let ty = &field.ty;
|
||||
let vis = &field.field.vis;
|
||||
|
||||
// If the field is marked as defaultable, wrap it in Option<T>
|
||||
// Otherwise, keep the original type
|
||||
let field_type = if field.defaultable {
|
||||
quote! { Option<#ty> }
|
||||
} else {
|
||||
quote! { #ty }
|
||||
};
|
||||
|
||||
quote! {
|
||||
#vis #ident: #field_type
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_defaultable_trait_impl(
|
||||
struct_name: &syn::Ident,
|
||||
defaultable_struct_name: &syn::Ident,
|
||||
struct_attrs: &GeormStructAttributes,
|
||||
fields: &[GeormField],
|
||||
) -> proc_macro2::TokenStream {
|
||||
let table = &struct_attrs.table;
|
||||
|
||||
// Find the ID field
|
||||
let id_field = fields
|
||||
.iter()
|
||||
.find(|field| field.id)
|
||||
.expect("Must have an ID field");
|
||||
let id_type = &id_field.ty;
|
||||
|
||||
// Separate defaultable and non-defaultable fields
|
||||
let non_defaultable_fields: Vec<_> = fields.iter().filter(|f| !f.defaultable).collect();
|
||||
let defaultable_fields: Vec<_> = fields.iter().filter(|f| f.defaultable).collect();
|
||||
|
||||
// Build static parts for non-defaultable fields
|
||||
let static_field_names: Vec<String> = non_defaultable_fields
|
||||
.iter()
|
||||
.map(|f| f.ident.to_string())
|
||||
.collect();
|
||||
let static_field_idents: Vec<&syn::Ident> =
|
||||
non_defaultable_fields.iter().map(|f| &f.ident).collect();
|
||||
|
||||
// Generate field checks for defaultable fields
|
||||
let mut field_checks = Vec::new();
|
||||
let mut bind_checks = Vec::new();
|
||||
|
||||
for field in &defaultable_fields {
|
||||
let field_name = field.ident.to_string();
|
||||
let field_ident = &field.ident;
|
||||
|
||||
field_checks.push(quote! {
|
||||
if self.#field_ident.is_some() {
|
||||
dynamic_fields.push(#field_name);
|
||||
}
|
||||
});
|
||||
|
||||
bind_checks.push(quote! {
|
||||
if let Some(ref value) = self.#field_ident {
|
||||
query_builder = query_builder.bind(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
quote! {
|
||||
impl ::georm::Defaultable<#id_type, #struct_name> for #defaultable_struct_name {
|
||||
async fn create(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<#struct_name> {
|
||||
let mut dynamic_fields = Vec::new();
|
||||
|
||||
#(#field_checks)*
|
||||
|
||||
let mut all_fields = vec![#(#static_field_names),*];
|
||||
all_fields.extend(dynamic_fields);
|
||||
|
||||
let placeholders: Vec<String> = (1..=all_fields.len())
|
||||
.map(|i| format!("${}", i))
|
||||
.collect();
|
||||
|
||||
let query = format!(
|
||||
"INSERT INTO {} ({}) VALUES ({}) RETURNING *",
|
||||
#table,
|
||||
all_fields.join(", "),
|
||||
placeholders.join(", ")
|
||||
);
|
||||
|
||||
let mut query_builder = ::sqlx::query_as::<_, #struct_name>(&query);
|
||||
|
||||
// Bind non-defaultable fields first
|
||||
#(query_builder = query_builder.bind(&self.#static_field_idents);)*
|
||||
|
||||
// Then bind defaultable fields that have values
|
||||
#(#bind_checks)*
|
||||
|
||||
query_builder.fetch_one(pool).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn derive_defaultable_struct(
|
||||
ast: &syn::DeriveInput,
|
||||
struct_attrs: &GeormStructAttributes,
|
||||
fields: &[GeormField],
|
||||
) -> proc_macro2::TokenStream {
|
||||
// Only generate if there are defaultable fields
|
||||
if fields.iter().all(|field| !field.defaultable) {
|
||||
return quote! {};
|
||||
}
|
||||
|
||||
let struct_name = &ast.ident;
|
||||
let vis = &ast.vis;
|
||||
let defaultable_struct_name = quote::format_ident!("{}Default", struct_name);
|
||||
|
||||
let defaultable_fields: Vec<proc_macro2::TokenStream> =
|
||||
fields.iter().map(create_defaultable_field).collect();
|
||||
|
||||
let trait_impl = generate_defaultable_trait_impl(
|
||||
struct_name,
|
||||
&defaultable_struct_name,
|
||||
struct_attrs,
|
||||
fields,
|
||||
);
|
||||
|
||||
quote! {
|
||||
#[derive(Debug, Clone)]
|
||||
#vis struct #defaultable_struct_name {
|
||||
#(#defaultable_fields),*
|
||||
}
|
||||
|
||||
#trait_impl
|
||||
}
|
||||
}
|
@ -1,195 +0,0 @@
|
||||
use quote::quote;
|
||||
|
||||
#[derive(deluxe::ExtractAttributes)]
|
||||
#[deluxe(attributes(georm))]
|
||||
pub struct GeormStructAttributes {
|
||||
pub table: String,
|
||||
#[deluxe(default = Vec::new())]
|
||||
pub one_to_many: Vec<O2MRelationship>,
|
||||
#[deluxe(default = Vec::new())]
|
||||
pub many_to_many: Vec<M2MRelationship>,
|
||||
}
|
||||
|
||||
#[derive(deluxe::ParseMetaItem)]
|
||||
pub struct O2MRelationship {
|
||||
pub name: String,
|
||||
pub remote_id: String,
|
||||
pub table: String,
|
||||
pub entity: syn::Type,
|
||||
}
|
||||
|
||||
impl From<&O2MRelationship> for proc_macro2::TokenStream {
|
||||
fn from(value: &O2MRelationship) -> Self {
|
||||
let query = format!(
|
||||
"SELECT * FROM {} WHERE {} = $1",
|
||||
value.table, value.remote_id
|
||||
);
|
||||
let entity = &value.entity;
|
||||
let function = syn::Ident::new(
|
||||
&format!("get_{}", value.name),
|
||||
proc_macro2::Span::call_site(),
|
||||
);
|
||||
quote! {
|
||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<#entity>> {
|
||||
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_all(pool).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(deluxe::ParseMetaItem, Clone)]
|
||||
pub struct M2MLink {
|
||||
pub table: String,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
#[derive(deluxe::ParseMetaItem)]
|
||||
pub struct M2MRelationship {
|
||||
pub name: String,
|
||||
pub entity: syn::Type,
|
||||
pub table: String,
|
||||
#[deluxe(default = String::from("id"))]
|
||||
pub remote_id: String,
|
||||
pub link: M2MLink,
|
||||
}
|
||||
|
||||
pub struct Identifier {
|
||||
pub table: String,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
pub struct M2MRelationshipComplete {
|
||||
pub name: String,
|
||||
pub entity: syn::Type,
|
||||
pub local: Identifier,
|
||||
pub remote: Identifier,
|
||||
pub link: M2MLink,
|
||||
}
|
||||
|
||||
impl M2MRelationshipComplete {
|
||||
pub fn new(other: &M2MRelationship, local_table: &String, local_id: String) -> Self {
|
||||
Self {
|
||||
name: other.name.clone(),
|
||||
entity: other.entity.clone(),
|
||||
link: other.link.clone(),
|
||||
local: Identifier {
|
||||
table: local_table.to_string(),
|
||||
id: local_id,
|
||||
},
|
||||
remote: Identifier {
|
||||
table: other.table.clone(),
|
||||
id: other.remote_id.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&M2MRelationshipComplete> for proc_macro2::TokenStream {
|
||||
fn from(value: &M2MRelationshipComplete) -> Self {
|
||||
let function = syn::Ident::new(
|
||||
&format!("get_{}", value.name),
|
||||
proc_macro2::Span::call_site(),
|
||||
);
|
||||
let entity = &value.entity;
|
||||
let query = format!(
|
||||
"SELECT remote.*
|
||||
FROM {} local
|
||||
JOIN {} link ON link.{} = local.{}
|
||||
JOIN {} remote ON link.{} = remote.{}
|
||||
WHERE local.{} = $1",
|
||||
value.local.table,
|
||||
value.link.table,
|
||||
value.link.from,
|
||||
value.local.id,
|
||||
value.remote.table,
|
||||
value.link.to,
|
||||
value.remote.id,
|
||||
value.local.id
|
||||
);
|
||||
quote! {
|
||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<#entity>> {
|
||||
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_all(pool).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(deluxe::ExtractAttributes, Clone)]
|
||||
#[deluxe(attributes(georm))]
|
||||
struct GeormFieldAttributes {
|
||||
#[deluxe(default = false)]
|
||||
pub id: bool,
|
||||
#[deluxe(default = None)]
|
||||
pub relation: Option<O2ORelationship>,
|
||||
}
|
||||
|
||||
#[derive(deluxe::ParseMetaItem, Clone, Debug)]
|
||||
pub struct O2ORelationship {
|
||||
pub entity: syn::Type,
|
||||
pub table: String,
|
||||
#[deluxe(default = String::from("id"))]
|
||||
pub remote_id: String,
|
||||
#[deluxe(default = false)]
|
||||
pub nullable: bool,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GeormField {
|
||||
pub ident: syn::Ident,
|
||||
pub field: syn::Field,
|
||||
pub ty: syn::Type,
|
||||
pub id: bool,
|
||||
pub relation: Option<O2ORelationship>,
|
||||
}
|
||||
|
||||
impl GeormField {
|
||||
pub fn new(field: &mut syn::Field) -> Self {
|
||||
let ident = field.clone().ident.unwrap();
|
||||
let ty = field.clone().ty;
|
||||
let attrs: GeormFieldAttributes =
|
||||
deluxe::extract_attributes(field).expect("Could not extract attributes from field");
|
||||
let GeormFieldAttributes { id, relation } = attrs;
|
||||
Self {
|
||||
ident,
|
||||
field: field.to_owned(),
|
||||
id,
|
||||
ty,
|
||||
relation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&GeormField> for proc_macro2::TokenStream {
|
||||
fn from(value: &GeormField) -> Self {
|
||||
let Some(relation) = value.relation.clone() else {
|
||||
return quote! {};
|
||||
};
|
||||
let function = syn::Ident::new(
|
||||
&format!("get_{}", relation.name),
|
||||
proc_macro2::Span::call_site(),
|
||||
);
|
||||
let entity = &relation.entity;
|
||||
let return_type = if relation.nullable {
|
||||
quote! { Option<#entity> }
|
||||
} else {
|
||||
quote! { #entity }
|
||||
};
|
||||
let query = format!(
|
||||
"SELECT * FROM {} WHERE {} = $1",
|
||||
relation.table, relation.remote_id
|
||||
);
|
||||
let local_ident = &value.field.ident;
|
||||
let fetch = if relation.nullable {
|
||||
quote! { fetch_optional }
|
||||
} else {
|
||||
quote! { fetch_one }
|
||||
};
|
||||
quote! {
|
||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<#return_type> {
|
||||
::sqlx::query_as!(#entity, #query, self.#local_ident).#fetch(pool).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
79
georm-macros/src/georm/ir/m2m_relationship.rs
Normal file
79
georm-macros/src/georm/ir/m2m_relationship.rs
Normal file
@ -0,0 +1,79 @@
|
||||
use quote::quote;
|
||||
|
||||
#[derive(deluxe::ParseMetaItem, Clone)]
|
||||
pub struct M2MLink {
|
||||
pub table: String,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
#[derive(deluxe::ParseMetaItem)]
|
||||
pub struct M2MRelationship {
|
||||
pub name: String,
|
||||
pub entity: syn::Type,
|
||||
pub table: String,
|
||||
#[deluxe(default = String::from("id"))]
|
||||
pub remote_id: String,
|
||||
pub link: M2MLink,
|
||||
}
|
||||
|
||||
pub struct Identifier {
|
||||
pub table: String,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
pub struct M2MRelationshipComplete {
|
||||
pub name: String,
|
||||
pub entity: syn::Type,
|
||||
pub local: Identifier,
|
||||
pub remote: Identifier,
|
||||
pub link: M2MLink,
|
||||
}
|
||||
|
||||
impl M2MRelationshipComplete {
|
||||
pub fn new(other: &M2MRelationship, local_table: &String, local_id: String) -> Self {
|
||||
Self {
|
||||
name: other.name.clone(),
|
||||
entity: other.entity.clone(),
|
||||
link: other.link.clone(),
|
||||
local: Identifier {
|
||||
table: local_table.to_string(),
|
||||
id: local_id,
|
||||
},
|
||||
remote: Identifier {
|
||||
table: other.table.clone(),
|
||||
id: other.remote_id.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&M2MRelationshipComplete> for proc_macro2::TokenStream {
|
||||
fn from(value: &M2MRelationshipComplete) -> Self {
|
||||
let function = syn::Ident::new(
|
||||
&format!("get_{}", value.name),
|
||||
proc_macro2::Span::call_site(),
|
||||
);
|
||||
let entity = &value.entity;
|
||||
let query = format!(
|
||||
"SELECT remote.*
|
||||
FROM {} local
|
||||
JOIN {} link ON link.{} = local.{}
|
||||
JOIN {} remote ON link.{} = remote.{}
|
||||
WHERE local.{} = $1",
|
||||
value.local.table,
|
||||
value.link.table,
|
||||
value.link.from,
|
||||
value.local.id,
|
||||
value.remote.table,
|
||||
value.link.to,
|
||||
value.remote.id,
|
||||
value.local.id
|
||||
);
|
||||
quote! {
|
||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<#entity>> {
|
||||
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_all(pool).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
130
georm-macros/src/georm/ir/mod.rs
Normal file
130
georm-macros/src/georm/ir/mod.rs
Normal file
@ -0,0 +1,130 @@
|
||||
use quote::quote;
|
||||
|
||||
pub mod simple_relationship;
|
||||
use simple_relationship::{OneToMany, OneToOne, SimpleRelationship};
|
||||
|
||||
pub mod m2m_relationship;
|
||||
use m2m_relationship::M2MRelationship;
|
||||
|
||||
#[derive(deluxe::ExtractAttributes)]
|
||||
#[deluxe(attributes(georm))]
|
||||
pub struct GeormStructAttributes {
|
||||
pub table: String,
|
||||
#[deluxe(default = Vec::new())]
|
||||
pub one_to_one: Vec<SimpleRelationship<OneToOne>>,
|
||||
#[deluxe(default = Vec::new())]
|
||||
pub one_to_many: Vec<SimpleRelationship<OneToMany>>,
|
||||
#[deluxe(default = Vec::new())]
|
||||
pub many_to_many: Vec<M2MRelationship>,
|
||||
}
|
||||
|
||||
#[derive(deluxe::ExtractAttributes, Clone)]
|
||||
#[deluxe(attributes(georm))]
|
||||
struct GeormFieldAttributes {
|
||||
#[deluxe(default = false)]
|
||||
pub id: bool,
|
||||
#[deluxe(default = None)]
|
||||
pub relation: Option<O2ORelationship>,
|
||||
#[deluxe(default = false)]
|
||||
pub defaultable: bool,
|
||||
}
|
||||
|
||||
#[derive(deluxe::ParseMetaItem, Clone, Debug)]
|
||||
pub struct O2ORelationship {
|
||||
pub entity: syn::Type,
|
||||
pub table: String,
|
||||
#[deluxe(default = String::from("id"))]
|
||||
pub remote_id: String,
|
||||
#[deluxe(default = false)]
|
||||
pub nullable: bool,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GeormField {
|
||||
pub ident: syn::Ident,
|
||||
pub field: syn::Field,
|
||||
pub ty: syn::Type,
|
||||
pub id: bool,
|
||||
pub relation: Option<O2ORelationship>,
|
||||
pub defaultable: bool,
|
||||
}
|
||||
|
||||
impl GeormField {
|
||||
pub fn new(field: &mut syn::Field) -> Self {
|
||||
let ident = field.clone().ident.unwrap();
|
||||
let ty = field.clone().ty;
|
||||
let attrs: GeormFieldAttributes =
|
||||
deluxe::extract_attributes(field).expect("Could not extract attributes from field");
|
||||
let GeormFieldAttributes {
|
||||
id,
|
||||
relation,
|
||||
defaultable,
|
||||
} = attrs;
|
||||
|
||||
// Validate that defaultable is not used on Option<T> fields
|
||||
if defaultable && Self::is_option_type(&ty) {
|
||||
panic!(
|
||||
"Field '{}' is already an Option<T> and cannot be marked as defaultable. \
|
||||
Remove the #[georm(defaultable)] attribute.",
|
||||
ident
|
||||
);
|
||||
}
|
||||
|
||||
Self {
|
||||
ident,
|
||||
field: field.to_owned(),
|
||||
id,
|
||||
ty,
|
||||
relation,
|
||||
defaultable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a type is Option<T>
|
||||
fn is_option_type(ty: &syn::Type) -> bool {
|
||||
match ty {
|
||||
syn::Type::Path(type_path) => {
|
||||
if let Some(segment) = type_path.path.segments.last() {
|
||||
segment.ident == "Option"
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&GeormField> for proc_macro2::TokenStream {
|
||||
fn from(value: &GeormField) -> Self {
|
||||
let Some(relation) = value.relation.clone() else {
|
||||
return quote! {};
|
||||
};
|
||||
let function = syn::Ident::new(
|
||||
&format!("get_{}", relation.name),
|
||||
proc_macro2::Span::call_site(),
|
||||
);
|
||||
let entity = &relation.entity;
|
||||
let return_type = if relation.nullable {
|
||||
quote! { Option<#entity> }
|
||||
} else {
|
||||
quote! { #entity }
|
||||
};
|
||||
let query = format!(
|
||||
"SELECT * FROM {} WHERE {} = $1",
|
||||
relation.table, relation.remote_id
|
||||
);
|
||||
let local_ident = &value.field.ident;
|
||||
let fetch = if relation.nullable {
|
||||
quote! { fetch_optional }
|
||||
} else {
|
||||
quote! { fetch_one }
|
||||
};
|
||||
quote! {
|
||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<#return_type> {
|
||||
::sqlx::query_as!(#entity, #query, self.#local_ident).#fetch(pool).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
66
georm-macros/src/georm/ir/simple_relationship.rs
Normal file
66
georm-macros/src/georm/ir/simple_relationship.rs
Normal file
@ -0,0 +1,66 @@
|
||||
use quote::quote;
|
||||
|
||||
pub trait SimpleRelationshipType {}
|
||||
|
||||
#[derive(deluxe::ParseMetaItem, Default)]
|
||||
pub struct OneToOne;
|
||||
impl SimpleRelationshipType for OneToOne {}
|
||||
|
||||
#[derive(deluxe::ParseMetaItem, Default)]
|
||||
pub struct OneToMany;
|
||||
impl SimpleRelationshipType for OneToMany {}
|
||||
|
||||
#[derive(deluxe::ParseMetaItem)]
|
||||
pub struct SimpleRelationship<T>
|
||||
where
|
||||
T: SimpleRelationshipType + deluxe::ParseMetaItem + Default,
|
||||
{
|
||||
pub name: String,
|
||||
pub remote_id: String,
|
||||
pub table: String,
|
||||
pub entity: syn::Type,
|
||||
#[deluxe(default = T::default())]
|
||||
_phantom: T,
|
||||
}
|
||||
|
||||
impl<T> SimpleRelationship<T>
|
||||
where
|
||||
T: SimpleRelationshipType + deluxe::ParseMetaItem + Default,
|
||||
{
|
||||
pub fn make_query(&self) -> String {
|
||||
format!("SELECT * FROM {} WHERE {} = $1", self.table, self.remote_id)
|
||||
}
|
||||
|
||||
pub fn make_function_name(&self) -> syn::Ident {
|
||||
syn::Ident::new(
|
||||
&format!("get_{}", self.name),
|
||||
proc_macro2::Span::call_site(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&SimpleRelationship<OneToOne>> for proc_macro2::TokenStream {
|
||||
fn from(value: &SimpleRelationship<OneToOne>) -> Self {
|
||||
let query = value.make_query();
|
||||
let entity = &value.entity;
|
||||
let function = value.make_function_name();
|
||||
quote! {
|
||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Option<#entity>> {
|
||||
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_optional(pool).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&SimpleRelationship<OneToMany>> for proc_macro2::TokenStream {
|
||||
fn from(value: &SimpleRelationship<OneToMany>) -> Self {
|
||||
let query = value.make_query();
|
||||
let entity = &value.entity;
|
||||
let function = value.make_function_name();
|
||||
quote! {
|
||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<#entity>> {
|
||||
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_all(pool).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
use ir::GeormField;
|
||||
use quote::quote;
|
||||
|
||||
mod defaultable_struct;
|
||||
mod ir;
|
||||
mod relationships;
|
||||
mod trait_implementation;
|
||||
@ -34,10 +35,13 @@ fn extract_georm_field_attrs(
|
||||
_ => {
|
||||
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
|
||||
)))
|
||||
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
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -51,9 +55,34 @@ pub fn georm_derive_macro2(
|
||||
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 defaultable_struct =
|
||||
defaultable_struct::derive_defaultable_struct(&ast, &struct_attrs, &fields);
|
||||
let from_row_impl = generate_from_row_impl(&ast, &fields);
|
||||
let code = quote! {
|
||||
#relationships
|
||||
#trait_impl
|
||||
#defaultable_struct
|
||||
#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,6 +1,6 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::georm::ir::M2MRelationshipComplete;
|
||||
use crate::georm::ir::m2m_relationship::M2MRelationshipComplete;
|
||||
|
||||
use super::ir::GeormField;
|
||||
use proc_macro2::TokenStream;
|
||||
@ -15,16 +15,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)
|
||||
}
|
||||
|
||||
@ -35,18 +31,20 @@ pub fn derive_relationships(
|
||||
id: &GeormField,
|
||||
) -> TokenStream {
|
||||
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()))
|
||||
.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
|
||||
}
|
||||
|
@ -97,6 +97,47 @@ fn generate_delete_query(table: &str, id: &GeormField) -> proc_macro2::TokenStre
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_upsert_query(
|
||||
table: &str,
|
||||
fields: &[GeormField],
|
||||
id: &GeormField,
|
||||
) -> proc_macro2::TokenStream {
|
||||
let inputs: Vec<String> = (1..=fields.len()).map(|num| format!("${num}")).collect();
|
||||
let columns = fields
|
||||
.iter()
|
||||
.map(|f| f.ident.to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ");
|
||||
|
||||
// For ON CONFLICT DO UPDATE, exclude the ID field from updates
|
||||
let update_assignments = fields
|
||||
.iter()
|
||||
.filter(|f| !f.id)
|
||||
.map(|f| format!("{} = EXCLUDED.{}", f.ident, f.ident))
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ");
|
||||
|
||||
let upsert_string = format!(
|
||||
"INSERT INTO {table} ({columns}) VALUES ({}) ON CONFLICT ({}) DO UPDATE SET {update_assignments} RETURNING *",
|
||||
inputs.join(", "),
|
||||
id.ident
|
||||
);
|
||||
|
||||
let field_idents: Vec<syn::Ident> = fields.iter().map(|f| f.ident.clone()).collect();
|
||||
|
||||
quote! {
|
||||
async fn create_or_update(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Self> {
|
||||
::sqlx::query_as!(
|
||||
Self,
|
||||
#upsert_string,
|
||||
#(self.#field_idents),*
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_get_id(id: &GeormField) -> proc_macro2::TokenStream {
|
||||
let ident = &id.ident;
|
||||
let ty = &id.ty;
|
||||
@ -125,6 +166,7 @@ pub fn derive_trait(
|
||||
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 upsert_query = generate_upsert_query(table, fields, id);
|
||||
let delete_query = generate_delete_query(table, id);
|
||||
quote! {
|
||||
impl #impl_generics Georm<#ty> for #ident #type_generics #where_clause {
|
||||
@ -133,6 +175,7 @@ pub fn derive_trait(
|
||||
#find_query
|
||||
#create_query
|
||||
#update_query
|
||||
#upsert_query
|
||||
#delete_query
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
);
|
@ -1,4 +1,4 @@
|
||||
# Do not increase during a minor/patch release cycle
|
||||
[toolchain]
|
||||
channel = "1.81"
|
||||
channel = "1.86"
|
||||
profile = "minimal"
|
||||
|
10
src/defaultable.rs
Normal file
10
src/defaultable.rs
Normal file
@ -0,0 +1,10 @@
|
||||
pub trait Defaultable<Id, Entity> {
|
||||
/// Creates an entity in the database.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error the database may have encountered
|
||||
fn create(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Entity>> + Send;
|
||||
}
|
83
src/entity.rs
Normal file
83
src/entity.rs
Normal file
@ -0,0 +1,83 @@
|
||||
pub trait Georm<Id> {
|
||||
/// Find all the entities in the database.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn find_all(
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl ::std::future::Future<Output = ::sqlx::Result<Vec<Self>>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Find the entiy in the database based on its identifier.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn find(
|
||||
pool: &sqlx::PgPool,
|
||||
id: &Id,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Option<Self>>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Create the entity in the database.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn create(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Update an entity with a matching identifier in the database.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn update(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Update an entity with a matching identifier in the database if
|
||||
/// it exists, create it otherwise.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn create_or_update(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Delete the entity from the database if it exists.
|
||||
///
|
||||
/// # Returns
|
||||
/// Returns the amount of rows affected by the deletion.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn delete(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send;
|
||||
|
||||
/// Delete any entity with the identifier `id`.
|
||||
///
|
||||
/// # Returns
|
||||
/// Returns the amount of rows affected by the deletion.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn delete_by_id(
|
||||
pool: &sqlx::PgPool,
|
||||
id: &Id,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send;
|
||||
|
||||
/// Returns the identifier of the entity.
|
||||
fn get_id(&self) -> &Id;
|
||||
}
|
92
src/georm.rs
Normal file
92
src/georm.rs
Normal file
@ -0,0 +1,92 @@
|
||||
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;
|
||||
}
|
213
src/lib.rs
213
src/lib.rs
@ -58,13 +58,13 @@
|
||||
//!
|
||||
//! 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 |
|
||||
//! | 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` |
|
||||
//! | 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:
|
||||
@ -81,6 +81,39 @@
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! But what if I have a one-to-one relationship with another entity and
|
||||
//! my current entity holds no data to reference that other identity? No
|
||||
//! worries, there is another way to declare such relationships.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! #[georm(
|
||||
//! one_to_one = [{
|
||||
//! name = "profile",
|
||||
//! remote_id = "user_id",
|
||||
//! table = "profiles",
|
||||
//! entity = User
|
||||
//! }]
|
||||
//! )]
|
||||
//! struct User {
|
||||
//! #[georm(id)]
|
||||
//! id: i32,
|
||||
//! username: String,
|
||||
//! hashed_password: String,
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! We now have access to the method `User::get_profile(&self, &pool:
|
||||
//! sqlx::PgPool) -> Option<User>`.
|
||||
//!
|
||||
//! Here is an explanation of the values of `one_to_many`:
|
||||
//!
|
||||
//! | Value Name | Explanaion | Default Value |
|
||||
//! |------------|------------------------------------------------------------------------------------------|---------------|
|
||||
//! | entity | Rust type of the entity found in the database | N/A |
|
||||
//! | name | Name of the remote entity within the local entity; generates a method named `get_{name}` | N/A |
|
||||
//! | table | Database table where the entity is stored | N/A |
|
||||
//! | remote_id | Name of the column serving as the identifier of the entity | `"id"` |
|
||||
//!
|
||||
//! ## One-to-many relationships
|
||||
//!
|
||||
//! Sometimes, our entity is the one being referenced to by multiple entities,
|
||||
@ -105,7 +138,7 @@
|
||||
//! entity = Post,
|
||||
//! name = "posts",
|
||||
//! table = "posts",
|
||||
//! remote_id = "id"
|
||||
//! remote_id = "author_id"
|
||||
//! }]
|
||||
//! )]
|
||||
//! struct User {
|
||||
@ -234,6 +267,76 @@
|
||||
//! | 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 |
|
||||
//!
|
||||
//! ## Defaultable Fields
|
||||
//!
|
||||
//! Georm supports defaultable fields for entities where some fields have database
|
||||
//! defaults or are auto-generated (like serial IDs). When you mark fields as
|
||||
//! `defaultable`, Georm generates a companion struct that makes these fields
|
||||
//! optional during entity creation.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! #[derive(sqlx::FromRow, Georm)]
|
||||
//! #[georm(table = "posts")]
|
||||
//! 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>, // Has database default
|
||||
//! author_id: i32, // Required field
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! This generates a `PostDefault` struct where defaultable fields become `Option<T>`:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! // Generated automatically by the macro
|
||||
//! pub struct PostDefault {
|
||||
//! pub id: Option<i32>, // Can be None for auto-generation
|
||||
//! pub title: String, // Required field stays the same
|
||||
//! pub published: Option<bool>, // Can be None to use database default
|
||||
//! pub created_at: Option<chrono::DateTime<chrono::Utc>>, // Can be None
|
||||
//! pub author_id: i32, // Required field stays the same
|
||||
//! }
|
||||
//!
|
||||
//! impl Defaultable<i32, Post> for PostDefault {
|
||||
//! async fn create(&self, pool: &sqlx::PgPool) -> sqlx::Result<Post>;
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ### Usage Example
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use georm::{Georm, Defaultable};
|
||||
//!
|
||||
//! // Create a post with some fields using database defaults
|
||||
//! let post_default = PostDefault {
|
||||
//! id: None, // Let database auto-generate
|
||||
//! title: "My Blog Post".to_string(),
|
||||
//! published: None, // Use database default (e.g., false)
|
||||
//! created_at: None, // Use database default (e.g., NOW())
|
||||
//! author_id: 42,
|
||||
//! };
|
||||
//!
|
||||
//! // Create the entity in the database
|
||||
//! let created_post = post_default.create(&pool).await?;
|
||||
//! println!("Created post with ID: {}", created_post.id);
|
||||
//! ```
|
||||
//!
|
||||
//! ### Rules and Limitations
|
||||
//!
|
||||
//! - **Option fields cannot be marked as defaultable**: If a field is already
|
||||
//! `Option<T>`, you cannot mark it with `#[georm(defaultable)]`. This prevents
|
||||
//! `Option<Option<T>>` types.
|
||||
//! - **Field visibility is preserved**: The generated defaultable struct maintains
|
||||
//! the same field visibility (`pub`, `pub(crate)`, private) as the original struct.
|
||||
//! - **ID fields can be defaultable**: It's common to mark ID fields as defaultable
|
||||
//! when they are auto-generated serials in PostgreSQL.
|
||||
//! - **Only generates when needed**: The defaultable struct is only generated if
|
||||
//! at least one field is marked as defaultable.
|
||||
//!
|
||||
//! ## Limitations
|
||||
//! ### Database
|
||||
//!
|
||||
@ -249,95 +352,7 @@
|
||||
|
||||
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;
|
||||
|
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));
|
||||
}
|
||||
}
|
||||
}
|
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),
|
||||
|
@ -1,14 +1,19 @@
|
||||
use georm::Georm;
|
||||
|
||||
#[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 +35,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 +68,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 +78,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 = [{
|
||||
|
@ -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(())
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user