18 Commits

Author SHA1 Message Date
5d8a1b1917 feat: deprecate create_or_update in favour of upsert
The `create_or_update` method has been deprecated and replaced by
`upsert` for clarity and consistency with common database terminology.

This commit also removes the file `src/entity.rs` which has been
forgotten in earlier commits and was no longer part of Georm.
2025-08-09 15:35:40 +02:00
49c7d86102 feat: enable transaction support via sqlx::Executor
This commit abstracts the database operations to use the generic
`sqlx::Executor` trait instead of a concrete `&sqlx::PgPool`.

This change allows all generated methods (find, create, update,
delete, and relationships) to be executed within a
`sqlx::Transaction`, in addition to a connection pool. This is a
crucial feature for ensuring atomic operations and data consistency.

The public-facing traits `Georm` and `Defaultable` have been updated
to require `sqlx::Executor`, and the documentation has been updated to
reflect this new capability.
2025-08-09 15:35:40 +02:00
3307aa679d feat: Add generated and generated_always attributes
This commit introduces support for PostgreSQL generated columns by
adding two new field attributes to the `Georm` derive macro:
`#[georm(generated)]` and `#[georm(generated_always)]`.

The `#[georm(generated_always)]` attribute is for fields that are
always generated by the database, such as `GENERATED ALWAYS AS
IDENTITY` columns or columns with a `GENERATED ALWAYS AS (expression)
STORED` clause. These fields are now excluded from `INSERT` and
`UPDATE` statements, preventing accidental writes and ensuring data
integrity at compile time.

The `#[georm(generated)]` attribute is for fields that have a default
value generated by the database but can also be manually overridden,
such as `GENERATED BY DEFAULT AS IDENTITY` columns. These fields
behave similarly to `#[georm(defaultable)]` fields, allowing them to
be omitted from `INSERT` statements to use the database-generated
value.

For now, the behaviour is the same between `#[georm(generated)]` and
`#[georm(defaultable)]`, but the addition of the former now will be
useful for future features.

Key changes:
- Added `generated` and `generated_always` attributes to
  `GeormFieldAttributes`.
- Introduced `GeneratedType` enum in the IR to represent the different
  generation strategies.
- Modified the `create` and `update` query generation to exclude
  fields marked with `#[georm(generated_always)]`.
- Integrated `#[georm(generated)]` fields with the existing
  defaultable struct logic.
- Added validation to prevent conflicting attribute usage, namely
  `#[georm(generated)]` and `#[georm(generated_always)]` on the same
  field.

Implements #3
2025-08-09 15:28:37 +02:00
545dfa066d docs(README): clarify async support for Diesel
Fixes #2
2025-08-09 13:57:56 +02:00
8468c3cd61 docs(README): update roadmap 2025-06-12 15:24:05 +02:00
13c7a413d7 chore: bump to 0.2.1 2025-06-10 12:16:06 +02:00
fcd0f57857 chore: bump version to 0.2.0 2025-06-10 12:04:58 +02:00
7e7a3ccd29 refactor(macros): split trait implementations into modular files
Move trait implementation code from single monolithic file into separate
modules organised by operation type (create, delete, find, update,
upsert) for better code organisation and maintainability.
2025-06-10 11:44:25 +02:00
a7696270da docs: rewrite documentation for core traits and library
Completely rewrite and expand documentation for Georm’s core
functionality with detailed explanations, examples, and implementation
details.

Changes:
- Rewrite lib.rs with comprehensive library documentation covering all
  features
- Add extensive Georm trait documentation with method-specific details
- Add detailed Defaultable trait documentation with usage patterns

The documentation now provides complete coverage of:
- All CRUD operations with database behavior details
- Composite key support and generated ID structs
- Defaultable field patterns and companion struct generation
- Relationship modeling (field-level and struct-level)
- Error handling and performance characteristics
- PostgreSQL-specific features and optimizations
2025-06-09 22:41:43 +02:00
19284665e6 feat: implement preliminary composite primary key support
Add support for entities with composite primary keys using multiple
#[georm(id)] fields. Automatically generates {EntityName}Id structs for
type-safe composite key handling.

Features:
- Multi-field primary key detection and ID struct generation
- Full CRUD operations (find, create, update, delete, create_or_update)
- Proper SQL generation with AND clauses for composite keys
- Updated documNtation in README and lib.rs

Note: Relationships not yet supported for composite key entities
2025-06-09 22:41:39 +02:00
190c4d7b1d feat(examples): add PostgreSQL example with user relationship
Adds an example demonstrating user, comment, and follower relationship
including:
- User management with profiles
- Comments (not really useful, just for showcasing)
- Follower/follozing relationships
- Ineractive CLI interface with CRUD operations
- Database migrations for the example schema
2025-06-09 21:30:35 +02:00
9e56952dc6 feat: implement efficient upsert operation for create_or_update
Replace the existing two-query create_or_update implementation with a
single atomic PostgreSQL upsert using ON CONFLICT clause to eliminate
race conditions and improve performance.

Race condition fix:
The previous implementation had a critical race condition where
multiple concurrent requests could:
1. Both call find() and get None (record doesn't exist)
2. Both call create() and the second one fails with duplicate key
   error
3. Or between find() and create(), another transaction inserts the
   record

This created unreliable behavior in high-concurrency scenarios.

Changes:
- Add generate_upsert_query function in trait_implementation.rs
- Generate SQL with INSERT ... ON CONFLICT ... DO UPDATE SET pattern
- Remove default trait implementation that used separate
  find/create/update calls
- Update derive_trait to include upsert query generation
- Convert create_or_update from default implementation to required
  trait method

The new implementation eliminates race conditions while reducing
database round trips from 2-3 queries down to 1, significantly
improving both reliability and performance.
2025-06-09 21:30:35 +02:00
0c3d5e6262 feat: upgrade to Rust 1.86 and edition 2024
Upgrades rust toolchain from 1.84 to 1.86, switches to Rust edition
2024, and updates dependencies including SQLx to 0.8.6.
2025-06-09 21:26:19 +02:00
8217a28a28 fix(deps): update tokio to 1.45.1 to address RUSTSEC-2025-0023
Updates tokio dependency to address security advisory RUSTSEC-2025-0023.
This ensures the codebase uses a secure version of the tokio runtime.
2025-06-07 15:46:10 +02:00
ab2d80d2f6 chore: migrate development environment from Nix flakes to devenv
Replace Nix flake-based development setup with devenv for better
developer experience and more streamlined environment management.

Changes:
  - Remove flake.nix and flake.lock files
  - Add devenv.nix, devenv.yaml, and devenv.lock configuration
  - Update .envrc to use devenv instead of nix develop
  - Remove Docker development setup (compose.dev.yml, docker/mod.just)
  - Expand .gitignore with comprehensive IDE and OS exclusions
  - Remove Docker-related just commands from justfile
2025-06-07 15:46:10 +02:00
7cdaa27f3b docs: complete rewrite of README
Replaces the existing README with a comprehensive guide that
significantly improves the developer and user experience. The new README
provides complete documentation for all Georm features and a detailed
development setup guide.

It also includes a roadmap with prioritized feature development plan.
2025-06-07 15:45:25 +02:00
a38b8e873d feat: add defaultable field support with companion struct generation
Introduces support for `#[georm(defaultable)]` attribute on entity
fields. When fields are marked as defaultable, generates companion
`<Entity>Default` structs where defaultable fields become `Option<T>`,
enabling easier entity creation when some fields have database defaults
or are auto-generated.

Key features:
- Generates `<Entity>Default` structs with optional defaultable fields
- Implements `Defaultable<Id, Entity>` trait with async `create` method
- Validates that `Option<T>` fields cannot be marked as defaultable
- Preserves field visibility in generated companion structs
- Only generates companion struct when defaultable fields are present
2025-06-07 15:42:48 +02:00
aafbfb7964 feat: add foreign one_to_one relationships 2025-06-07 15:42:31 +02:00
64 changed files with 6915 additions and 1226 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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
View File

@@ -1,4 +1,57 @@
.direnv
.env
/coverage
/target
/.sqls
/examples/target
# Devenv
.devenv*
devenv.local.nix
# direnv
.direnv
# pre-commit
.pre-commit-config.yaml
# Emacs backup files
*~
\#*\#
.\#*
.dir-locals.el
# Vim files
*.swp
*.swo
*~
# VS Code
.vscode/
*.code-workspace
# JetBrains IDEs
.idea/
*.iml
# macOS
.DS_Store
.AppleDouble
.LSOverride
# Windows
Thumbs.db
ehthumbs.db
Desktop.ini
# Linux
*~
# Temporary files
*.tmp
*.temp
*.log
# OS generated files
.Spotlight-V100
.Trashes
._*

1076
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,13 @@
[workspace]
members = [".", "georm-macros"]
members = [
".",
"georm-macros",
"examples/postgres/*"
]
[workspace.package]
version = "0.1.1"
edition = "2021"
version = "0.2.1"
edition = "2024"
authors = ["Lucien Cartier-Tilet <lucien@phundrak.com>"]
homepage = "https://github.com/Phundrak/georm"
repository = "https://github.com/Phundrak/georm"
@@ -23,20 +27,26 @@ repository.workspace = true
version.workspace = true
[workspace.dependencies]
georm-macros = { version = "=0.1.1", path = "georm-macros" }
georm-macros = { version = "=0.2.1", path = "georm-macros" }
[workspace.dependencies.sqlx]
version = "0.8.3"
version = "0.8.6"
default-features = false
features = ["postgres", "runtime-tokio", "macros", "migrate"]
features = ["postgres", "runtime-tokio", "macros", "migrate", "bigdecimal"]
[dependencies]
sqlx = { workspace = true }
georm-macros = { workspace = true }
[dev-dependencies]
chrono = { version = "0.4", features = ["serde"] }
rand = "0.9"
[dev-dependencies.sqlx]
version = "0.8.6"
default-features = false
features = ["postgres", "runtime-tokio", "macros", "migrate", "chrono"]
[workspace.lints.rust]
unsafe_code = "forbid"

887
README.md
View File

@@ -1,116 +1,377 @@
<div align="center">
<a href="https://github.com/Phundrak/georm">
<img src="assets/logo.png" alt="Georm logo" width="150px" />
</a>
</div>
<h1 align="center">Georm</h1>
<div align="center">
<strong>
A simple, opinionated SQLx ORM for PostgreSQL
A simple, type-safe SQLx ORM for PostgreSQL
</strong>
</div>
<br/>
<div align="center">
<!-- Github Actions -->
<a href="https://github.com/phundrak/georm/actions/workflows/ci.yaml?query=branch%3Amain">
<img src="https://img.shields.io/github/actions/workflow/status/phundrak/georm/ci.yaml?branch=main&style=flat-square" alt="actions status" /></a>
<img src="https://img.shields.io/github/actions/workflow/status/phundrak/georm/ci.yaml?branch=main&style=flat-square" alt="actions status" />
</a>
<!-- Version -->
<a href="https://crates.io/crates/georm">
<img src="https://img.shields.io/crates/v/georm.svg?style=flat-square"
alt="Crates.io version" /></a>
<!-- Discord -->
<img src="https://img.shields.io/crates/v/georm.svg?style=flat-square" alt="Crates.io version" />
</a>
<!-- Docs -->
<a href="https://docs.rs/georm">
<img src="https://img.shields.io/badge/docs-latest-blue.svg?style=flat-square" alt="docs.rs docs" /></a>
<img src="https://img.shields.io/badge/docs-latest-blue.svg?style=flat-square" alt="docs.rs docs" />
</a>
<!-- License -->
<a href="#license">
<img src="https://img.shields.io/badge/license-MIT%20OR%20GPL--3.0-blue?style=flat-square" alt="License" />
</a>
</div>
<div align="center">
<h4>What is Georm?</h4>
</div>
## Overview
Georm is a quite simple ORM built around
[SQLx](https://crates.io/crates/sqlx) that gives access to a few
useful functions when interacting with a database, implementing
automatically the most basic SQL interactions youre 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 thats easy and straightforward to use. I am aware
some other projects exist, such as
[SeaORM](https://www.sea-ql.org/SeaORM/), but they generally dont fit
my needs and/or my wants of a simple interface. I ended up writing the
ORM I wanted to use.
- **Type Safety**: Compile-time verified SQL queries using SQLx macros
- **Flexible Executor**: Works with both `PgPool` and `Transaction` for atomic operations.
- **Zero Runtime Cost**: No reflection or runtime query building
- **Simple API**: Intuitive derive macros for common operations
- **Relationship Support**: One-to-one, one-to-many, and many-to-many relationships
- **Composite Primary Keys**: Support for multi-field primary keys
- **Defaultable Fields**: Easy entity creation with database defaults and auto-generated values
- **PostgreSQL Native**: Optimized for PostgreSQL features and data types
<div align="center">
<h4>How is Georm?</h4>
</div>
## Quick Start
I use it in a few projects, and Im quite happy with it right now. But
of course, Im 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 SQLxs `migrate` feature for
this), and then declare their equivalent in Rust.
### Basic Usage
1. **Define your database schema**:
```sql
CREATE TABLE biographies (
id SERIAL PRIMARY KEY,
content TEXT NOT NULL
);
CREATE TABLE authors (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
biography_id INT,
FOREIGN KEY (biography_id) REFERENCES biographies(id)
email VARCHAR(255) UNIQUE NOT NULL
);
CREATE TABLE posts (
id SERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
published BOOLEAN DEFAULT FALSE,
author_id INT NOT NULL REFERENCES authors(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
```
```rust
pub struct Author {
pub id: i32,
pub name: String,
}
```
2. **Define your Rust entities**:
To link a struct to a table in your database, derive the
`sqlx::FromRow` and the `georm::Georm` traits.
```rust
#[derive(sqlx::FromRow, Georm)]
pub struct Author {
pub id: i32,
pub name: String,
}
```
use georm::Georm;
Now, indicate with the `georm` proc-macro which table they refer to.
```rust
#[derive(sqlx::FromRow, Georm)]
#[derive(Georm)]
#[georm(table = "authors")]
pub struct Author {
#[georm(id)]
pub id: i32,
pub name: String,
pub email: String,
}
#[derive(Georm)]
#[georm(table = "posts")]
pub struct Post {
#[georm(id)]
pub id: i32,
pub title: String,
pub content: String,
pub published: bool,
#[georm(relation = {
entity = Author,
table = "authors",
name = "author"
})]
pub author_id: i32,
pub created_at: chrono::DateTime<chrono::Utc>,
}
```
Finally, indicate with the same proc-macro which field of your struct
is the primary key in your database.
3. **Use the generated methods**:
```rust
#[derive(sqlx::FromRow, Georm)]
#[georm(table = "authors")]
use sqlx::PgPool;
async fn example(pool: &PgPool) -> sqlx::Result<()> {
// Start a transaction
let mut tx = pool.begin().await?;
// Create an author
let author = Author {
id: 0, // Will be auto-generated
name: "Jane Doe".to_string(),
email: "jane@example.com".to_string(),
};
let author = author.create(&mut *tx).await?;
// Create a post
let post = Post {
id: 0,
title: "Hello, Georm!".to_string(),
content: "This is my first post using Georm.".to_string(),
published: false,
author_id: author.id,
created_at: chrono::Utc::now(),
};
let post = post.create(&mut *tx).await?;
// Commit the transaction
tx.commit().await?;
// Find all posts (using the pool directly)
let all_posts = Post::find_all(pool).await?;
// Get the post's author
let post_author = post.get_author(pool).await?;
println!("Post '{}' by {}", post.title, post_author.name);
Ok(())
}
```
## Advanced Features
### Composite Primary Keys
Georm supports composite primary keys by marking multiple fields with `#[georm(id)]`:
```rust
#[derive(Georm)]
#[georm(table = "user_roles")]
pub struct UserRole {
#[georm(id)]
pub user_id: i32,
#[georm(id)]
pub role_id: i32,
pub assigned_at: chrono::DateTime<chrono::Utc>,
}
```
This automatically generates a composite ID struct:
```rust
// Generated automatically
pub struct UserRoleId {
pub user_id: i32,
pub role_id: i32,
}
// Usage
let id = UserRoleId { user_id: 1, role_id: 2 };
let user_role = UserRole::find(pool, &id).await?;
```
**Note**: Relationships are not yet supported for entities with composite primary keys.
### Defaultable and Generated Fields
Georm provides three attributes for handling fields with database-managed values:
#### `#[georm(defaultable)]` - Optional Override Fields
For fields with database defaults that can be manually overridden:
```rust
#[derive(Georm)]
#[georm(table = "posts")]
pub struct Post {
#[georm(id, defaultable)]
pub id: i32, // Auto-generated serial
pub title: String,
#[georm(defaultable)]
pub published: bool, // Has database default (false)
#[georm(defaultable)]
pub created_at: chrono::DateTime<chrono::Utc>, // DEFAULT NOW()
pub author_id: i32,
}
```
#### `#[georm(generated)]` - Generated by Default
For PostgreSQL `GENERATED BY DEFAULT` columns that can be overridden but are typically auto-generated:
```rust
#[derive(Georm)]
#[georm(table = "products")]
pub struct Product {
#[georm(id, generated_always)]
pub id: i32, // GENERATED ALWAYS AS IDENTITY
#[georm(generated)]
pub sku_number: i32, // GENERATED BY DEFAULT AS IDENTITY
pub name: String,
pub price: sqlx::types::BigDecimal,
}
```
#### `#[georm(generated_always)]` - Always Generated
For PostgreSQL `GENERATED ALWAYS` columns that are strictly managed by the database:
```rust
#[derive(Georm)]
#[georm(table = "products")]
pub struct Product {
#[georm(id, generated_always)]
pub id: i32, // GENERATED ALWAYS AS IDENTITY
pub name: String,
pub price: sqlx::types::BigDecimal,
pub discount_percent: i32,
#[georm(generated_always)]
pub final_price: Option<sqlx::types::BigDecimal>, // GENERATED ALWAYS AS (expression) STORED
}
```
#### Generated Structs and Behavior
Both `defaultable` and `generated` fields create a companion `<Entity>Default` struct:
```rust
use georm::Defaultable;
let product_default = ProductDefault {
name: "Laptop".to_string(),
price: BigDecimal::from(999),
discount_percent: 10,
sku_number: None, // Let database auto-generate
// Note: generated_always fields are excluded from this struct
};
let created_product = product_default.create(pool).await?;
```
#### Key Differences
| Attribute | INSERT Behavior | UPDATE Behavior | Use Case |
|--------------------|------------------------------------|-----------------|----------------------------------------------|
| `defaultable` | Optional (can override defaults) | Included | Fields with database defaults |
| `generated` | Optional (can override generation) | Included | `GENERATED BY DEFAULT` columns |
| `generated_always` | **Excluded** (always generated) | **Excluded** | `GENERATED ALWAYS` columns, computed columns |
#### Important Notes
- **`generated_always` fields are completely excluded** from INSERT and UPDATE statements to prevent database errors
- **`generated` and `generated_always` cannot be used together** on the same field
- **`generated` fields behave like `defaultable` fields** but are semantically distinct for future enhancements
- **Option types cannot be marked as `defaultable`** to prevent `Option<Option<T>>` situations
### Relationships
Georm supports comprehensive relationship modeling with two approaches: field-level relationships for foreign keys and struct-level relationships for reverse lookups.
#### Field-Level Relationships (Foreign Keys)
Use the `relation` attribute on foreign key fields to generate lookup methods:
```rust
#[derive(Georm)]
#[georm(table = "posts")]
pub struct Post {
#[georm(id)]
pub id: i32,
pub title: String,
#[georm(relation = {
entity = Author, // Target entity type
table = "authors", // Target table name
name = "author", // Method name (generates get_author)
remote_id = "id", // Target table's key column (default: "id")
nullable = false // Whether relationship can be null (default: false)
})]
pub author_id: i32,
}
```
**Generated method**: `post.get_author(pool).await? -> Author`
For nullable relationships:
```rust
#[derive(Georm)]
#[georm(table = "posts")]
pub struct Post {
#[georm(id)]
pub id: i32,
pub title: String,
#[georm(relation = {
entity = Category,
table = "categories",
name = "category",
nullable = true // Allows NULL values
})]
pub category_id: Option<i32>,
}
```
**Generated method**: `post.get_category(pool).await? -> Option<Category>`
#### Struct-Level Relationships (Reverse Lookups)
Define relationships at the struct level to query related entities that reference this entity:
##### One-to-One Relationships
```rust
#[derive(Georm)]
#[georm(
table = "users",
one_to_one = [{
entity = Profile, // Related entity type
name = "profile", // Method name (generates get_profile)
table = "profiles", // Related table name
remote_id = "user_id", // Foreign key in related table
}]
)]
pub struct User {
#[georm(id)]
pub id: i32,
pub username: String,
}
```
**Generated method**: `user.get_profile(pool).await? -> Option<Profile>`
##### One-to-Many Relationships
```rust
#[derive(Georm)]
#[georm(
table = "authors",
one_to_many = [{
entity = Post, // Related entity type
name = "posts", // Method name (generates get_posts)
table = "posts", // Related table name
remote_id = "author_id" // Foreign key in related table
}, {
entity = Comment, // Multiple relationships allowed
name = "comments",
table = "comments",
remote_id = "author_id"
}]
)]
pub struct Author {
#[georm(id)]
pub id: i32,
@@ -118,38 +379,482 @@ pub struct Author {
}
```
Congratulations, your struct `Author` now has access to all the
functions described in the `Georm` trait!
**Generated methods**:
- `author.get_posts(pool).await? -> Vec<Post>`
- `author.get_comments(pool).await? -> Vec<Comment>`
<div align="center">
<h4>Entity relationship</h4>
</div>
##### Many-to-Many Relationships
For many-to-many relationships, specify the link table that connects the entities:
```sql
-- Example schema for books and genres
CREATE TABLE books (
id SERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL
);
CREATE TABLE genres (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL
);
CREATE TABLE book_genres (
book_id INT NOT NULL REFERENCES books(id),
genre_id INT NOT NULL REFERENCES genres(id),
PRIMARY KEY (book_id, genre_id)
);
```
It is possible to implement one-to-one, one-to-many, and many-to-many
relationships with Georm. This is a quick example of how a struct with
several relationships of different types may be declared:
```rust
#[derive(sqlx::FromRow, Georm)]
#[derive(Georm)]
#[georm(
table = "books",
one_to_many = [
{ name = "reviews", remote_id = "book_id", table = "reviews", entity = Review }
],
many_to_many = [{
name = "genres",
table = "genres",
entity = Genre,
link = { table = "book_genres", from = "book_id", to = "genre_id" }
entity = Genre, // Related entity type
name = "genres", // Method name (generates get_genres)
table = "genres", // Related table name
remote_id = "id", // Primary key in related table (default: "id")
link = { // Link table configuration
table = "book_genres", // Join table name
from = "book_id", // Column referencing this entity
to = "genre_id" // Column referencing related entity
}
}]
)]
pub struct Book {
#[georm(id)]
ident: i32,
title: String,
#[georm(relation = {entity = Author, table = "authors", name = "author"})]
author_id: i32,
pub id: i32,
pub title: String,
}
#[derive(Georm)]
#[georm(
table = "genres",
many_to_many = [{
entity = Book,
name = "books",
table = "books",
link = {
table = "book_genres",
from = "genre_id", // Note: reversed perspective
to = "book_id"
}
}]
)]
pub struct Genre {
#[georm(id)]
pub id: i32,
pub name: String,
}
```
To read more about these features, you can refer to the [online
documentation](https://docs.rs/georm/).
**Generated methods**:
- `book.get_genres(pool).await? -> Vec<Genre>`
- `genre.get_books(pool).await? -> Vec<Book>`
#### Relationship Attribute Reference
| Attribute | Description | Required | Default |
|--------------|------------------------------------------------------|----------|---------|
| `entity` | Target entity type | Yes | N/A |
| `name` | Method name (generates `get_{name}`) | Yes | N/A |
| `table` | Target table name | Yes | N/A |
| `remote_id` | Target table's key column | No | `"id"` |
| `nullable` | Whether relationship can be null (field-level only) | No | `false` |
| `link.table` | Join table name (many-to-many only) | Yes* | N/A |
| `link.from` | Column referencing this entity (many-to-many only) | Yes* | N/A |
| `link.to` | Column referencing target entity (many-to-many only) | Yes* | N/A |
*Required for many-to-many relationships
#### Complex Relationship Example
Here's a comprehensive example showing multiple relationship types:
```rust
#[derive(Georm)]
#[georm(
table = "posts",
one_to_many = [{
entity = Comment,
name = "comments",
table = "comments",
remote_id = "post_id"
}],
many_to_many = [{
entity = Tag,
name = "tags",
table = "tags",
link = {
table = "post_tags",
from = "post_id",
to = "tag_id"
}
}]
)]
pub struct Post {
#[georm(id)]
pub id: i32,
pub title: String,
pub content: String,
// Field-level relationship (foreign key)
#[georm(relation = {
entity = Author,
table = "authors",
name = "author"
})]
pub author_id: i32,
// Nullable field-level relationship
#[georm(relation = {
entity = Category,
table = "categories",
name = "category",
nullable = true
})]
pub category_id: Option<i32>,
}
```
**Generated methods**:
- `post.get_author(pool).await? -> Author` (from field relation)
- `post.get_category(pool).await? -> Option<Category>` (nullable field relation)
- `post.get_comments(pool).await? -> Vec<Comment>` (one-to-many)
- `post.get_tags(pool).await? -> Vec<Tag>` (many-to-many)
## API Reference
### Core Operations
All entities implementing `Georm<Id>` get these methods. All database operations now accept any `sqlx`-compatible executor, which can be a connection pool (`&PgPool`) or a transaction (`&mut Transaction<'_, Postgres>`).
```rust
async fn example(pool: &PgPool, post_id: i32) -> sqlx::Result<()> {
// Operations on the pool
let all_posts = Post::find_all(pool).await?;
// Operations within a transaction
let mut tx = pool.begin().await?;
let post = Post::find(&mut *tx, &post_id).await?;
if let Some(post) = post {
post.delete(&mut *tx).await?;
}
tx.commit().await?;
Ok(())
}
```
The available methods are:
```rust
// Query operations
Post::find_all(executor).await?;
Post::find(executor, &post_id).await?;
// Mutation operations
post.create(executor).await?;
post.update(executor).await?;
post.upsert(executor).await?;
post.delete(executor).await?;
Post::delete_by_id(executor, &post_id).await?;
// Utility
post.get_id();
```
### Defaultable Operations
Entities with defaultable fields get a companion `<Entity>Default` struct:
```rust
// Create with defaults
post_default.create(pool).await?;
```
## Configuration
### Attributes Reference
#### Struct-level attributes
```rust
#[georm(
table = "table_name", // Required: database table name
one_to_one = [{ /* ... */ }], // Optional: one-to-one relationships
one_to_many = [{ /* ... */ }], // Optional: one-to-many relationships
many_to_many = [{ /* ... */ }] // Optional: many-to-many relationships
)]
```
#### Field-level attributes
```rust
#[georm(id)] // Mark as primary key
#[georm(defaultable)] // Mark as defaultable field
#[georm(generated)] // Mark as generated by default field
#[georm(generated_always)] // Mark as always generated field
#[georm(relation = { /* ... */ })] // Define relationship
```
## Performance
Georm is designed for zero runtime overhead:
- **Compile-time queries**: All SQL is verified at compile time
- **No reflection**: Direct field access, no runtime introspection
- **Minimal allocations**: Efficient use of owned vs borrowed data
- **SQLx integration**: Leverages SQLx's optimized PostgreSQL driver
## Examples
### Comprehensive Example
For an example showcasing user management, comments, and follower relationships, see the example in `examples/postgres/users-comments-and-followers/`. This example demonstrates:
- User management and profile management
- Comment system with user associations
- Follower/following relationships (many-to-many)
- Interactive CLI interface with CRUD operations
- Database migrations and schema setup
To run the example:
```bash
# Set up your database
export DATABASE_URL="postgres://username:password@localhost/georm_example"
# Run migrations
cargo sqlx migrate run
# Run the example
cd examples/postgres/users-comments-and-followers
cargo run help # For a list of all available actions
```
## Comparison
| Feature | Georm | SeaORM | Diesel |
|----------------------|-------|--------|--------|
| Compile-time safety | ✅ | ✅ | ✅ |
| Relationship support | ✅ | ✅ | ✅ |
| Async support | ✅ | ✅ | ⚠️[^1] |
| Learning curve | Low | Medium | High |
| Macro simplicity | ✅ | ❌ | ❌ |
| Advanced queries | ❌ | ✅ | ✅ |
[^1]: Requires `diesel-async`
## Roadmap
### High Priority
- **Simplified Relationship Syntax**: Remove redundant table/remote_id specifications by inferring them from target entity metadata
- **Multi-Database Support**: MySQL and SQLite support with feature flags
### Medium Priority
- **Composite Key Relationships**: Add relationship support (one-to-one, one-to-many, many-to-many) for entities with composite primary keys
- **Field-Based Queries**: Generate `find_by_{field_name}` methods that return `Vec<T>` for regular fields or `Option<T>` for unique fields
- **Relationship Optimization**: Eager loading and N+1 query prevention
- **Automatic Table Name Inference**: Infer table names from struct names (PascalCase → snake_case plural), eliminating the need for explicit `#[georm(table = "...")]` attributes
- **Alternative Attribute Syntax**: Introduce path-based attribute syntax as sugar (e.g., `#[table("users")]`, `#[id]`) while maintaining backward compatibility
### Lower Priority
- **Migration Support**: Schema generation and evolution utilities
- **Soft Delete**: Optional soft delete with `deleted_at` timestamps
- **Enhanced Error Handling**: Custom error types with better context
## Contributing
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
### Development Setup
#### Prerequisites
- **Rust 1.86+**: Georm uses modern Rust features and follows the MSRV specified in `rust-toolchain.toml`
- **PostgreSQL 12+**: Required for running tests and development
- **Git**: For version control
- **Jujutsu**: For version control (alternative to Git)
#### Required Tools
The following tools are used in the development workflow:
- **[just](https://github.com/casey/just)**: Task runner for common development commands
- **[cargo-deny](https://github.com/EmbarkStudios/cargo-deny)**: License and security auditing
- **[sqlx-cli](https://github.com/launchbadge/sqlx/tree/main/sqlx-cli)**: Database migrations and management
- **[bacon](https://github.com/Canop/bacon)**: Background code checker (optional but recommended)
Install these tools:
```bash
# Install just (task runner)
cargo install just
# Install cargo-deny (for auditing)
cargo install cargo-deny
# Install sqlx-cli (for database management)
cargo install sqlx-cli --no-default-features --features native-tls,postgres
# Install bacon (optional, for live feedback)
cargo install bacon
```
#### Quick Start
```bash
# Clone the repository
git clone https://github.com/Phundrak/georm.git
cd georm
# Set up your PostgreSQL database and set DATABASE_URL
export DATABASE_URL="postgres://username:password@localhost/georm_test"
# Run migrations
just migrate
# Run all tests
just test
# Run linting
just lint
# Run security audit
just audit
# Run all checks (format, lint, audit, test)
just check-all
```
#### Available Commands (via just)
```bash
just # Default: run linting
just build # Build the project
just build-release # Build in release mode
just test # Run all tests
just lint # Run clippy linting
just audit # Run security and license audit
just migrate # Run database migrations
just format # Format all code
just format-check # Check code formatting
just check-all # Run all checks (format, lint, audit, test)
just clean # Clean build artifacts
```
#### Running Specific Tests
```bash
# Run tests for a specific module
cargo test --test simple_struct
cargo test --test defaultable_struct
cargo test --test m2m_relationship
# Run tests with output
cargo test -- --nocapture
# Run a specific test function
cargo test defaultable_struct_should_exist
```
#### Development with Bacon (Optional)
For continuous feedback during development:
```bash
# Run clippy continuously
bacon
# Run tests continuously
bacon test
# Build docs continuously
bacon doc
```
#### Devenv Development Environment (Optional)
If you use [Nix](https://nixos.org/), you can use the provided devenv configuration for a reproducible development environment:
```bash
# Enter the development shell with all tools pre-installed
devenv shell
# Or use direnv for automatic environment activation
direnv allow
```
The devenv configuration provides:
- Exact Rust version (1.86) with required components
- All development tools (just, cargo-deny, sqlx-cli, bacon)
- LSP support (rust-analyzer)
- SQL tooling (sqls for SQL language server)
- PostgreSQL database for development
**Devenv configuration:**
- **Rust toolchain**: Specified version with rustfmt, clippy, and rust-analyzer
- **Development tools**: just, cargo-deny, sqlx-cli, bacon
- **SQL tools**: sqls (SQL language server)
- **Database**: PostgreSQL with automatic setup
- **Platform support**: Cross-platform (Linux, macOS, etc.)
#### Database Setup for Tests
Tests require a PostgreSQL database. Set up a test database:
```sql
-- Connect to PostgreSQL as superuser
CREATE DATABASE georm_test;
CREATE USER georm_user WITH PASSWORD 'georm_password';
GRANT ALL PRIVILEGES ON DATABASE georm_test TO georm_user;
```
Set the environment variable:
```bash
export DATABASE_URL="postgres://georm_user:georm_password@localhost/georm_test"
```
#### IDE Setup
- Ensure `rust-analyzer` is configured
- Set up PostgreSQL connection for SQL syntax highlighting
#### Code Style
The project uses standard Rust formatting:
```bash
# Format code
just format
# Check formatting (CI)
just format-check
```
Clippy linting is enforced:
```bash
# Run linting
just lint
# Fix auto-fixable lints
cargo clippy --fix
```
## License
Licensed under either of
* MIT License ([LICENSE-MIT](LICENSE-MIT.md) or http://opensource.org/licenses/MIT)
* GNU General Public License v3.0 ([LICENSE-GPL](LICENSE-GPL.md) or https://www.gnu.org/licenses/gpl-3.0.html)
at your option.
## Acknowledgments
- Built on top of the excellent [SQLx](https://github.com/launchbadge/sqlx) library
- Inspired by [Hibernate](https://hibernate.org/)

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

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
View 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
View 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
View File

@@ -0,0 +1,8 @@
inputs:
rust-overlay:
url: github:oxalica/rust-overlay
inputs:
nixpkgs:
follows: nixpkgs
nixpkgs:
url: github:cachix/devenv-nixpkgs/rolling

View File

@@ -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:

View File

@@ -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:

View 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"] }

View File

@@ -0,0 +1,135 @@
use super::{Executable, Result};
use crate::{
errors::UserInputError,
models::{Comment, CommentDefault, User},
};
use clap::{Args, Subcommand};
use georm::{Defaultable, Georm};
use std::collections::HashMap;
#[derive(Debug, Args, Clone)]
pub struct CommentArgs {
#[command(subcommand)]
pub command: CommentCommand,
}
impl Executable for CommentArgs {
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
self.command.execute(pool).await
}
}
#[derive(Debug, Clone, Subcommand)]
pub enum CommentCommand {
Create {
text: Option<String>,
username: Option<String>,
},
Remove {
id: Option<i32>,
},
RemoveFromUser {
username: Option<String>,
},
ListFromUser {
username: Option<String>,
},
List,
}
impl Executable for CommentCommand {
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
match self {
CommentCommand::Create { text, username } => {
create_comment(username.clone(), text.clone(), pool).await
}
CommentCommand::Remove { id } => remove_comment(*id, pool).await,
CommentCommand::RemoveFromUser { username } => {
remove_user_comment(username.clone(), pool).await
}
CommentCommand::ListFromUser { username } => {
list_user_comments(username.clone(), pool).await
}
CommentCommand::List => list_comments(pool).await,
}
}
}
async fn create_comment(
username: Option<String>,
text: Option<String>,
pool: &sqlx::PgPool,
) -> Result {
let prompt = "Who is creating the comment?";
let mut tx = pool.begin().await?;
let user = User::get_user_by_username_or_select(username.as_deref(), prompt, &mut *tx).await?;
let content = match text {
Some(text) => text,
None => inquire::Text::new("Content of the comment:")
.prompt()
.map_err(UserInputError::InquireError)?,
};
let comment = CommentDefault {
author_id: user.id,
content,
id: None,
};
let comment = comment.create(&mut *tx).await?;
tx.commit().await?;
println!("Successfuly created comment:\n{comment}");
Ok(())
}
async fn remove_comment(id: Option<i32>, pool: &sqlx::PgPool) -> Result {
let prompt = "Select the comment to remove:";
let mut tx = pool.begin().await?;
let comment = match id {
Some(id) => Comment::find(&mut *tx, &id)
.await
.map_err(UserInputError::DatabaseError)?
.ok_or(UserInputError::CommentDoesNotExist)?,
None => Comment::select_comment(prompt, &mut *tx).await?,
};
comment.delete(&mut *tx).await?;
tx.commit().await?;
Ok(())
}
async fn remove_user_comment(username: Option<String>, pool: &sqlx::PgPool) -> Result {
let mut tx = pool.begin().await?;
let prompt = "Select user whose comment you want to delete:";
let user = User::get_user_by_username_or_select(username.as_deref(), prompt, &mut *tx).await?;
let comments: HashMap<String, Comment> = user
.get_comments(&mut *tx)
.await?
.into_iter()
.map(|comment| (comment.content.clone(), comment))
.collect();
let selected_comment_content =
inquire::Select::new(prompt, comments.clone().into_keys().collect())
.prompt()
.map_err(UserInputError::InquireError)?;
let comment: &Comment = comments.get(&selected_comment_content).unwrap();
comment.delete(&mut *tx).await?;
tx.commit().await?;
Ok(())
}
async fn list_user_comments(username: Option<String>, pool: &sqlx::PgPool) -> Result {
let prompt = "User whose comment you want to list:";
let user = User::get_user_by_username_or_select(username.as_deref(), prompt, pool).await?;
println!("List of comments from user:\n");
for comment in user.get_comments(pool).await? {
println!("{comment}\n");
}
Ok(())
}
async fn list_comments(pool: &sqlx::PgPool) -> Result {
let comments = Comment::find_all(pool).await?;
println!("List of all comments:\n");
for comment in comments {
println!("{comment}\n")
}
Ok(())
}

View File

@@ -0,0 +1,141 @@
use super::{Executable, Result};
use crate::models::{FollowerDefault, User};
use clap::{Args, Subcommand};
use georm::Defaultable;
use std::collections::HashMap;
#[derive(Debug, Args, Clone)]
pub struct FollowersArgs {
#[command(subcommand)]
pub command: FollowersCommand,
}
impl Executable for FollowersArgs {
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
self.command.execute(pool).await
}
}
#[derive(Debug, Clone, Subcommand)]
pub enum FollowersCommand {
Follow {
follower: Option<String>,
followed: Option<String>,
},
Unfollow {
follower: Option<String>,
},
ListFollowers {
user: Option<String>,
},
ListFollowed {
user: Option<String>,
},
}
impl Executable for FollowersCommand {
async fn execute(&self, pool: &sqlx::PgPool) -> Result {
match self {
FollowersCommand::Follow { follower, followed } => {
follow_user(follower.clone(), followed.clone(), pool).await
}
FollowersCommand::Unfollow { follower } => unfollow_user(follower.clone(), pool).await,
FollowersCommand::ListFollowers { user } => {
list_user_followers(user.clone(), pool).await
}
FollowersCommand::ListFollowed { user } => list_user_followed(user.clone(), pool).await,
}
}
}
async fn follow_user(
follower: Option<String>,
followed: Option<String>,
pool: &sqlx::PgPool,
) -> Result {
let mut tx = pool.begin().await?;
let follower = User::get_user_by_username_or_select(
follower.as_deref(),
"Select who will be following someone:",
&mut *tx,
)
.await?;
let followed = User::get_user_by_username_or_select(
followed.as_deref(),
"Select who will be followed:",
&mut *tx,
)
.await?;
let follow = FollowerDefault {
id: None,
follower: follower.id,
followed: followed.id,
};
follow.create(&mut *tx).await?;
tx.commit().await?;
println!("User {follower} now follows {followed}");
Ok(())
}
async fn unfollow_user(follower: Option<String>, pool: &sqlx::PgPool) -> Result {
let mut tx = pool.begin().await?;
let follower = User::get_user_by_username_or_select(
follower.as_deref(),
"Select who is following",
&mut *tx,
)
.await?;
let followed_list: HashMap<String, User> = follower
.get_followed(&mut *tx)
.await?
.iter()
.map(|person| (person.username.clone(), person.clone()))
.collect();
let followed = inquire::Select::new(
"Who to unfollow?",
followed_list.clone().into_keys().collect(),
)
.prompt()
.unwrap();
let followed = followed_list.get(&followed).unwrap();
sqlx::query!(
"DELETE FROM Followers WHERE follower = $1 AND followed = $2",
follower.id,
followed.id
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
println!("User {follower} unfollowed {followed}");
Ok(())
}
async fn list_user_followers(user: Option<String>, pool: &sqlx::PgPool) -> Result {
let user = User::get_user_by_username_or_select(
user.as_deref(),
"Whose followers do you want to display?",
pool,
)
.await?;
println!("List of followers of {user}:\n");
user.get_followers(pool)
.await?
.iter()
.for_each(|person| println!("{person}"));
Ok(())
}
async fn list_user_followed(user: Option<String>, pool: &sqlx::PgPool) -> Result {
let user = User::get_user_by_username_or_select(
user.as_deref(),
"Whose follows do you want to display?",
pool,
)
.await?;
println!("List of people followed by {user}:\n");
user.get_followed(pool)
.await?
.iter()
.for_each(|person| println!("{person}"));
Ok(())
}

View File

@@ -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,
}
}
}

View 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(())
}

View 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),
}

View 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}"),
}
}

View File

@@ -0,0 +1,46 @@
use super::User;
use crate::{Result, errors::UserInputError};
use georm::Georm;
use std::collections::HashMap;
#[derive(Debug, Georm, Clone)]
#[georm(table = "Comments")]
pub struct Comment {
#[georm(id, defaultable)]
pub id: i32,
#[georm(relation = {
entity = User,
table = "Users",
name = "author"
})]
pub author_id: i32,
pub content: String,
}
impl Comment {
pub async fn select_comment<'e, E>(prompt: &str, executor: E) -> Result<Self>
where
E: sqlx::Executor<'e, Database = sqlx::Postgres>,
{
let comments: HashMap<String, Self> = Self::find_all(executor)
.await?
.into_iter()
.map(|comment| (comment.content.clone(), comment))
.collect();
let comment_content = inquire::Select::new(prompt, comments.clone().into_keys().collect())
.prompt()
.map_err(UserInputError::InquireError)?;
let comment: &Self = comments.get(&comment_content).unwrap();
Ok(comment.clone())
}
}
impl std::fmt::Display for Comment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Comment:\nID:\t{}\nAuthor:\t{}\nContent:\t{}",
self.id, self.author_id, self.content
)
}
}

View File

@@ -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,
}

View File

@@ -0,0 +1,8 @@
mod users;
pub use users::*;
mod profiles;
pub use profiles::*;
mod comments;
pub use comments::*;
mod followers;
pub use followers::*;

View File

@@ -0,0 +1,72 @@
use super::User;
use crate::{Result, errors::UserInputError};
use georm::{Defaultable, Georm};
#[derive(Debug, Georm, Default)]
#[georm(table = "Profiles")]
pub struct Profile {
#[georm(id, defaultable)]
pub id: i32,
#[georm(relation = {
entity = User,
table = "Users",
name = "user",
nullable = false
})]
pub user_id: i32,
pub bio: Option<String>,
pub display_name: Option<String>,
}
impl std::fmt::Display for Profile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Display Name:\t{}\nBiography:\n{}\n",
self.get_display_name(),
self.get_bio()
)
}
}
impl Profile {
pub fn get_display_name(&self) -> String {
self.display_name.clone().unwrap_or_default()
}
pub fn get_bio(&self) -> String {
self.bio.clone().unwrap_or_default()
}
pub async fn try_new<'e, E>(user_id: i32, executor: E) -> Result<Self>
where
E: sqlx::Executor<'e, Database = sqlx::Postgres>,
{
let profile = ProfileDefault {
user_id,
id: None,
bio: None,
display_name: None,
};
profile
.create(executor)
.await
.map_err(UserInputError::DatabaseError)
}
pub async fn update_interactive<'e, E>(
&mut self,
display_name: Option<String>,
bio: Option<String>,
executor: E,
) -> Result<Self>
where
E: sqlx::Executor<'e, Database = sqlx::Postgres>,
{
self.display_name = display_name;
self.bio = bio;
self.update(executor)
.await
.map_err(UserInputError::DatabaseError)
}
}

View File

@@ -0,0 +1,140 @@
use std::collections::HashMap;
use crate::{Result, errors::UserInputError};
use georm::{Defaultable, Georm};
use super::{Comment, Profile};
#[derive(Debug, Georm, Clone)]
#[georm(
table = "Users",
one_to_one = [{
name = "profile", remote_id = "user_id", table = "Profiles", entity = Profile
}],
one_to_many = [{
name = "comments", remote_id = "author_id", table = "Comments", entity = Comment
}],
many_to_many = [{
name = "followers",
table = "Users",
entity = User,
link = { table = "Followers", from = "followed", to = "follower" }
},
{
name = "followed",
table = "Users",
entity = User,
link = { table = "Followers", from = "follower", to = "followed" }
}
]
)]
pub struct User {
#[georm(id, defaultable)]
pub id: i32,
pub username: String,
}
impl std::fmt::Display for User {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} (ID: {})", self.username, self.id)
}
}
impl From<&str> for UserDefault {
fn from(value: &str) -> Self {
Self {
id: None,
username: value.to_string(),
}
}
}
impl User {
async fn select_user<'e, E>(prompt: &str, executor: E) -> Result<Self>
where
E: sqlx::Executor<'e, Database = sqlx::Postgres>,
{
let users: HashMap<String, Self> = Self::find_all(executor)
.await?
.into_iter()
.map(|user| (user.username.clone(), user))
.collect();
let username = inquire::Select::new(prompt, users.clone().into_keys().collect())
.prompt()
.map_err(UserInputError::InquireError)?;
let user: &Self = users.get(&username).unwrap();
Ok(user.clone())
}
pub async fn get_user_by_id_or_select<'e, E>(
id: Option<i32>,
prompt: &str,
executor: E,
) -> Result<Self>
where
E: sqlx::Executor<'e, Database = sqlx::Postgres>,
{
let user = match id {
Some(id) => Self::find(executor, &id)
.await?
.ok_or(UserInputError::UserDoesNotExist)?,
None => Self::select_user(prompt, executor).await?,
};
Ok(user)
}
pub async fn get_user_by_username_or_select<'e, E>(
username: Option<&str>,
prompt: &str,
executor: E,
) -> Result<Self>
where
E: sqlx::Executor<'e, Database = sqlx::Postgres>,
{
let user = match username {
Some(username) => Self::find_by_username(username, executor)
.await?
.ok_or(UserInputError::UserDoesNotExist)?,
None => Self::select_user(prompt, executor).await?,
};
Ok(user)
}
pub async fn find_by_username<'e, E>(username: &str, executor: E) -> Result<Option<Self>>
where
E: sqlx::Executor<'e, Database = sqlx::Postgres>,
{
sqlx::query_as!(
Self,
"SELECT * FROM Users u WHERE u.username = $1",
username
)
.fetch_optional(executor)
.await
.map_err(UserInputError::DatabaseError)
}
pub async fn try_new(username: &str, pool: &sqlx::PgPool) -> Result<Self> {
let user = UserDefault::from(username);
user.create(pool)
.await
.map_err(UserInputError::DatabaseError)
}
pub async fn remove_interactive(id: Option<i32>, pool: &sqlx::PgPool) -> Result<Self> {
let prompt = "Select a user to delete:";
let user = Self::get_user_by_id_or_select(id, prompt, pool).await?;
let _ = user.clone().delete(pool).await?;
Ok(user)
}
pub async fn update_profile(id: Option<i32>, pool: &sqlx::PgPool) -> Result<(User, Profile)> {
let prompt = "Select the user whose profile you want to update";
let user = Self::get_user_by_id_or_select(id, prompt, pool).await?;
let profile = match user.get_profile(pool).await? {
Some(profile) => profile,
None => Profile::try_new(user.id, pool).await?,
};
Ok((user, profile))
}
}

96
flake.lock generated
View File

@@ -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
}

View File

@@ -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
];
};
});
}

View File

@@ -0,0 +1,87 @@
use super::ir::GeormField;
use quote::quote;
#[derive(Debug)]
pub enum IdType {
Simple {
field_name: syn::Ident,
field_type: syn::Type,
},
Composite {
fields: Vec<IdField>,
field_type: syn::Ident,
},
}
#[derive(Debug, Clone)]
pub struct IdField {
pub name: syn::Ident,
pub ty: syn::Type,
}
fn field_to_code(field: &GeormField) -> proc_macro2::TokenStream {
let ident = field.ident.clone();
let ty = field.ty.clone();
quote! {
pub #ident: #ty
}
}
fn generate_struct(
ast: &syn::DeriveInput,
fields: &[GeormField],
) -> (syn::Ident, proc_macro2::TokenStream) {
let struct_name = &ast.ident;
let id_struct_name = quote::format_ident!("{struct_name}Id");
let vis = &ast.vis;
let fields: Vec<proc_macro2::TokenStream> = fields
.iter()
.filter_map(|field| {
if field.is_id {
Some(field_to_code(field))
} else {
None
}
})
.collect();
let code = quote! {
#vis struct #id_struct_name {
#(#fields),*
}
};
(id_struct_name, code)
}
pub fn create_primary_key(
ast: &syn::DeriveInput,
fields: &[GeormField],
) -> (IdType, proc_macro2::TokenStream) {
let georm_id_fields: Vec<&GeormField> = fields.iter().filter(|field| field.is_id).collect();
let id_fields: Vec<IdField> = georm_id_fields
.iter()
.map(|field| IdField {
name: field.ident.clone(),
ty: field.ty.clone(),
})
.collect();
match id_fields.len() {
0 => panic!("No ID field found"),
1 => (
IdType::Simple {
field_name: id_fields[0].name.clone(),
field_type: id_fields[0].ty.clone(),
},
quote! {},
),
_ => {
let (struct_name, struct_code) = generate_struct(ast, fields);
(
IdType::Composite {
fields: id_fields.clone(),
field_type: struct_name,
},
struct_code,
)
}
}
}

View File

@@ -0,0 +1,169 @@
//! This module creates the defaultable version of a structured derived with
//! Georm. It creates a new struct named `<StructName>Default` where the fields
//! marked as defaultable become an `Option<T>`, where `T` is the initial type
//! of the field.
//!
//! The user does not have to mark a field defaultable if the field already has
//! a type `Option<T>`. It is intended only for fields marked as `NOT NULL` in
//! the database, but not required when creating the entity due to a `DEFAULT`
//! or something similar. The type `<StructName>Default` implements the
//! `Defaultable` trait.
use crate::georm::ir::GeneratedType;
use super::ir::{GeormField, GeormStructAttributes};
use quote::quote;
fn create_defaultable_field(field: &GeormField) -> proc_macro2::TokenStream {
let ident = &field.ident;
let ty = &field.ty;
let vis = &field.field.vis;
// If the field is marked as defaultable, wrap it in Option<T>
// Otherwise, keep the original type
let field_type = if field.is_defaultable_behavior() {
quote! { Option<#ty> }
} else {
quote! { #ty }
};
quote! {
#vis #ident: #field_type
}
}
fn generate_defaultable_trait_impl(
struct_name: &syn::Ident,
defaultable_struct_name: &syn::Ident,
struct_attrs: &GeormStructAttributes,
fields: &[GeormField],
) -> proc_macro2::TokenStream {
let table = &struct_attrs.table;
// Find the ID field
let id_field = fields
.iter()
.find(|field| field.is_id)
.expect("Must have an ID field");
let id_type = &id_field.ty;
// Remove always generated fields
let fields: Vec<&GeormField> = fields
.iter()
.filter(|field| !matches!(field.generated_type, GeneratedType::Always))
.collect();
// Separate defaultable and non-defaultable fields
let non_defaultable_fields: Vec<_> = fields
.iter()
.filter(|f| !f.is_defaultable_behavior())
.collect();
let defaultable_fields: Vec<_> = fields
.iter()
.filter(|f| f.is_defaultable_behavior())
.collect();
// Build static parts for non-defaultable fields
let static_field_names: Vec<String> = non_defaultable_fields
.iter()
.map(|f| f.ident.to_string())
.collect();
let static_field_idents: Vec<&syn::Ident> =
non_defaultable_fields.iter().map(|f| &f.ident).collect();
// Generate field checks for defaultable fields
let mut field_checks = Vec::new();
let mut bind_checks = Vec::new();
for field in &defaultable_fields {
let field_name = field.ident.to_string();
let field_ident = &field.ident;
field_checks.push(quote! {
if self.#field_ident.is_some() {
dynamic_fields.push(#field_name);
}
});
bind_checks.push(quote! {
if let Some(ref value) = self.#field_ident {
query_builder = query_builder.bind(value);
}
});
}
quote! {
impl ::georm::Defaultable<#id_type, #struct_name> for #defaultable_struct_name {
async fn create<'e, E>(&self, mut executor: E) -> ::sqlx::Result<#struct_name>
where
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
{
let mut dynamic_fields = Vec::new();
#(#field_checks)*
let mut all_fields = vec![#(#static_field_names),*];
all_fields.extend(dynamic_fields);
let placeholders: Vec<String> = (1..=all_fields.len())
.map(|i| format!("${}", i))
.collect();
let query = format!(
"INSERT INTO {} ({}) VALUES ({}) RETURNING *",
#table,
all_fields.join(", "),
placeholders.join(", ")
);
let mut query_builder = ::sqlx::query_as::<_, #struct_name>(&query);
// Bind non-defaultable fields first
#(query_builder = query_builder.bind(&self.#static_field_idents);)*
// Then bind defaultable fields that have values
#(#bind_checks)*
query_builder.fetch_one(executor).await
}
}
}
}
pub fn derive_defaultable_struct(
ast: &syn::DeriveInput,
struct_attrs: &GeormStructAttributes,
fields: &[GeormField],
) -> proc_macro2::TokenStream {
// Only generate if there are defaultable fields
if fields.iter().all(|field| !field.is_defaultable_behavior()) {
return quote! {};
}
let struct_name = &ast.ident;
let vis = &ast.vis;
let defaultable_struct_name = quote::format_ident!("{}Default", struct_name);
let defaultable_fields: Vec<proc_macro2::TokenStream> = fields
.iter()
.flat_map(|field| match field.generated_type {
GeneratedType::Always => None,
_ => Some(create_defaultable_field(field)),
})
.collect();
let trait_impl = generate_defaultable_trait_impl(
struct_name,
&defaultable_struct_name,
struct_attrs,
fields,
);
quote! {
#vis struct #defaultable_struct_name {
#(#defaultable_fields),*
}
#trait_impl
}
}

View File

@@ -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
}
}
}
}

View File

@@ -0,0 +1,82 @@
use quote::quote;
#[derive(deluxe::ParseMetaItem, Clone)]
pub struct M2MLink {
pub table: String,
pub from: String,
pub to: String,
}
#[derive(deluxe::ParseMetaItem)]
pub struct M2MRelationship {
pub name: String,
pub entity: syn::Type,
pub table: String,
#[deluxe(default = String::from("id"))]
pub remote_id: String,
pub link: M2MLink,
}
pub struct Identifier {
pub table: String,
pub id: String,
}
pub struct M2MRelationshipComplete {
pub name: String,
pub entity: syn::Type,
pub local: Identifier,
pub remote: Identifier,
pub link: M2MLink,
}
impl M2MRelationshipComplete {
pub fn new(other: &M2MRelationship, local_table: &String, local_id: &String) -> Self {
Self {
name: other.name.clone(),
entity: other.entity.clone(),
link: other.link.clone(),
local: Identifier {
table: local_table.to_string(),
id: local_id.to_string(),
},
remote: Identifier {
table: other.table.clone(),
id: other.remote_id.clone(),
},
}
}
}
impl From<&M2MRelationshipComplete> for proc_macro2::TokenStream {
fn from(value: &M2MRelationshipComplete) -> Self {
let function = syn::Ident::new(
&format!("get_{}", value.name),
proc_macro2::Span::call_site(),
);
let entity = &value.entity;
let query = format!(
"SELECT remote.*
FROM {} local
JOIN {} link ON link.{} = local.{}
JOIN {} remote ON link.{} = remote.{}
WHERE local.{} = $1",
value.local.table,
value.link.table,
value.link.from,
value.local.id,
value.remote.table,
value.link.to,
value.remote.id,
value.local.id
);
quote! {
pub async fn #function<'e, E>(&self, mut executor: E) -> ::sqlx::Result<Vec<#entity>>
where
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
{
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_all(executor).await
}
}
}
}

View File

@@ -0,0 +1,183 @@
use quote::quote;
pub mod simple_relationship;
use simple_relationship::{OneToMany, OneToOne, SimpleRelationship};
pub mod m2m_relationship;
use m2m_relationship::M2MRelationship;
#[derive(deluxe::ExtractAttributes)]
#[deluxe(attributes(georm))]
pub struct GeormStructAttributes {
pub table: String,
#[deluxe(default = Vec::new())]
pub one_to_one: Vec<SimpleRelationship<OneToOne>>,
#[deluxe(default = Vec::new())]
pub one_to_many: Vec<SimpleRelationship<OneToMany>>,
#[deluxe(default = Vec::new())]
pub many_to_many: Vec<M2MRelationship>,
}
#[derive(deluxe::ExtractAttributes, Clone)]
#[deluxe(attributes(georm))]
struct GeormFieldAttributes {
#[deluxe(default = false)]
pub id: bool,
#[deluxe(default = None)]
pub relation: Option<O2ORelationship>,
#[deluxe(default = false)]
pub defaultable: bool,
#[deluxe(default = false)]
pub generated: bool,
#[deluxe(default = false)]
pub generated_always: bool,
}
#[derive(deluxe::ParseMetaItem, Clone, Debug)]
pub struct O2ORelationship {
pub entity: syn::Type,
pub table: String,
#[deluxe(default = String::from("id"))]
pub remote_id: String,
#[deluxe(default = false)]
pub nullable: bool,
pub name: String,
}
#[derive(Debug, Clone)]
pub enum GeneratedType {
None,
ByDefault, // #[georm(generated)] - BY DEFAULT behaviour
Always, // #[georm(generated_always)] - ALWAYS behaviour
}
#[derive(Clone, Debug)]
pub struct GeormField {
pub ident: syn::Ident,
pub field: syn::Field,
pub ty: syn::Type,
pub is_id: bool,
pub is_defaultable: bool,
pub generated_type: GeneratedType,
pub relation: Option<O2ORelationship>,
}
impl GeormField {
pub fn new(field: &mut syn::Field) -> Self {
let ident = field.clone().ident.unwrap();
let ty = field.clone().ty;
let attrs: GeormFieldAttributes =
deluxe::extract_attributes(field).expect("Could not extract attributes from field");
let GeormFieldAttributes {
id,
relation,
defaultable,
generated,
generated_always,
} = attrs;
// Validate that defaultable is not used on Option<T> fields
if defaultable && Self::is_option_type(&ty) {
panic!(
"Field '{}' is already an Option<T> and cannot be marked as defaultable.\
Remove the #[georm(defaultable)] attribute.",
ident
);
}
if generated && generated_always {
panic!(
"Field '{}' cannot have both the #[georm(generated)] and \
#[georm(generated_always)] attributes at the same time. Remove one\
of them before continuing.",
ident
);
}
Self {
ident,
field: field.to_owned(),
is_id: id,
ty,
relation,
is_defaultable: defaultable,
generated_type: if generated_always {
GeneratedType::Always
} else if generated {
GeneratedType::ByDefault
} else {
GeneratedType::None
},
}
}
/// Check if a type is Option<T>
fn is_option_type(ty: &syn::Type) -> bool {
match ty {
syn::Type::Path(type_path) => {
if let Some(segment) = type_path.path.segments.last() {
segment.ident == "Option"
} else {
false
}
}
_ => false,
}
}
/// Check if field should be excluded from INSERT statements
pub fn exclude_from_insert(&self) -> bool {
matches!(self.generated_type, GeneratedType::Always)
}
/// Check if field should be excluded from UPDATE statements
pub fn exclude_from_update(&self) -> bool {
matches!(self.generated_type, GeneratedType::Always)
}
/// Check if field should behave like a defaultable field
pub fn is_defaultable_behavior(&self) -> bool {
self.is_defaultable || matches!(self.generated_type, GeneratedType::ByDefault)
}
/// Check if field is any type of generated field
pub fn is_any_generated(&self) -> bool {
!matches!(self.generated_type, GeneratedType::None)
}
}
impl From<&GeormField> for proc_macro2::TokenStream {
fn from(value: &GeormField) -> Self {
let Some(relation) = value.relation.clone() else {
return quote! {};
};
let function = syn::Ident::new(
&format!("get_{}", relation.name),
proc_macro2::Span::call_site(),
);
let entity = &relation.entity;
let return_type = if relation.nullable {
quote! { Option<#entity> }
} else {
quote! { #entity }
};
let query = format!(
"SELECT * FROM {} WHERE {} = $1",
relation.table, relation.remote_id
);
let local_ident = &value.field.ident;
let fetch = if relation.nullable {
quote! { fetch_optional }
} else {
quote! { fetch_one }
};
quote! {
pub async fn #function<'e, E>(&self, mut executor: E) -> ::sqlx::Result<#return_type>
where
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
{
::sqlx::query_as!(#entity, #query, self.#local_ident).#fetch(executor).await
}
}
}
}

View File

@@ -0,0 +1,72 @@
use quote::quote;
pub trait SimpleRelationshipType {}
#[derive(deluxe::ParseMetaItem, Default)]
pub struct OneToOne;
impl SimpleRelationshipType for OneToOne {}
#[derive(deluxe::ParseMetaItem, Default)]
pub struct OneToMany;
impl SimpleRelationshipType for OneToMany {}
#[derive(deluxe::ParseMetaItem)]
pub struct SimpleRelationship<T>
where
T: SimpleRelationshipType + deluxe::ParseMetaItem + Default,
{
pub name: String,
pub remote_id: String,
pub table: String,
pub entity: syn::Type,
#[deluxe(default = T::default())]
_phantom: T,
}
impl<T> SimpleRelationship<T>
where
T: SimpleRelationshipType + deluxe::ParseMetaItem + Default,
{
pub fn make_query(&self) -> String {
format!("SELECT * FROM {} WHERE {} = $1", self.table, self.remote_id)
}
pub fn make_function_name(&self) -> syn::Ident {
syn::Ident::new(
&format!("get_{}", self.name),
proc_macro2::Span::call_site(),
)
}
}
impl From<&SimpleRelationship<OneToOne>> for proc_macro2::TokenStream {
fn from(value: &SimpleRelationship<OneToOne>) -> Self {
let query = value.make_query();
let entity = &value.entity;
let function = value.make_function_name();
quote! {
pub async fn #function<'e, E>(&self, mut executor: E) -> ::sqlx::Result<Option<#entity>>
where
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
{
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_optional(executor).await
}
}
}
}
impl From<&SimpleRelationship<OneToMany>> for proc_macro2::TokenStream {
fn from(value: &SimpleRelationship<OneToMany>) -> Self {
let query = value.make_query();
let entity = &value.entity;
let function = value.make_function_name();
quote! {
pub async fn #function<'e, E>(&self, mut executor: E) -> ::sqlx::Result<Vec<#entity>>
where
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
{
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_all(executor).await
}
}
}
}

View File

@@ -1,13 +1,14 @@
use ir::GeormField;
use quote::quote;
mod composite_keys;
mod defaultable_struct;
mod ir;
pub(crate) use ir::GeormField;
mod relationships;
mod trait_implementation;
mod traits;
pub(crate) use composite_keys::IdType;
fn extract_georm_field_attrs(
ast: &mut syn::DeriveInput,
) -> deluxe::Result<(Vec<GeormField>, GeormField)> {
fn extract_georm_field_attrs(ast: &mut syn::DeriveInput) -> deluxe::Result<Vec<GeormField>> {
let syn::Data::Struct(s) = &mut ast.data else {
return Err(syn::Error::new_spanned(
ast,
@@ -23,22 +24,15 @@ fn extract_georm_field_attrs(
let identifiers: Vec<GeormField> = fields
.clone()
.into_iter()
.filter(|field| field.id)
.filter(|field| field.is_id)
.collect();
match identifiers.len() {
0 => Err(syn::Error::new_spanned(
if identifiers.is_empty() {
Err(syn::Error::new_spanned(
ast,
"Struct {name} must have one identifier",
)),
1 => Ok((fields, identifiers.first().unwrap().clone())),
_ => {
let id1 = identifiers.first().unwrap();
let id2 = identifiers.get(1).unwrap();
Err(syn::Error::new_spanned(id2.field.clone(), format!(
"Field {} cannot be an identifier, {} already is one.\nOnly one identifier is supported.",
id1.ident, id2.ident
)))
}
))
} else {
Ok(fields)
}
}
@@ -48,12 +42,43 @@ pub fn georm_derive_macro2(
let mut ast: syn::DeriveInput = syn::parse2(item).expect("Failed to parse input");
let struct_attrs: ir::GeormStructAttributes =
deluxe::extract_attributes(&mut ast).expect("Could not extract attributes from struct");
let (fields, id) = extract_georm_field_attrs(&mut ast)?;
let relationships = relationships::derive_relationships(&ast, &struct_attrs, &fields, &id);
let trait_impl = trait_implementation::derive_trait(&ast, &struct_attrs.table, &fields, &id);
let fields = extract_georm_field_attrs(&mut ast)?;
let defaultable_struct =
defaultable_struct::derive_defaultable_struct(&ast, &struct_attrs, &fields);
let from_row_impl = generate_from_row_impl(&ast, &fields);
let (identifier, id_struct) = composite_keys::create_primary_key(&ast, &fields);
let relationships =
relationships::derive_relationships(&ast, &struct_attrs, &fields, &identifier);
let trait_impl = traits::derive_trait(&ast, &struct_attrs.table, &fields, &identifier);
let code = quote! {
#id_struct
#defaultable_struct
#relationships
#trait_impl
#from_row_impl
};
Ok(code)
}
fn generate_from_row_impl(
ast: &syn::DeriveInput,
fields: &[GeormField],
) -> proc_macro2::TokenStream {
let struct_name = &ast.ident;
let field_idents: Vec<&syn::Ident> = fields.iter().map(|f| &f.ident).collect();
let field_names: Vec<String> = fields.iter().map(|f| f.ident.to_string()).collect();
quote! {
impl<'r> ::sqlx::FromRow<'r, ::sqlx::postgres::PgRow> for #struct_name {
fn from_row(row: &'r ::sqlx::postgres::PgRow) -> ::sqlx::Result<Self> {
use ::sqlx::Row;
Ok(Self {
#(#field_idents: row.try_get(#field_names)?),*
})
}
}
}
}

View File

@@ -1,7 +1,8 @@
use std::str::FromStr;
use crate::georm::ir::M2MRelationshipComplete;
use crate::georm::ir::m2m_relationship::M2MRelationshipComplete;
use super::composite_keys::IdType;
use super::ir::GeormField;
use proc_macro2::TokenStream;
use quote::quote;
@@ -15,16 +16,12 @@ fn join_token_streams(token_streams: &[TokenStream]) -> TokenStream {
.collect()
}
fn derive<T, P>(relationships: &[T], condition: P) -> TokenStream
fn derive<T>(relationships: &[T]) -> TokenStream
where
for<'a> &'a T: Into<TokenStream>,
P: FnMut(&&T) -> bool,
{
let implementations: Vec<TokenStream> = relationships
.iter()
.filter(condition)
.map(std::convert::Into::into)
.collect();
let implementations: Vec<TokenStream> =
relationships.iter().map(std::convert::Into::into).collect();
join_token_streams(&implementations)
}
@@ -32,21 +29,39 @@ pub fn derive_relationships(
ast: &syn::DeriveInput,
struct_attrs: &super::ir::GeormStructAttributes,
fields: &[GeormField],
id: &GeormField,
id: &IdType,
) -> TokenStream {
let id = match id {
IdType::Simple {
field_name,
field_type: _,
} => field_name.to_string(),
IdType::Composite {
fields: _,
field_type: _,
} => {
eprintln!(
"Warning: entity {}: Relationships are not supported for entities with composite primary keys yet",
ast.ident
);
return quote! {};
}
};
let struct_name = &ast.ident;
let one_to_one = derive(fields, |field| field.relation.is_some());
let one_to_many = derive(&struct_attrs.one_to_many, |_| true);
let one_to_one_local = derive(fields);
let one_to_one_remote = derive(&struct_attrs.one_to_one);
let one_to_many = derive(&struct_attrs.one_to_many);
let many_to_many: Vec<M2MRelationshipComplete> = struct_attrs
.many_to_many
.iter()
.map(|v| M2MRelationshipComplete::new(v, &struct_attrs.table, id.ident.to_string()))
.map(|v| M2MRelationshipComplete::new(v, &struct_attrs.table, &id))
.collect();
let many_to_many = derive(&many_to_many, |_| true);
let many_to_many = derive(&many_to_many);
quote! {
impl #struct_name {
#one_to_one
#one_to_one_local
#one_to_one_remote
#one_to_many
#many_to_many
}

View File

@@ -1,139 +0,0 @@
use super::ir::GeormField;
use quote::quote;
fn generate_find_all_query(table: &str) -> proc_macro2::TokenStream {
let find_string = format!("SELECT * FROM {table}");
quote! {
async fn find_all(pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<Self>> {
::sqlx::query_as!(Self, #find_string).fetch_all(pool).await
}
}
}
fn generate_find_query(table: &str, id: &GeormField) -> proc_macro2::TokenStream {
let find_string = format!("SELECT * FROM {table} WHERE {} = $1", id.ident);
let ty = &id.ty;
quote! {
async fn find(pool: &::sqlx::PgPool, id: &#ty) -> ::sqlx::Result<Option<Self>> {
::sqlx::query_as!(Self, #find_string, id)
.fetch_optional(pool)
.await
}
}
}
fn generate_create_query(table: &str, fields: &[GeormField]) -> proc_macro2::TokenStream {
let inputs: Vec<String> = (1..=fields.len()).map(|num| format!("${num}")).collect();
let create_string = format!(
"INSERT INTO {table} ({}) VALUES ({}) RETURNING *",
fields
.iter()
.map(|f| f.ident.to_string())
.collect::<Vec<String>>()
.join(", "),
inputs.join(", ")
);
let field_idents: Vec<syn::Ident> = fields.iter().map(|f| f.ident.clone()).collect();
quote! {
async fn create(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Self> {
::sqlx::query_as!(
Self,
#create_string,
#(self.#field_idents),*
)
.fetch_one(pool)
.await
}
}
}
fn generate_update_query(
table: &str,
fields: &[GeormField],
id: &GeormField,
) -> proc_macro2::TokenStream {
let mut fields: Vec<&GeormField> = fields.iter().filter(|f| !f.id).collect();
let update_columns = fields
.iter()
.enumerate()
.map(|(i, &field)| format!("{} = ${}", field.ident, i + 1))
.collect::<Vec<String>>()
.join(", ");
let update_string = format!(
"UPDATE {table} SET {update_columns} WHERE {} = ${} RETURNING *",
id.ident,
fields.len() + 1
);
fields.push(id);
let field_idents: Vec<_> = fields.iter().map(|f| f.ident.clone()).collect();
quote! {
async fn update(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Self> {
::sqlx::query_as!(
Self,
#update_string,
#(self.#field_idents),*
)
.fetch_one(pool)
.await
}
}
}
fn generate_delete_query(table: &str, id: &GeormField) -> proc_macro2::TokenStream {
let delete_string = format!("DELETE FROM {table} WHERE {} = $1", id.ident);
let ty = &id.ty;
quote! {
async fn delete_by_id(pool: &::sqlx::PgPool, id: &#ty) -> ::sqlx::Result<u64> {
let rows_affected = ::sqlx::query!(#delete_string, id)
.execute(pool)
.await?
.rows_affected();
Ok(rows_affected)
}
async fn delete(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<u64> {
Self::delete_by_id(pool, self.get_id()).await
}
}
}
fn generate_get_id(id: &GeormField) -> proc_macro2::TokenStream {
let ident = &id.ident;
let ty = &id.ty;
quote! {
fn get_id(&self) -> &#ty {
&self.#ident
}
}
}
pub fn derive_trait(
ast: &syn::DeriveInput,
table: &str,
fields: &[GeormField],
id: &GeormField,
) -> proc_macro2::TokenStream {
let ty = &id.ty;
// define impl variables
let ident = &ast.ident;
let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl();
// generate
let get_all = generate_find_all_query(table);
let get_id = generate_get_id(id);
let find_query = generate_find_query(table, id);
let create_query = generate_create_query(table, fields);
let update_query = generate_update_query(table, fields, id);
let delete_query = generate_delete_query(table, id);
quote! {
impl #impl_generics Georm<#ty> for #ident #type_generics #where_clause {
#get_all
#get_id
#find_query
#create_query
#update_query
#delete_query
}
}
}

View File

@@ -0,0 +1,37 @@
use crate::georm::GeormField;
use quote::quote;
pub fn generate_create_query(table_name: &str, fields: &[GeormField]) -> proc_macro2::TokenStream {
let insert_fields: Vec<&GeormField> = fields
.iter()
.filter(|field| !field.exclude_from_insert())
.collect();
let field_names: Vec<String> = insert_fields
.iter()
.map(|field| field.ident.to_string())
.collect();
let field_idents: Vec<syn::Ident> = insert_fields
.iter()
.map(|field| field.ident.clone())
.collect();
let placeholders: Vec<String> = (1..=insert_fields.len()).map(|i| format!("${i}")).collect();
let query = format!(
"INSERT INTO {table_name} ({}) VALUES ({}) RETURNING *",
field_names.join(", "),
placeholders.join(", ")
);
quote! {
async fn create<'e, E>(&self, mut executor: E) -> ::sqlx::Result<Self>
where
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
{
::sqlx::query_as!(
Self,
#query,
#(self.#field_idents),*
)
.fetch_one(executor)
.await
}
}
}

View File

@@ -0,0 +1,45 @@
use crate::georm::IdType;
use quote::quote;
pub fn generate_delete_query(table: &str, id: &IdType) -> proc_macro2::TokenStream {
let where_clause = match id {
IdType::Simple { field_name, .. } => format!("{} = $1", field_name),
IdType::Composite { fields, .. } => fields
.iter()
.enumerate()
.map(|(i, field)| format!("{} = ${}", field.name, i + 1))
.collect::<Vec<String>>()
.join(" AND "),
};
let query_args = match id {
IdType::Simple { .. } => quote! { id },
IdType::Composite { fields, .. } => {
let fields: Vec<syn::Ident> = fields.iter().map(|f| f.name.clone()).collect();
quote! { #(id.#fields), * }
}
};
let id_type = match id {
IdType::Simple { field_type, .. } => quote! { #field_type },
IdType::Composite { field_type, .. } => quote! { #field_type },
};
let delete_string = format!("DELETE FROM {table} WHERE {where_clause}");
quote! {
async fn delete<'e, E>(&self, mut executor: E) -> ::sqlx::Result<u64>
where
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
{
Self::delete_by_id(executor, &self.get_id()).await
}
async fn delete_by_id<'e, E>(mut executor: E, id: &#id_type) -> ::sqlx::Result<u64>
where
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
{
let rows_affected = ::sqlx::query!(#delete_string, #query_args)
.execute(executor)
.await?
.rows_affected();
Ok(rows_affected)
}
}
}

View File

@@ -0,0 +1,56 @@
use crate::georm::IdType;
use quote::quote;
pub fn generate_find_all_query(table: &str) -> proc_macro2::TokenStream {
let find_string = format!("SELECT * FROM {table}");
quote! {
async fn find_all<'e, E>(mut executor: E) -> ::sqlx::Result<Vec<Self>>
where
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
{
::sqlx::query_as!(Self, #find_string).fetch_all(executor).await
}
}
}
pub fn generate_find_query(table: &str, id: &IdType) -> proc_macro2::TokenStream {
match id {
IdType::Simple {
field_name,
field_type,
} => {
let find_string = format!("SELECT * FROM {table} WHERE {} = $1", field_name);
quote! {
async fn find<'e, E>(mut executor: E, id: &#field_type) -> ::sqlx::Result<Option<Self>>
where
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
{
::sqlx::query_as!(Self, #find_string, id)
.fetch_optional(executor)
.await
}
}
}
IdType::Composite { fields, field_type } => {
let id_match_string = fields
.iter()
.enumerate()
.map(|(i, field)| format!("{} = ${}", field.name, i + 1))
.collect::<Vec<String>>()
.join(" AND ");
let id_members: Vec<syn::Ident> =
fields.iter().map(|field| field.name.clone()).collect();
let find_string = format!("SELECT * FROM {table} WHERE {id_match_string}");
quote! {
async fn find<'e, E>(mut executor: E, id: &#field_type) -> ::sqlx::Result<Option<Self>>
where
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
{
::sqlx::query_as!(Self, #find_string, #(id.#id_members),*)
.fetch_optional(executor)
.await
}
}
}
}
}

View File

@@ -0,0 +1,70 @@
use super::composite_keys::IdType;
use super::ir::GeormField;
use quote::quote;
mod create;
mod delete;
mod find;
mod update;
mod upsert;
fn generate_get_id(id: &IdType) -> proc_macro2::TokenStream {
match id {
IdType::Simple {
field_name,
field_type,
} => {
quote! {
fn get_id(&self) -> #field_type {
self.#field_name.clone()
}
}
}
IdType::Composite { fields, field_type } => {
let field_names: Vec<syn::Ident> = fields.iter().map(|f| f.name.clone()).collect();
quote! {
fn get_id(&self) -> #field_type {
#field_type {
#(#field_names: self.#field_names),*
}
}
}
}
}
}
pub fn derive_trait(
ast: &syn::DeriveInput,
table: &str,
fields: &[GeormField],
id: &IdType,
) -> proc_macro2::TokenStream {
let ty = match id {
IdType::Simple { field_type, .. } => quote! {#field_type},
IdType::Composite { field_type, .. } => quote! {#field_type},
};
// define impl variables
let ident = &ast.ident;
let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl();
// generate
let get_id = generate_get_id(id);
let get_all = find::generate_find_all_query(table);
let find_query = find::generate_find_query(table, id);
let create_query = create::generate_create_query(table, fields);
let update_query = update::generate_update_query(table, fields);
let upsert_query = upsert::generate_upsert_query(table, fields, id);
let delete_query = delete::generate_delete_query(table, id);
quote! {
impl #impl_generics Georm<#ty> for #ident #type_generics #where_clause {
#get_all
#get_id
#find_query
#create_query
#update_query
#upsert_query
#delete_query
}
}
}

View File

@@ -0,0 +1,45 @@
use crate::georm::GeormField;
use quote::quote;
pub fn generate_update_query(table_name: &str, fields: &[GeormField]) -> proc_macro2::TokenStream {
let update_fields: Vec<&GeormField> = fields
.iter()
.filter(|field| !field.is_id && !field.exclude_from_update())
.collect();
let update_idents: Vec<syn::Ident> = update_fields
.iter()
.map(|field| field.ident.clone())
.collect();
let id_fields: Vec<&GeormField> = fields.iter().filter(|field| field.is_id).collect();
let id_idents: Vec<syn::Ident> = id_fields.iter().map(|f| f.ident.clone()).collect();
let set_clauses: Vec<String> = update_fields
.iter()
.enumerate()
.map(|(i, field)| format!("{} = ${}", field.ident, i + 1))
.collect();
let where_clauses: Vec<String> = id_fields
.iter()
.enumerate()
.map(|(i, field)| format!("{} = ${}", field.ident, update_fields.len() + i + 1))
.collect();
let query = format!(
"UPDATE {table_name} SET {} WHERE {} RETURNING *",
set_clauses.join(", "),
where_clauses.join(" AND ")
);
quote! {
async fn update<'e, E>(&self, mut executor: E) -> ::sqlx::Result<Self>
where
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
{
::sqlx::query_as!(
Self,
#query,
#(self.#update_idents),*,
#(self.#id_idents),*
)
.fetch_one(executor)
.await
}
}
}

View File

@@ -0,0 +1,60 @@
use crate::georm::{GeormField, IdType, ir::GeneratedType};
use quote::quote;
pub fn generate_upsert_query(
table: &str,
fields: &[GeormField],
id: &IdType,
) -> proc_macro2::TokenStream {
let fields: Vec<&GeormField> = fields
.iter()
.filter(|field| !matches!(field.generated_type, GeneratedType::Always))
.collect();
let inputs: Vec<String> = (1..=fields.len()).map(|num| format!("${num}")).collect();
let columns = fields
.iter()
.map(|f| f.ident.to_string())
.collect::<Vec<String>>()
.join(", ");
let primary_key: proc_macro2::TokenStream = match id {
IdType::Simple { field_name, .. } => quote! {#field_name},
IdType::Composite { fields, .. } => {
let field_names: Vec<syn::Ident> = fields.iter().map(|f| f.name.clone()).collect();
quote! {
#(#field_names),*
}
}
};
// For ON CONFLICT DO UPDATE, exclude the ID field from updates
let update_assignments = fields
.iter()
.filter(|f| !f.is_id)
.map(|f| format!("{} = EXCLUDED.{}", f.ident, f.ident))
.collect::<Vec<String>>()
.join(", ");
let upsert_string = format!(
"INSERT INTO {table} ({columns}) VALUES ({}) ON CONFLICT ({}) DO UPDATE SET {update_assignments} RETURNING *",
inputs.join(", "),
primary_key
);
let field_idents: Vec<syn::Ident> = fields.iter().map(|f| f.ident.clone()).collect();
quote! {
async fn upsert<'e, E>(&self, mut executor: E) -> ::sqlx::Result<Self>
where
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
{
::sqlx::query_as!(
Self,
#upsert_string,
#(self.#field_idents),*
)
.fetch_one(executor)
.await
}
}
}

View File

@@ -1,5 +1,3 @@
mod docker
default: lint
clean:

View File

@@ -0,0 +1,4 @@
DROP TABLE IF EXISTS Followers;
DROP TABLE IF EXISTS Comments;
DROP TABLE IF EXISTS Profiles;
DROP TABLE IF EXISTS Users;

View File

@@ -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)
);

View File

@@ -0,0 +1,2 @@
-- Add down migration script here
DROP TABLE IF EXISTS UserRoles;

View File

@@ -0,0 +1,7 @@
-- Add up migration script here
CREATE TABLE UserRoles (
user_id INTEGER NOT NULL,
role_id INTEGER NOT NULL,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, role_id)
);

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS products;

View File

@@ -0,0 +1,12 @@
CREATE TABLE products (
-- strictly autogenerated
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
-- auto-generated but allows manual override
sku_number INTEGER GENERATED BY DEFAULT AS IDENTITY,
name VARCHAR(100) NOT NULL,
price DECIMAL(10, 2) NOT NULL,
discount_percent INTEGER DEFAULT 0 NOT NULL,
final_price DECIMAL(10, 2) GENERATED ALWAYS AS (price * (1 - discount_percent / 100.0)) STORED
);

View File

@@ -1,4 +1,4 @@
# Do not increase during a minor/patch release cycle
[toolchain]
channel = "1.81"
channel = "1.86"
profile = "minimal"

281
src/defaultable.rs Normal file
View File

@@ -0,0 +1,281 @@
use sqlx::{Executor, Postgres};
/// Trait for creating entities with database defaults and auto-generated values.
///
/// This trait is automatically implemented on generated companion structs for entities
/// that have fields marked with `#[georm(defaultable)]`. It provides a convenient way
/// to create entities while allowing the database to provide default values for certain
/// fields.
///
/// ## Generated Implementation
///
/// When you mark fields with `#[georm(defaultable)]`, Georm automatically generates:
/// - A companion struct named `{EntityName}Default`
/// - An implementation of this trait for the companion struct
/// - Optimized SQL that omits defaultable fields when they are `None`
///
/// ## How It Works
///
/// The generated companion struct transforms defaultable fields into `Option<T>` types:
/// - `Some(value)` - Use the provided value
/// - `None` - Let the database provide the default value
///
/// Non-defaultable fields remain unchanged and are always required.
///
/// ## Database Behavior
///
/// The `create` method generates SQL that:
/// - Only includes fields where `Some(value)` is provided
/// - Omits fields that are `None`, allowing database defaults to apply
/// - Uses `RETURNING *` to capture the final entity state with all defaults applied
/// - Respects database triggers, sequences, and default value expressions
///
/// ## Usage Examples
///
/// ```ignore
/// use georm::{Georm, Defaultable};
///
/// #[derive(Georm)]
/// #[georm(table = "posts")]
/// pub struct Post {
/// #[georm(id, defaultable)]
/// id: i32, // Auto-generated serial
/// title: String, // Required field
/// #[georm(defaultable)]
/// published: bool, // Database default: false
/// #[georm(defaultable)]
/// created_at: chrono::DateTime<chrono::Utc>, // Database default: NOW()
/// author_id: i32, // Required field
/// }
///
/// // Generated automatically:
/// // pub struct PostDefault {
/// // pub id: Option<i32>,
/// // pub title: String,
/// // pub published: Option<bool>,
/// // pub created_at: Option<chrono::DateTime<chrono::Utc>>,
/// // pub author_id: i32,
/// // }
/// //
/// // impl Defaultable<i32, Post> for PostDefault { ... }
///
/// // Create with some defaults
/// let post_default = PostDefault {
/// id: None, // Let database auto-generate
/// title: "My Blog Post".to_string(),
/// published: None, // Use database default (false)
/// created_at: None, // Use database default (NOW())
/// author_id: 42,
/// };
///
/// let created_post = post_default.create(&pool).await?;
/// println!("Created post with ID: {}", created_post.id);
///
/// // Create with explicit values
/// let post_default = PostDefault {
/// id: None, // Still auto-generate ID
/// title: "Published Post".to_string(),
/// published: Some(true), // Override default
/// created_at: Some(specific_time), // Override default
/// author_id: 42,
/// };
/// ```
///
/// ## Type Parameters
///
/// - `Id` - The primary key type of the target entity (e.g., `i32`, `UserRoleId`)
/// - `Entity` - The target entity type that will be created (e.g., `Post`, `User`)
///
/// ## Comparison with Regular Creation
///
/// ```ignore
/// // Using regular Georm::create - must provide all values
/// let post = Post {
/// id: 0, // Ignored for auto-increment, but required
/// title: "My Post".to_string(),
/// published: false, // Must specify even if it's the default
/// created_at: chrono::Utc::now(), // Must calculate current time manually
/// author_id: 42,
/// };
/// let created = post.create(&pool).await?;
///
/// // Using Defaultable::create - let database handle defaults
/// let post_default = PostDefault {
/// id: None, // Clearer intent for auto-generation
/// title: "My Post".to_string(),
/// published: None, // Let database default apply
/// created_at: None, // Let database calculate NOW()
/// author_id: 42,
/// };
/// let created = post_default.create(&pool).await?;
/// ```
///
/// ## Field Visibility
///
/// The generated companion struct preserves the field visibility of the original entity:
///
/// ```ignore
/// #[derive(Georm)]
/// #[georm(table = "posts")]
/// pub struct Post {
/// #[georm(id, defaultable)]
/// pub id: i32,
/// pub title: String,
/// #[georm(defaultable)]
/// pub(crate) internal_status: String, // Crate-private field
/// #[georm(defaultable)]
/// private_field: String, // Private field
/// }
///
/// // Generated with preserved visibility:
/// // pub struct PostDefault {
/// // pub id: Option<i32>,
/// // pub title: String,
/// // pub(crate) internal_status: Option<String>, // Preserved
/// // private_field: Option<String>, // Preserved
/// // }
/// ```
///
/// ## Limitations and Rules
///
/// - **Option fields cannot be defaultable**: Fields that are already `Option<T>` cannot
/// be marked with `#[georm(defaultable)]` to prevent `Option<Option<T>>` types
/// - **Compile-time validation**: Attempts to mark `Option<T>` fields as defaultable
/// result in compile-time errors
/// - **Requires at least one defaultable field**: The companion struct is only generated
/// if at least one field is marked as defaultable
/// - **No partial updates**: This trait only supports creating new entities, not updating
/// existing ones with defaults
///
/// ## Error Handling
///
/// The `create` method can fail for the same reasons as regular entity creation:
/// - Database connection issues
/// - Constraint violations (unique, foreign key, NOT NULL for non-defaultable fields)
/// - Permission problems
/// - Table or column doesn't exist
///
/// ## Performance Characteristics
///
/// - **Efficient SQL**: Only includes necessary fields in the INSERT statement
/// - **Single round-trip**: Uses `RETURNING *` to get the final entity state
/// - **No overhead**: Defaultable logic is resolved at compile time
/// - **Database-optimized**: Leverages database defaults rather than application logic
pub trait Defaultable<Id, Entity> {
/// Create a new entity in the database using database defaults for unspecified fields.
///
/// This method constructs and executes an `INSERT INTO table_name (...) VALUES (...) RETURNING *`
/// query that only includes fields where `Some(value)` is provided. Fields that are `None`
/// are omitted from the query, allowing the database to apply default values, auto-increment
/// sequences, or trigger-generated values.
///
/// # Parameters
/// - `pool` - Database connection pool
///
/// # Returns
/// - `Ok(Entity)` - The newly created entity with all database-generated values populated
/// - `Err(sqlx::Error)` - Database constraint violations or connection errors
///
/// # Database Behavior
/// - **Selective field inclusion**: Only includes fields with `Some(value)` in the INSERT
/// - **Default value application**: Database defaults apply to omitted fields
/// - **RETURNING clause**: Captures the complete entity state after insertion
/// - **Trigger execution**: Database triggers run and their effects are captured
/// - **Sequence generation**: Auto-increment values are generated and returned
///
/// # SQL Generation
///
/// The generated SQL dynamically includes only the necessary fields:
///
/// ```sql
/// -- If id=None, published=None, created_at=None:
/// INSERT INTO posts (title, author_id) VALUES ($1, $2) RETURNING *;
///
/// -- If id=None, published=Some(true), created_at=None:
/// INSERT INTO posts (title, published, author_id) VALUES ($1, $2, $3) RETURNING *;
/// ```
///
/// # Examples
///
/// ```ignore
/// // Minimal creation - let database handle all defaults
/// let post_default = PostDefault {
/// id: None, // Auto-generated
/// title: "Hello World".to_string(),
/// published: None, // Database default
/// created_at: None, // Database default (NOW())
/// author_id: 1,
/// };
/// let post = post_default.create(&pool).await?;
///
/// // Mixed creation - some explicit values, some defaults
/// let post_default = PostDefault {
/// id: None, // Auto-generated
/// title: "Published Post".to_string(),
/// published: Some(true), // Override default
/// created_at: None, // Still use database default
/// author_id: 1,
/// };
/// let post = post_default.create(&pool).await?;
///
/// // Full control - specify all defaultable values
/// let specific_time = chrono::Utc::now() - chrono::Duration::hours(1);
/// let post_default = PostDefault {
/// id: Some(100), // Explicit ID (if not auto-increment)
/// title: "Backdated Post".to_string(),
/// published: Some(false), // Explicit value
/// created_at: Some(specific_time), // Explicit timestamp
/// author_id: 1,
/// };
/// let post = post_default.create(&pool).await?;
/// ```
///
/// # Error Conditions
///
/// Returns `sqlx::Error` for:
/// - **Unique constraint violations**: Duplicate values for unique fields
/// - **Foreign key violations**: Invalid references to other tables
/// - **NOT NULL violations**: Missing values for required non-defaultable fields
/// - **Check constraint violations**: Values that don't meet database constraints
/// - **Database connection issues**: Network or connection pool problems
/// - **Permission problems**: Insufficient privileges for the operation
/// - **Table/column errors**: Missing tables or columns (usually caught at compile time)
///
/// # Performance Notes
///
/// - **Optimal field selection**: Only transmits necessary data to the database
/// - **Single database round-trip**: INSERT and retrieval in one operation
/// - **Compile-time optimization**: Field inclusion logic resolved at compile time
/// - **Database-native defaults**: Leverages database performance for default value generation
///
/// # Comparison with Standard Creation
///
/// ```ignore
/// // Standard Georm::create - all fields required
/// let post = Post {
/// id: 0, // Placeholder for auto-increment
/// title: "My Post".to_string(),
/// published: false, // Must specify, even if it's the default
/// created_at: chrono::Utc::now(), // Must calculate manually
/// author_id: 1,
/// };
/// let created = post.create(&pool).await?;
///
/// // Defaultable::create - only specify what you need
/// let post_default = PostDefault {
/// id: None, // Clear intent for auto-generation
/// title: "My Post".to_string(),
/// published: None, // Let database decide
/// created_at: None, // Let database calculate
/// author_id: 1,
/// };
/// let created = post_default.create(&pool).await?;
/// ```
fn create<'e, E>(
&self,
executor: E,
) -> impl std::future::Future<Output = sqlx::Result<Entity>> + Send
where
Self: Sized,
E: Executor<'e, Database = Postgres>;
}

390
src/georm.rs Normal file
View File

@@ -0,0 +1,390 @@
use sqlx::{Executor, Postgres};
/// Core database operations trait for Georm entities.
///
/// This trait is automatically implemented by the `#[derive(Georm)]` macro and provides
/// all essential CRUD operations for database entities. The trait is generic over the
/// primary key type `Id`, which can be a simple type (e.g., `i32`) or a generated
/// composite key struct (e.g., `UserRoleId`).
///
/// ## Generated Implementation
///
/// When you derive `Georm` on a struct, this trait is automatically implemented with
/// PostgreSQL-optimized queries that use:
/// - **Prepared statements** for security and performance
/// - **RETURNING clause** to capture database-generated values
/// - **ON CONFLICT** for efficient upsert operations
/// - **Compile-time verification** via SQLx macros
///
/// ## Method Categories
///
/// ### Static Methods (Query Operations)
/// - [`find_all`] - Retrieve all entities from the table
/// - [`find`] - Retrieve a single entity by primary key
/// - [`delete_by_id`] - Delete an entity by primary key
///
/// ### Instance Methods (Mutation Operations)
/// - [`create`] - Insert a new entity into the database
/// - [`update`] - Update an existing entity in the database
/// - [`upsert`] - Upsert (insert or update) an entity
/// - [`delete`] - Delete this entity from the database
/// - [`get_id`] - Get the primary key of this entity
///
/// ## Usage Examples
///
/// ```ignore
/// use georm::Georm;
///
/// #[derive(Georm)]
/// #[georm(table = "users")]
/// struct User {
/// #[georm(id)]
/// id: i32,
/// username: String,
/// email: String,
/// }
///
/// // Static methods
/// let all_users = User::find_all(&pool).await?;
/// let user = User::find(&pool, &1).await?;
/// let deleted_count = User::delete_by_id(&pool, &1).await?;
///
/// // Instance methods
/// let new_user = User { id: 0, username: "alice".into(), email: "alice@example.com".into() };
/// let created = new_user.create(&pool).await?;
/// let updated = created.update(&pool).await?;
/// let id = updated.get_id();
/// let deleted_count = updated.delete(&pool).await?;
/// ```
///
/// ## Composite Key Support
///
/// For entities with composite primary keys, the `Id` type parameter becomes a generated
/// struct following the pattern `{EntityName}Id`:
///
/// ```ignore
/// #[derive(Georm)]
/// #[georm(table = "user_roles")]
/// struct UserRole {
/// #[georm(id)]
/// user_id: i32,
/// #[georm(id)]
/// role_id: i32,
/// assigned_at: chrono::DateTime<chrono::Utc>,
/// }
///
/// // Generated: pub struct UserRoleId { pub user_id: i32, pub role_id: i32 }
/// // Trait: impl Georm<UserRoleId> for UserRole
///
/// let id = UserRoleId { user_id: 1, role_id: 2 };
/// let user_role = UserRole::find(&pool, &id).await?;
/// ```
///
/// ## Error Handling
///
/// All methods return `sqlx::Result<T>` and may fail due to:
/// - Database connection issues
/// - Constraint violations (unique, foreign key, etc.)
/// - Invalid queries (though most are caught at compile time)
/// - Missing records (for operations expecting existing data)
///
/// [`find_all`]: Georm::find_all
/// [`find`]: Georm::find
/// [`create`]: Georm::create
/// [`update`]: Georm::update
/// [`upsert`]: Georm::upsert
/// [`delete`]: Georm::delete
/// [`delete_by_id`]: Georm::delete_by_id
/// [`get_id`]: Georm::get_id
pub trait Georm<Id> {
/// Retrieve all entities from the database table.
///
/// This method executes a `SELECT * FROM table_name` query and returns all records
/// as a vector of entities. The results are not paginated or filtered.
///
/// # Returns
/// - `Ok(Vec<Self>)` - All entities in the table (may be empty)
/// - `Err(sqlx::Error)` - Database connection or query execution errors
///
/// # Performance Notes
/// - Returns all records in memory - consider pagination for large tables
/// - Uses prepared statements for optimal performance
/// - No built-in ordering - results may vary between calls
///
/// # Examples
/// ```ignore
/// let all_users = User::find_all(&pool).await?;
/// println!("Found {} users", all_users.len());
/// ```
///
/// # Errors
/// Returns `sqlx::Error` for database connection issues, permission problems,
/// or if the table doesn't exist.
fn find_all<'e, E>(
executor: E,
) -> impl ::std::future::Future<Output = ::sqlx::Result<Vec<Self>>> + Send
where
Self: Sized,
E: Executor<'e, Database = Postgres>;
/// Find a single entity by its primary key.
///
/// This method executes a `SELECT * FROM table_name WHERE primary_key = $1` query
/// (or equivalent for composite keys) and returns the matching entity if found.
///
/// # Parameters
/// - `pool` - Database connection pool
/// - `id` - Primary key value (simple type or composite key struct)
///
/// # Returns
/// - `Ok(Some(Self))` - Entity found and returned
/// - `Ok(None)` - No entity with the given ID exists
/// - `Err(sqlx::Error)` - Database connection or query execution errors
///
/// # Examples
/// ```ignore
/// // Simple primary key
/// let user = User::find(&pool, &1).await?;
///
/// // Composite primary key
/// let id = UserRoleId { user_id: 1, role_id: 2 };
/// let user_role = UserRole::find(&pool, &id).await?;
/// ```
///
/// # Errors
/// Returns `sqlx::Error` for database connection issues, type conversion errors,
/// or query execution problems. Note that not finding a record is not an error
/// - it returns `Ok(None)`.
fn find<'e, E>(
executor: E,
id: &Id,
) -> impl std::future::Future<Output = sqlx::Result<Option<Self>>> + Send
where
Self: Sized,
E: Executor<'e, Database = Postgres>;
/// Insert this entity as a new record in the database.
///
/// This method executes an `INSERT INTO table_name (...) VALUES (...) RETURNING *`
/// query and returns the newly created entity with any database-generated values
/// (such as auto-increment IDs, default timestamps, etc.).
///
/// # Parameters
/// - `pool` - Database connection pool
///
/// # Returns
/// - `Ok(Self)` - The entity as it exists in the database after insertion
/// - `Err(sqlx::Error)` - Database constraint violations or connection errors
///
/// # Database Behavior
/// - Uses `RETURNING *` to capture database-generated values
/// - Respects database defaults for fields marked `#[georm(defaultable)]`
/// - Triggers and database-side modifications are reflected in the returned entity
///
/// # Examples
/// ```ignore
/// let new_user = User { id: 0, username: "alice".into(), email: "alice@example.com".into() };
/// let created_user = new_user.create(&pool).await?;
/// println!("Created user with ID: {}", created_user.id);
/// ```
///
/// # Errors
/// Returns `sqlx::Error` for:
/// - Unique constraint violations
/// - Foreign key constraint violations
/// - NOT NULL constraint violations
/// - Database connection issues
/// - Permission problems
fn create<'e, E>(
&self,
executor: E,
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
where
Self: Sized,
E: Executor<'e, Database = Postgres>;
/// Update an existing entity in the database.
///
/// This method executes an `UPDATE table_name SET ... WHERE primary_key = ... RETURNING *`
/// query using the entity's current primary key to locate the record to update.
///
/// # Parameters
/// - `pool` - Database connection pool
///
/// # Returns
/// - `Ok(Self)` - The entity as it exists in the database after the update
/// - `Err(sqlx::Error)` - Database errors or if no matching record exists
///
/// # Database Behavior
/// - Uses `RETURNING *` to capture any database-side changes
/// - Updates all fields, not just changed ones
/// - Triggers and database-side modifications are reflected in the returned entity
/// - Fails if no record with the current primary key exists
///
/// # Examples
/// ```ignore
/// let mut user = User::find(&pool, &1).await?.unwrap();
/// user.email = "newemail@example.com".into();
/// let updated_user = user.update(&pool).await?;
/// ```
///
/// # Errors
/// Returns `sqlx::Error` for:
/// - No matching record found (record was deleted by another process)
/// - Constraint violations (unique, foreign key, etc.)
/// - Database connection issues
/// - Permission problems
fn update<'e, E>(
&self,
executor: E,
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
where
Self: Sized,
E: Executor<'e, Database = Postgres>;
/// Insert or update this entity using PostgreSQL's upsert functionality.
///
/// This method executes an `INSERT ... ON CONFLICT (...) DO UPDATE SET ... RETURNING *`
/// query that atomically inserts the entity if it doesn't exist, or updates it if
/// a record with the same primary key already exists.
///
/// # Parameters
/// - `pool` - Database connection pool
///
/// # Returns
/// - `Ok(Self)` - The final entity state in the database (inserted or updated)
/// - `Err(sqlx::Error)` - Database connection or constraint violation errors
///
/// # Database Behavior
/// - Uses PostgreSQL's `ON CONFLICT` for true atomic upsert
/// - More efficient than separate find-then-create-or-update logic
/// - Uses `RETURNING *` to capture the final state
/// - Conflict resolution is based on the primary key constraint
///
/// # Examples
/// ```ignore
/// let user = User { id: 1, username: "alice".into(), email: "alice@example.com".into() };
/// let final_user = user.upsert(&pool).await?;
/// // Will insert if ID 1 doesn't exist, update if it does
/// ```
///
/// # Errors
/// Returns `sqlx::Error` for:
/// - Non-primary-key constraint violations
/// - Database connection issues
/// - Permission problems
fn upsert<'e, E>(&self, executor: E) -> impl ::std::future::Future<Output = sqlx::Result<Self>>
where
Self: Sized,
E: Executor<'e, Database = Postgres>;
#[deprecated(since = "0.3.0", note = "Please use `upsert` instead")]
fn create_or_update<'e, E>(
&self,
executor: E,
) -> impl ::std::future::Future<Output = sqlx::Result<Self>>
where
Self: Sized,
E: Executor<'e, Database = Postgres>,
{
self.upsert(executor)
}
/// Delete this entity from the database.
///
/// This method executes a `DELETE FROM table_name WHERE primary_key = ...` query
/// using this entity's primary key to identify the record to delete.
///
/// # Parameters
/// - `pool` - Database connection pool
///
/// # Returns
/// - `Ok(u64)` - Number of rows affected (0 if entity didn't exist, 1 if deleted)
/// - `Err(sqlx::Error)` - Database connection or constraint violation errors
///
/// # Database Behavior
/// - Uses the entity's current primary key for deletion
/// - Returns 0 if no matching record exists (not an error)
/// - May fail due to foreign key constraints if other records reference this entity
///
/// # Examples
/// ```ignore
/// let user = User::find(&pool, &1).await?.unwrap();
/// let deleted_count = user.delete(&pool).await?;
/// assert_eq!(deleted_count, 1);
/// ```
///
/// # Errors
/// Returns `sqlx::Error` for:
/// - Foreign key constraint violations (referenced by other tables)
/// - Database connection issues
/// - Permission problems
fn delete<'e, E>(
&self,
executor: E,
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send
where
E: Executor<'e, Database = Postgres>;
/// Delete an entity by its primary key without needing an entity instance.
///
/// This method executes a `DELETE FROM table_name WHERE primary_key = ...` query
/// using the provided ID to identify the record to delete.
///
/// # Parameters
/// - `pool` - Database connection pool
/// - `id` - Primary key value (simple type or composite key struct)
///
/// # Returns
/// - `Ok(u64)` - Number of rows affected (0 if entity didn't exist, 1 if deleted)
/// - `Err(sqlx::Error)` - Database connection or constraint violation errors
///
/// # Database Behavior
/// - More efficient than `find().delete()` when you only have the ID
/// - Returns 0 if no matching record exists (not an error)
/// - May fail due to foreign key constraints if other records reference this entity
///
/// # Examples
/// ```ignore
/// // Simple primary key
/// let deleted_count = User::delete_by_id(&pool, &1).await?;
///
/// // Composite primary key
/// let id = UserRoleId { user_id: 1, role_id: 2 };
/// let deleted_count = UserRole::delete_by_id(&pool, &id).await?;
/// ```
///
/// # Errors
/// Returns `sqlx::Error` for:
/// - Foreign key constraint violations (referenced by other tables)
/// - Database connection issues
/// - Permission problems
fn delete_by_id<'e, E>(
executor: E,
id: &Id,
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send
where
E: Executor<'e, Database = Postgres>;
/// Get the primary key of this entity.
///
/// For entities with simple primary keys, this returns the ID value directly.
/// For entities with composite primary keys, this returns an owned instance of
/// the generated `{EntityName}Id` struct.
///
/// # Returns
/// - Simple keys: The primary key value (e.g., `i32`, `String`)
/// - Composite keys: Generated ID struct (e.g., `UserRoleId`)
///
/// # Examples
/// ```ignore
/// // Simple primary key
/// let user = User { id: 42, username: "alice".into(), email: "alice@example.com".into() };
/// let id = user.get_id(); // Returns 42
///
/// // Composite primary key
/// let user_role = UserRole { user_id: 1, role_id: 2, assigned_at: now };
/// let id = user_role.get_id(); // Returns UserRoleId { user_id: 1, role_id: 2 }
/// ```
fn get_id(&self) -> Id;
}

View File

@@ -1,164 +1,408 @@
//! # Georm
//!
//! ## Introduction
//! A simple, type-safe PostgreSQL ORM built on SQLx with zero runtime overhead.
//!
//! Georm is a simple, opinionated SQLx ORM for PostgreSQL.
//!
//! To automatically implement the `Georm` trait, you need at least:
//! - to derive the `Georm` and `sqlx::FromRow` traits
//! - use the `georm` proc-macro to indicate the table in which your entity
//! lives
//! - use the `georm` proc-macro again to indicate which field of your struct is
//! the identifier of your entity.
//!
//! ## Simple usage
//! Here is a minimal use of Georm with a struct:
//! ## Quick Start
//!
//! ```ignore
//! #[derive(sqlx::FromRow, Georm)]
//! use georm::Georm;
//!
//! // Note: No need to derive FromRow - Georm generates it automatically
//! #[derive(Georm)]
//! #[georm(table = "users")]
//! pub struct User {
//! #[georm(id)]
//! id: i32,
//! username: String,
//! hashed_password: String,
//! email: String,
//! }
//!
//! // Use generated methods
//! let user = User::find(&pool, &1).await?; // Static method
//! let all_users = User::find_all(&pool).await?; // Static method
//! user.update(&pool).await?; // Instance method
//! ```
//!
//! The `User` type will now have access to all the functions declared in the
//! `Georm` trait.
//! ## Core CRUD Operations
//!
//! ## One-to-one relationships
//! ### Static Methods (called on the struct type)
//! - `Entity::find(pool, &id)` - Find by primary key, returns `Option<Entity>`
//! - `Entity::find_all(pool)` - Get all records, returns `Vec<Entity>`
//! - `Entity::delete_by_id(pool, &id)` - Delete by ID, returns affected row count
//!
//! You can then create relationships between different entities. For instance,
//! you can use an identifier of another entity as a link to that other entity.
//! ### Instance Methods (called on entity objects)
//! - `entity.create(pool)` - Insert new record, returns created entity with database-generated values
//! - `entity.update(pool)` - Update existing record, returns updated entity with fresh database state
//! - `entity.upsert(pool)` - True PostgreSQL upsert using `ON CONFLICT`, returns final entity
//! - `entity.delete(pool)` - Delete this record, returns affected row count
//! - `entity.get_id()` - Get reference to the entity's ID (`&Id` for simple keys, owned for composite)
//!
//! ```ignore
//! #[derive(sqlx::FromRow, Georm)]
//! #[georm(table = "profiles")]
//! pub struct Profile {
//! // Static methods
//! let user = User::find(&pool, &1).await?.unwrap();
//! let all_users = User::find_all(&pool).await?;
//! let deleted_count = User::delete_by_id(&pool, &1).await?;
//!
//! // Instance methods
//! let new_user = User { id: 0, username: "alice".to_string(), email: "alice@example.com".to_string() };
//! let created = new_user.create(&pool).await?; // Returns entity with actual generated ID
//! let updated = created.update(&pool).await?; // Returns entity with fresh database state
//! let deleted_count = updated.delete(&pool).await?;
//! ```
//!
//! ### PostgreSQL Optimizations
//!
//! Georm leverages PostgreSQL-specific features for performance and reliability:
//!
//! - **RETURNING clause**: All `INSERT` and `UPDATE` operations use `RETURNING *` to capture database-generated values (sequences, defaults, triggers)
//! - **True upserts**: `upsert()` uses `INSERT ... ON CONFLICT ... DO UPDATE` for atomic upsert operations
//! - **Prepared statements**: All queries use parameter binding for security and performance
//! - **Compile-time verification**: SQLx macros verify all generated SQL against your database schema at compile time
//!
//! ## Primary Keys and Identifiers
//!
//! ### Simple Primary Keys
//!
//! Primary key fields can have any name (not just "id"):
//!
//! ```ignore
//! #[derive(Georm)]
//! #[georm(table = "books")]
//! pub struct Book {
//! #[georm(id)]
//! id: i32,
//! #[georm(
//! relation = {
//! entity = User,
//! name = "user",
//! table = "users",
//! remote_id = "id",
//! nullable = false
//! })
//! ]
//! user_id: i32,
//! display_name: String,
//! ident: i32, // Custom field name for primary key
//! title: String,
//! }
//!
//! // Works the same way
//! let book = Book::find(&pool, &1).await?;
//! ```
//!
//! This will give access to the `Profile::get_user(&self, pool: &sqlx::PgPool)
//! -> User` method.
//! ### Composite Primary Keys
//!
//! Here is an explanation of what these different values mean:
//!
//! | Value Name | Explanation | Default value |
//! |------------|-----------------------------------------------------------------------------------------|---------------|
//! | entity | Rust type of the entity found in the database | N/A |
//! | name | Name of the remote entity within the local entity; generates a method named `get_{name}` | N/A |
//! | table | Database table where the entity is stored | N/A |
//! | remote_id | Name of the column serving as the identifier of the entity | `"id"` |
//! | nullable | Whether the relationship can be broken | `false` |
//!
//! Note that in this instance, the `remote_id` and `nullable` values can be
//! omitted as this is their default value. This below is a strict equivalent:
//! Mark multiple fields with `#[georm(id)]` for composite keys:
//!
//! ```ignore
//! #[derive(sqlx::FromRow, Georm)]
//! #[georm(table = "profiles")]
//! pub struct Profile {
//! #[derive(Georm)]
//! #[georm(table = "user_roles")]
//! pub struct UserRole {
//! #[georm(id)]
//! id: i32,
//! #[georm(relation = { entity = User, table = "users", name = "user" })]
//! user_id: i32,
//! display_name: String,
//! #[georm(id)]
//! role_id: i32,
//! assigned_at: chrono::DateTime<chrono::Utc>,
//! }
//! ```
//!
//! ## One-to-many relationships
//!
//! Sometimes, our entity is the one being referenced to by multiple entities,
//! but we have no internal reference to these remote entities in our local
//! entity. Fortunately, we have a way to indicate to Georm how to find these.
//! This automatically generates a composite ID struct following the `{EntityName}Id` pattern:
//!
//! ```ignore
//! #[derive(sqlx::FromRow, Georm)]
//! // Generated automatically by the macro
//! pub struct UserRoleId {
//! pub user_id: i32,
//! pub role_id: i32,
//! }
//! ```
//!
//! Usage with composite keys:
//!
//! ```ignore
//! // Static methods work with generated ID structs
//! let id = UserRoleId { user_id: 1, role_id: 2 };
//! let user_role = UserRole::find(&pool, &id).await?;
//! UserRole::delete_by_id(&pool, &id).await?;
//!
//! // Instance methods work the same way
//! let role = UserRole { user_id: 1, role_id: 2, assigned_at: chrono::Utc::now() };
//! let created = role.create(&pool).await?;
//! let id = created.get_id(); // Returns owned UserRoleId for composite keys
//! ```
//!
//! ### Composite Key Limitations
//!
//! - **Relationships not supported**: Entities with composite primary keys cannot
//! yet define relationships (one-to-one, one-to-many, many-to-many)
//! - **ID struct naming**: Generated ID struct follows pattern `{EntityName}Id` (not customizable)
//!
//! ## Defaultable and Generated Fields
//!
//! Georm provides three attributes for handling fields with database-managed values:
//!
//! ### `#[georm(defaultable)]` - Optional Override Fields
//!
//! For fields with database defaults that can be manually overridden:
//!
//! ```ignore
//! #[derive(Georm)]
//! #[georm(table = "posts")]
//! struct Post {
//! #[georm(id)]
//! id: i32,
//! #[georm(relation = { entity = User, table = "users", name = "user" })]
//! author_id: i32,
//! content: String
//! pub struct Post {
//! #[georm(id, defaultable)]
//! id: i32, // Auto-generated serial
//! title: String, // Required field
//! #[georm(defaultable)]
//! published: bool, // Has database default
//! #[georm(defaultable)]
//! created_at: chrono::DateTime<chrono::Utc>, // DEFAULT NOW()
//! #[georm(defaultable)]
//! pub(crate) internal_note: String, // Field visibility preserved
//! author_id: i32, // Required field
//! }
//! ```
//!
//! ### `#[georm(generated)]` - Generated by Default
//!
//! For PostgreSQL `GENERATED BY DEFAULT` columns that can be overridden but are typically auto-generated:
//!
//! ```ignore
//! #[derive(Georm)]
//! #[georm(table = "products")]
//! pub struct Product {
//! #[georm(id, generated_always)]
//! id: i32, // GENERATED ALWAYS AS IDENTITY
//! #[georm(generated)]
//! sku_number: i32, // GENERATED BY DEFAULT AS IDENTITY
//! name: String,
//! price: sqlx::types::BigDecimal,
//! discount_percent: i32,
//! }
//! ```
//!
//! ### `#[georm(generated_always)]` - Always Generated
//!
//! For PostgreSQL `GENERATED ALWAYS` columns that are strictly managed by the database:
//!
//! ```ignore
//! #[derive(Georm)]
//! #[georm(table = "products")]
//! pub struct Product {
//! #[georm(id, generated_always)]
//! id: i32, // GENERATED ALWAYS AS IDENTITY
//! name: String,
//! price: sqlx::types::BigDecimal,
//! discount_percent: i32,
//! #[georm(generated_always)]
//! final_price: Option<sqlx::types::BigDecimal>, // GENERATED ALWAYS AS (expression) STORED
//! }
//! ```
//!
//! ### Generated Structs and Behavior
//!
//! Both `defaultable` and `generated` fields create a companion `<Entity>Default` struct where these fields become `Option<T>`:
//!
//! ```ignore
//! // Generated automatically by the macro for the Product example above
//! pub struct ProductDefault {
//! pub name: String, // Required field stays the same
//! pub price: sqlx::types::BigDecimal, // Required field stays the same
//! pub discount_percent: i32, // Required field stays the same
//! pub sku_number: Option<i32>, // Can be None for auto-generation
//! // Note: generated_always fields are completely excluded from this struct
//! }
//!
//! #[derive(sqlx::FromRow, Georm)]
//! impl Defaultable<i32, Product> for ProductDefault {
//! async fn create<'e, E>(&self, pool: E) -> sqlx::Result<Post>
//! where
//! E: sqlx::Executor<'e, Database = sqlx::Postgres>;
//! }
//! ```
//!
//! ### Usage Example
//!
//! ```ignore
//! use georm::{Georm, Defaultable};
//!
//! // Create a product with auto-generated values
//! let product_default = ProductDefault {
//! name: "Laptop".to_string(),
//! price: sqlx::types::BigDecimal::from(999),
//! discount_percent: 10,
//! sku_number: None, // Let database auto-generate
//! // Note: id and final_price are excluded (generated_always)
//! };
//!
//! // Create the entity in the database (instance method on ProductDefault)
//! let created_product = product_default.create(&pool).await?;
//! println!("Created product with ID: {}", created_product.id);
//! println!("Final price: ${}", created_product.final_price.unwrap_or_default());
//! ```
//!
//! ### Key Differences
//!
//! | Attribute | INSERT Behavior | UPDATE Behavior | Use Case |
//! |--------------------|------------------------------------|-----------------|----------------------------------------------|
//! | `defaultable` | Optional (can override defaults) | Included | Fields with database defaults |
//! | `generated` | Optional (can override generation) | Included | `GENERATED BY DEFAULT` columns |
//! | `generated_always` | **Excluded** (always generated) | **Excluded** | `GENERATED ALWAYS` columns, computed columns |
//!
//! ### Rules and Limitations
//!
//! - **`generated_always` fields are completely excluded** from INSERT and UPDATE statements to prevent database errors
//! - **`generated` and `generated_always` cannot be used together** on the same field - this causes a compile-time error
//! - **`generated` fields behave like `defaultable` fields** but are semantically distinct for future enhancements
//! - **Option fields cannot be marked as `defaultable`**: If a field is already
//! `Option<T>`, you cannot mark it with `#[georm(defaultable)]`. This prevents
//! `Option<Option<T>>` types and causes a compile-time error.
//! - **Field visibility is preserved**: The generated defaultable struct maintains
//! the same field visibility (`pub`, `pub(crate)`, private) as the original struct.
//! - **ID fields can be defaultable or generated**: It's common to mark ID fields as defaultable
//! or generated when they are auto-generated serials in PostgreSQL.
//! - **Only generates when needed**: The defaultable struct is only generated if
//! at least one field is marked as defaultable or generated.
//!
//! ## Relationships
//!
//! Georm supports comprehensive relationship modeling with two approaches: field-level
//! relationships for foreign keys and struct-level relationships for reverse lookups.
//! Each relationship method call executes a separate database query.
//!
//! ### Field-Level Relationships (Foreign Keys)
//!
//! Use the `relation` attribute on foreign key fields to generate lookup methods:
//!
//! ```ignore
//! #[derive(Georm)]
//! #[georm(table = "posts")]
//! pub struct Post {
//! #[georm(id)]
//! id: i32,
//! title: String,
//! #[georm(relation = {
//! entity = Author, // Target entity type
//! table = "authors", // Target table name
//! name = "author", // Method name (generates get_author)
//! remote_id = "id", // Target table's key column (default: "id")
//! nullable = false // Whether relationship can be null (default: false)
//! })]
//! author_id: i32,
//! }
//! ```
//!
//! **Generated instance method**: `post.get_author(pool).await? -> sqlx::Result<Author>`
//!
//! For nullable relationships:
//!
//! ```ignore
//! #[derive(Georm)]
//! #[georm(table = "posts")]
//! pub struct Post {
//! #[georm(id)]
//! id: i32,
//! title: String,
//! #[georm(relation = {
//! entity = Category,
//! table = "categories",
//! name = "category",
//! nullable = true // Allows NULL values
//! })]
//! category_id: Option<i32>,
//! }
//! ```
//!
//! **Generated instance method**: `post.get_category(pool).await? -> sqlx::Result<Option<Category>>`
//!
//! Since `remote_id` and `nullable` have default values, this is equivalent:
//!
//! ```ignore
//! #[georm(relation = { entity = Author, table = "authors", name = "author" })]
//! author_id: i32,
//! ```
//!
//! #### Non-Standard Primary Key References
//!
//! Use `remote_id` to reference tables with non-standard primary key names:
//!
//! ```ignore
//! #[derive(Georm)]
//! #[georm(table = "reviews")]
//! pub struct Review {
//! #[georm(id)]
//! id: i32,
//! #[georm(relation = {
//! entity = Book,
//! table = "books",
//! name = "book",
//! remote_id = "ident" // Book uses 'ident' instead of 'id'
//! })]
//! book_id: i32,
//! content: String,
//! }
//! ```
//!
//! #### Field-Level Relationship Attributes
//!
//! | Attribute | Description | Required | Default |
//! |--------------|------------------------------------------------------|----------|---------|
//! | `entity` | Target entity type | Yes | N/A |
//! | `name` | Method name (generates `get_{name}`) | Yes | N/A |
//! | `table` | Target table name | Yes | N/A |
//! | `remote_id` | Target table's key column | No | `"id"` |
//! | `nullable` | Whether relationship can be null | No | `false` |
//!
//! ### Struct-Level Relationships (Reverse Lookups)
//!
//! Define relationships at the struct level to query related entities that reference this entity.
//! These generate separate database queries for each method call.
//!
//! #### One-to-One Relationships
//!
//! ```ignore
//! #[derive(Georm)]
//! #[georm(
//! table = "users",
//! one_to_many = [{
//! entity = Post,
//! name = "posts",
//! table = "posts",
//! remote_id = "id"
//! one_to_one = [{
//! entity = Profile, // Related entity type
//! name = "profile", // Method name (generates get_profile)
//! table = "profiles", // Related table name
//! remote_id = "user_id", // Foreign key in related table
//! }]
//! )]
//! struct User {
//! pub struct User {
//! #[georm(id)]
//! id: i32,
//! username: String,
//! hashed_password: String
//! }
//! ```
//!
//! As weve seen earlier, the struct `Post` has access to the method
//! `Post::get_user(&self, pool: &sqlx::PgPool) -> User` thanks to the
//! proc-macro used on `author_id`. However, `User` now has also access to
//! `User::get_posts(&self, pool: &sqlx::PgPool) -> Vec<Post>`. And as you can
//! see, `one_to_many` is an array, meaning you can define several one-to-many
//! relationships for `User`.
//! **Generated instance method**: `user.get_profile(pool).await? -> sqlx::Result<Option<Profile>>`
//!
//! Here is an explanation of the values of `one_to_many`:
//!
//! | Value Name | Explanaion | Default Value |
//! |------------|------------------------------------------------------------------------------------------|---------------|
//! | entity | Rust type of the entity found in the database | N/A |
//! | name | Name of the remote entity within the local entity; generates a method named `get_{name}` | N/A |
//! | table | Database table where the entity is stored | N/A |
//! | remote_id | Name of the column serving as the identifier of the entity | `"id"` |
//!
//! As with one-to-one relationships, `remote_id` is optional. The following
//! `User` struct is strictly equivalent.
//! #### One-to-Many Relationships
//!
//! ```ignore
//! #[derive(sqlx::FromRow, Georm)]
//! #[derive(Georm)]
//! #[georm(
//! table = "users",
//! one_to_many = [{ entity = Post, name = "posts", table = "posts" }]
//! table = "authors",
//! one_to_many = [{
//! entity = Post, // Related entity type
//! name = "posts", // Method name (generates get_posts)
//! table = "posts", // Related table name
//! remote_id = "author_id" // Foreign key in related table
//! }, {
//! entity = Comment, // Multiple relationships allowed
//! name = "comments",
//! table = "comments",
//! remote_id = "author_id"
//! }]
//! )]
//! struct User {
//! pub struct Author {
//! #[georm(id)]
//! id: i32,
//! username: String,
//! hashed_password: String
//! name: String,
//! }
//! ```
//!
//! ## Many-to-many relationships
//! **Generated instance methods**:
//! - `author.get_posts(pool).await? -> sqlx::Result<Vec<Post>>`
//! - `author.get_comments(pool).await? -> sqlx::Result<Vec<Comment>>`
//!
//! Many-to-many relationships between entities A and entities B with Georm rely
//! on a third table which refers to both. For instance, the following SQL code
//! describes a many-to-many relationship between books and book genre.
//! #### Many-to-Many Relationships
//!
//! For many-to-many relationships, specify the link table that connects the entities:
//!
//! ```sql
//! -- Example schema for books and genres
//! CREATE TABLE books (
//! id SERIAL PRIMARY KEY,
//! title VARCHAR(100) NOT NULL
//! title VARCHAR(200) NOT NULL
//! );
//!
//! CREATE TABLE genres (
@@ -166,178 +410,245 @@
//! name VARCHAR(100) NOT NULL
//! );
//!
//! CREATE TABLE books_genres (
//! book_id INT NOT NULL,
//! genre_id INT NOT NULL,
//! PRIMARY KEY (book_id, genre_id),
//! FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE CASCADE,
//! FOREIGN KEY (genre_id) REFERENCES genres(id) ON DELETE CASCADE
//! CREATE TABLE book_genres (
//! book_id INT NOT NULL REFERENCES books(id),
//! genre_id INT NOT NULL REFERENCES genres(id),
//! PRIMARY KEY (book_id, genre_id)
//! );
//! ```
//!
//! The table `books_genres` is the one defining the many-to-many relationship
//! between the table `books` and the table `genres`. With Georm, this gives us
//! the following code:
//!
//! ```ignore
//! #[derive(sqlx::FromRow, Georm)]
//! #[derive(Georm)]
//! #[georm(
//! table = "books",
//! many_to_many = [{
//! name = "genres",
//! entity = Genre,
//! table = "genres",
//! remote_id = "id",
//! link = { table = "books_genres", from = "book_id", to = "genre_id" }
//! entity = Genre, // Related entity type
//! name = "genres", // Method name (generates get_genres)
//! table = "genres", // Related table name
//! remote_id = "id", // Primary key in related table (default: "id")
//! link = { // Link table configuration
//! table = "book_genres", // Join table name
//! from = "book_id", // Column referencing this entity
//! to = "genre_id" // Column referencing related entity
//! }
//! }]
//! )]
//! struct Book {
//! pub struct Book {
//! #[georm(id)]
//! id: i32,
//! title: String
//! title: String,
//! }
//!
//! #[derive(sqlx::FromRow, Georm)]
//! #[derive(Georm)]
//! #[georm(
//! table = "genres",
//! many_to_many = [{
//! entity = Book,
//! name = "books",
//! table = "books",
//! remote_id = "id",
//! link = { table = "books_genres", from = "genre_id", to = "book_id" }
//! link = {
//! table = "book_genres",
//! from = "genre_id", // Note: reversed perspective
//! to = "book_id"
//! }
//! }]
//! )]
//! struct Genre {
//! pub struct Genre {
//! #[georm(id)]
//! id: i32,
//! name: String
//! name: String,
//! }
//! ```
//!
//! This generates two methods:
//! - `Book::get_genres(&self, pool: &sqlx::PgPool) -> Vec<Genre>`
//! - `Genre::get_books(&self, pool: &sqlx::PgPool) -> Vec<Book>`
//! **Generated instance methods**:
//! - `book.get_genres(pool).await? -> sqlx::Result<Vec<Genre>>`
//! - `genre.get_books(pool).await? -> sqlx::Result<Vec<Book>>`
//!
//! As you can see, `many_to_many` is also an array, meaning we can define
//! several many-to-many relationships for the same struct.
//! #### Struct-Level Relationship Attributes
//!
//! Here is an explanation of the values behind `many_to_many`:
//! | Attribute | Description | Required | Default |
//! |--------------|------------------------------------------------------|----------|---------|
//! | `entity` | Target entity type | Yes | N/A |
//! | `name` | Method name (generates `get_{name}`) | Yes | N/A |
//! | `table` | Target table name | Yes | N/A |
//! | `remote_id` | Target table's key column | No | `"id"` |
//! | `link.table` | Join table name (many-to-many only) | Yes* | N/A |
//! | `link.from` | Column referencing this entity (many-to-many only) | Yes* | N/A |
//! | `link.to` | Column referencing target entity (many-to-many only) | Yes* | N/A |
//!
//! | Value Name | Explanation | Default value |
//! |------------|------------------------------------------------------------------------------------------|---------------|
//! | entity | Rust type of the entity found in the database | N/A |
//! | name | Name of the remote entity within the local entity; generates a method named `get_{name}` | N/A |
//! | table | Database table where the entity is stored | N/A |
//! | remote_id | Name of the column serving as the identifier of the entity | `"id"` |
//! | link.table | Name of the many-to-many relationship table | N/A |
//! | link.from | Column of the linking table referring to this entity | N/A |
//! | link.to | Column of the linking table referring to the remote entity | N/A |
//! *Required for many-to-many relationships
//!
//! As with field-level relationships, `remote_id` is optional and defaults to `"id"`:
//!
//! ```ignore
//! #[georm(
//! table = "users",
//! one_to_many = [{ entity = Post, name = "posts", table = "posts" }]
//! )]
//! ```
//!
//! #### Complex Relationship Example
//!
//! Here's a comprehensive example showing multiple relationship types:
//!
//! ```ignore
//! #[derive(Georm)]
//! #[georm(
//! table = "posts",
//! one_to_many = [{
//! entity = Comment,
//! name = "comments",
//! table = "comments",
//! remote_id = "post_id"
//! }],
//! many_to_many = [{
//! entity = Tag,
//! name = "tags",
//! table = "tags",
//! link = {
//! table = "post_tags",
//! from = "post_id",
//! to = "tag_id"
//! }
//! }]
//! )]
//! pub struct Post {
//! #[georm(id)]
//! id: i32,
//! title: String,
//! content: String,
//!
//! // Field-level relationship (foreign key)
//! #[georm(relation = {
//! entity = Author,
//! table = "authors",
//! name = "author"
//! })]
//! author_id: i32,
//!
//! // Nullable field-level relationship
//! #[georm(relation = {
//! entity = Category,
//! table = "categories",
//! name = "category",
//! nullable = true
//! })]
//! category_id: Option<i32>,
//! }
//! ```
//!
//! **Generated instance methods**:
//! - `post.get_author(pool).await? -> sqlx::Result<Author>` (from field relation)
//! - `post.get_category(pool).await? -> sqlx::Result<Option<Category>>` (nullable field relation)
//! - `post.get_comments(pool).await? -> sqlx::Result<Vec<Comment>>` (one-to-many)
//! - `post.get_tags(pool).await? -> sqlx::Result<Vec<Tag>>` (many-to-many)
//!
//! ## Error Handling
//!
//! All Georm methods return `sqlx::Result<T>` which can contain:
//!
//! - **Database errors**: Connection issues, constraint violations, etc.
//! - **Not found errors**: When `find()` operations return `None`
//! - **Compile-time errors**: Invalid SQL, type mismatches, schema validation failures
//!
//! ### Compile-Time Validations
//!
//! Georm performs several validations at compile time:
//!
//! ```ignore
//! // ❌ Compile error: No ID field specified
//! #[derive(Georm)]
//! #[georm(table = "invalid")]
//! pub struct Invalid {
//! name: String, // Missing #[georm(id)]
//! }
//!
//! // ❌ Compile error: Option<T> cannot be defaultable
//! #[derive(Georm)]
//! #[georm(table = "invalid")]
//! pub struct Invalid {
//! #[georm(id)]
//! id: i32,
//! #[georm(defaultable)] // Error: would create Option<Option<String>>
//! optional_field: Option<String>,
//! }
//!
//! // ❌ Compile error: Cannot use both generated attributes on same field
//! #[derive(Georm)]
//! #[georm(table = "invalid")]
//! pub struct Invalid {
//! #[georm(id, generated, generated_always)] // Error: conflicting attributes
//! id: i32,
//! name: String,
//! }
//! ```
//!
//! ## Attribute Reference
//!
//! ### Struct-Level Attributes
//!
//! ```ignore
//! #[georm(
//! table = "table_name", // Required: database table name
//! one_to_one = [{ /* ... */ }], // Optional: one-to-one relationships
//! one_to_many = [{ /* ... */ }], // Optional: one-to-many relationships
//! many_to_many = [{ /* ... */ }] // Optional: many-to-many relationships
//! )]
//! ```
//!
//! ### Field-Level Attributes
//!
//! ```ignore
//! #[georm(id)] // Mark as primary key (required on at least one field)
//! #[georm(defaultable)] // Mark as defaultable field (database default/auto-generated)
//! #[georm(generated)] // Mark as generated by default field (GENERATED BY DEFAULT)
//! #[georm(generated_always)] // Mark as always generated field (GENERATED ALWAYS)
//! #[georm(relation = { /* ... */ })] // Define foreign key relationship
//! ```
//!
//! ## Performance Characteristics
//!
//! - **Zero runtime overhead**: All SQL is generated at compile time
//! - **No eager loading**: Each relationship method executes a separate query
//! - **Prepared statements**: All queries use parameter binding for optimal performance
//! - **Database round-trips**: CRUD operations use RETURNING clause to minimize round-trips
//! - **No N+1 prevention**: Built-in relationships don't prevent N+1 query patterns
//!
//! ## Limitations
//! ### Database
//!
//! For now, Georm is limited to PostgreSQL. Other databases may be supported in
//! the future, such as Sqlite or MySQL, but that is not the case yet.
//! ### Database Support
//!
//! ## Identifiers
//! Georm is currently limited to PostgreSQL. Other databases may be supported in
//! the future, such as SQLite or MySQL, but that is not the case yet.
//!
//! ### Identifiers
//!
//! Identifiers, or primary keys from the point of view of the database, may
//! only be simple types recognized by SQLx. They also cannot be arrays, and
//! optionals are only supported in one-to-one relationships when explicitly
//! marked as nullables.
//! be simple types recognized by SQLx or composite keys (multiple fields marked
//! with `#[georm(id)]`). Single primary keys cannot be arrays, and optionals are
//! only supported in one-to-one relationships when explicitly marked as nullables.
//!
//! ### Current Limitations
//!
//! - **Composite key relationships**: Entities with composite primary keys cannot define relationships
//! - **Single table per entity**: No table inheritance or polymorphism support
//! - **No advanced queries**: No complex WHERE clauses or joins beyond relationships
//! - **No eager loading**: Each relationship call is a separate database query
//! - **No field-based queries**: No `find_by_{field_name}` methods generated automatically
//! - **PostgreSQL only**: No support for other database systems
//!
//! ## Generated Code
//!
//! Georm automatically generates:
//! - `sqlx::FromRow` implementation (no need to derive manually)
//! - Composite ID structs for multi-field primary keys
//! - Defaultable companion structs for entities with defaultable fields
//! - Relationship methods for accessing related entities
//! - All CRUD operations with proper PostgreSQL optimizations
pub use georm_macros::Georm;
pub trait Georm<Id> {
/// Find all the entities in the database.
///
/// # Errors
/// Returns any error Postgres may have encountered
fn find_all(
pool: &sqlx::PgPool,
) -> impl ::std::future::Future<Output = ::sqlx::Result<Vec<Self>>> + Send
where
Self: Sized;
/// Find the entiy in the database based on its identifier.
///
/// # Errors
/// Returns any error Postgres may have encountered
fn find(
pool: &sqlx::PgPool,
id: &Id,
) -> impl std::future::Future<Output = sqlx::Result<Option<Self>>> + Send
where
Self: Sized;
/// Create the entity in the database.
///
/// # Errors
/// Returns any error Postgres may have encountered
fn create(
&self,
pool: &sqlx::PgPool,
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
where
Self: Sized;
/// Update an entity with a matching identifier in the database.
///
/// # Errors
/// Returns any error Postgres may have encountered
fn update(
&self,
pool: &sqlx::PgPool,
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
where
Self: Sized;
/// Update an entity with a matching identifier in the database if
/// it exists, create it otherwise.
///
/// # Errors
/// Returns any error Postgres may have encountered
fn create_or_update(
&self,
pool: &sqlx::PgPool,
) -> impl ::std::future::Future<Output = sqlx::Result<Self>>
where
Self: Sized,
{
async {
if Self::find(pool, self.get_id()).await?.is_some() {
self.update(pool).await
} else {
self.create(pool).await
}
}
}
/// Delete the entity from the database if it exists.
///
/// # Returns
/// Returns the amount of rows affected by the deletion.
///
/// # Errors
/// Returns any error Postgres may have encountered
fn delete(
&self,
pool: &sqlx::PgPool,
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send;
/// Delete any entity with the identifier `id`.
///
/// # Returns
/// Returns the amount of rows affected by the deletion.
///
/// # Errors
/// Returns any error Postgres may have encountered
fn delete_by_id(
pool: &sqlx::PgPool,
id: &Id,
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send;
/// Returns the identifier of the entity.
fn get_id(&self) -> &Id;
}
mod georm;
pub use georm::Georm;
mod defaultable;
pub use defaultable::Defaultable;

112
tests/composite_key.rs Normal file
View File

@@ -0,0 +1,112 @@
use georm::Georm;
mod models;
use models::{UserRole, UserRoleId};
#[sqlx::test(fixtures("composite_key"))]
async fn composite_key_find(pool: sqlx::PgPool) -> sqlx::Result<()> {
// This will test the find query generation bug
let id = models::UserRoleId {
user_id: 1,
role_id: 1,
};
let result = UserRole::find(&pool, &id).await?;
assert!(result.is_some());
let user_role = result.unwrap();
assert_eq!(1, user_role.user_id);
assert_eq!(1, user_role.role_id);
Ok(())
}
#[test]
fn composite_key_get_id() {
let user_role = UserRole {
user_id: 1,
role_id: 1,
assigned_at: chrono::Local::now().into(),
};
// This will test the get_id implementation bug
let id = user_role.get_id();
assert_eq!(1, id.user_id);
assert_eq!(1, id.role_id);
}
#[sqlx::test(fixtures("composite_key"))]
async fn composite_key_upsert(pool: sqlx::PgPool) -> sqlx::Result<()> {
let new_user_role = UserRole {
user_id: 5,
role_id: 2,
assigned_at: chrono::Local::now().into(),
};
// This will test the upsert query generation bug
let result = new_user_role.upsert(&pool).await?;
assert_eq!(5, result.user_id);
assert_eq!(2, result.role_id);
Ok(())
}
#[sqlx::test(fixtures("composite_key"))]
async fn composite_key_delete(pool: sqlx::PgPool) -> sqlx::Result<()> {
let id = models::UserRoleId {
user_id: 1,
role_id: 1,
};
let rows_affected = UserRole::delete_by_id(&pool, &id).await?;
assert_eq!(1, rows_affected);
// Verify it's deleted
let result = UserRole::find(&pool, &id).await?;
assert!(result.is_none());
Ok(())
}
#[sqlx::test(fixtures("composite_key"))]
async fn composite_key_find_all(pool: sqlx::PgPool) -> sqlx::Result<()> {
let all_user_roles = UserRole::find_all(&pool).await?;
assert_eq!(4, all_user_roles.len());
Ok(())
}
#[sqlx::test(fixtures("composite_key"))]
async fn composite_key_create(pool: sqlx::PgPool) -> sqlx::Result<()> {
let new_user_role = UserRole {
user_id: 10,
role_id: 5,
assigned_at: chrono::Local::now().into(),
};
let result = new_user_role.create(&pool).await?;
assert_eq!(new_user_role.user_id, result.user_id);
assert_eq!(new_user_role.role_id, result.role_id);
Ok(())
}
#[sqlx::test(fixtures("composite_key"))]
async fn composite_key_update(pool: sqlx::PgPool) -> sqlx::Result<()> {
let mut user_role = UserRole::find(
&pool,
&UserRoleId {
user_id: 1,
role_id: 1,
},
)
.await?
.unwrap();
let now: chrono::DateTime<chrono::Utc> = chrono::Local::now().into();
user_role.assigned_at = now;
let updated = user_role.update(&pool).await?;
assert_eq!(
now.timestamp_millis(),
updated.assigned_at.timestamp_millis()
);
assert_eq!(1, updated.user_id);
assert_eq!(1, updated.role_id);
Ok(())
}

526
tests/defaultable_struct.rs Normal file
View File

@@ -0,0 +1,526 @@
use georm::Georm;
// Test struct with defaultable fields using existing table structure
#[derive(Georm, Debug)]
#[georm(table = "authors")]
struct TestAuthor {
#[georm(id, defaultable)]
pub id: i32,
pub name: String,
pub biography_id: Option<i32>, // Don't mark Option fields as defaultable
}
// Test struct with only ID defaultable
#[derive(Georm)]
#[georm(table = "authors")]
struct MinimalDefaultable {
#[georm(id, defaultable)]
pub id: i32,
pub name: String,
pub biography_id: Option<i32>,
}
// Test struct with multiple defaultable fields
#[derive(Georm)]
#[georm(table = "authors")]
struct MultiDefaultable {
#[georm(id, defaultable)]
pub id: i32,
#[georm(defaultable)]
pub name: String,
pub biography_id: Option<i32>,
}
#[test]
fn defaultable_struct_should_exist() {
// This test will compile only if TestAuthorDefault struct exists
let _author_default = TestAuthorDefault {
id: Some(1), // Should be Option<i32> since ID is defaultable
name: "Test Author".to_string(), // Should remain String
biography_id: None, // Should remain Option<i32>
};
}
#[test]
fn minimal_defaultable_struct_should_exist() {
// MinimalDefaultableDefault should exist because ID is marked as defaultable
let _minimal_default = MinimalDefaultableDefault {
id: None, // Should be Option<i32>
name: "testuser".to_string(), // Should remain String
biography_id: None, // Should remain Option<i32>
};
}
#[test]
fn defaultable_fields_can_be_none() {
let _author_default = TestAuthorDefault {
id: None, // Can be None since it's defaultable (auto-generated)
name: "Test Author".to_string(),
biography_id: None, // Can remain None
};
}
#[test]
fn field_visibility_is_preserved() {
let _author_default = TestAuthorDefault {
id: Some(1), // pub
name: "Test".to_string(), // pub
biography_id: Some(1), // pub, Option<i32>
};
// This test ensures field visibility is preserved in generated struct
}
mod defaultable_tests {
use super::*;
use georm::Defaultable;
use sqlx::PgPool;
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
async fn test_create_entity_from_defaultable_with_id(pool: PgPool) {
// Test creating entity from defaultable struct with explicit ID
let author_default = TestAuthorDefault {
id: Some(999),
name: "John Doe".to_string(),
biography_id: None,
};
let created_author = author_default.create(&pool).await.unwrap();
assert_eq!(created_author.id, 999);
assert_eq!(created_author.name, "John Doe");
assert_eq!(created_author.biography_id, None);
}
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
async fn test_create_entity_from_defaultable_without_id(pool: PgPool) {
// Test creating entity from defaultable struct with auto-generated ID
let author_default = TestAuthorDefault {
id: None, // Let database generate the ID
name: "Jane Smith".to_string(),
biography_id: None,
};
let created_author = author_default.create(&pool).await.unwrap();
// ID should be auto-generated (positive value)
assert!(created_author.id > 0);
assert_eq!(created_author.name, "Jane Smith");
assert_eq!(created_author.biography_id, None);
}
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
async fn test_create_entity_from_minimal_defaultable(pool: PgPool) {
// Test creating entity from minimal defaultable struct
let minimal_default = MinimalDefaultableDefault {
id: None,
name: "Alice Wonder".to_string(),
biography_id: Some(1), // Reference existing biography
};
let created_author = minimal_default.create(&pool).await.unwrap();
assert!(created_author.id > 0);
assert_eq!(created_author.name, "Alice Wonder");
assert_eq!(created_author.biography_id, Some(1));
}
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
async fn test_create_multiple_entities_from_defaultable(pool: PgPool) {
// Test creating multiple entities to ensure ID generation works properly
let author1_default = TestAuthorDefault {
id: None,
name: "Author One".to_string(),
biography_id: None,
};
let author2_default = TestAuthorDefault {
id: None,
name: "Author Two".to_string(),
biography_id: None,
};
let created_author1 = author1_default.create(&pool).await.unwrap();
let created_author2 = author2_default.create(&pool).await.unwrap();
// Both should have unique IDs
assert!(created_author1.id > 0);
assert!(created_author2.id > 0);
assert_ne!(created_author1.id, created_author2.id);
assert_eq!(created_author1.name, "Author One");
assert_eq!(created_author2.name, "Author Two");
}
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
async fn test_multiple_defaultable_fields_all_none(pool: PgPool) {
// Test with multiple defaultable fields all set to None
let multi_default = MultiDefaultableDefault {
id: None,
name: None, // This should use database default or be handled gracefully
biography_id: None,
};
let result = multi_default.create(&pool).await;
// This might fail if database doesn't have a default for name
// That's expected behavior - test documents the current behavior
match result {
Ok(created) => {
assert!(created.id > 0);
// If successful, name should have some default value
}
Err(e) => {
// Expected if no database default for name column
assert!(e.to_string().contains("null") || e.to_string().contains("NOT NULL"));
}
}
}
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
async fn test_multiple_defaultable_fields_mixed(pool: PgPool) {
// Test with some defaultable fields set and others None
let multi_default = MultiDefaultableDefault {
id: None, // Let database generate
name: Some("Explicit Name".to_string()), // Explicit value
biography_id: Some(1), // Reference existing biography
};
let created = multi_default.create(&pool).await.unwrap();
assert!(created.id > 0);
assert_eq!(created.name, "Explicit Name");
assert_eq!(created.biography_id, Some(1));
}
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
async fn test_multiple_defaultable_fields_all_explicit(pool: PgPool) {
// Test with all defaultable fields having explicit values
let multi_default = MultiDefaultableDefault {
id: Some(888),
name: Some("All Explicit".to_string()),
biography_id: None,
};
let created = multi_default.create(&pool).await.unwrap();
assert_eq!(created.id, 888);
assert_eq!(created.name, "All Explicit");
assert_eq!(created.biography_id, None);
}
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
async fn test_error_duplicate_id(pool: PgPool) {
// Test error handling for duplicate ID constraint violation
let author1 = TestAuthorDefault {
id: Some(777),
name: "First Author".to_string(),
biography_id: None,
};
let author2 = TestAuthorDefault {
id: Some(777), // Same ID - should cause constraint violation
name: "Second Author".to_string(),
biography_id: None,
};
// First creation should succeed
let _created1 = author1.create(&pool).await.unwrap();
// Second creation should fail due to duplicate key
let result2 = author2.create(&pool).await;
assert!(result2.is_err());
let error = result2.unwrap_err();
let error_str = error.to_string();
assert!(
error_str.contains("duplicate")
|| error_str.contains("unique")
|| error_str.contains("UNIQUE")
);
}
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
async fn test_error_invalid_foreign_key(pool: PgPool) {
// Test error handling for invalid foreign key reference
let author_default = TestAuthorDefault {
id: None,
name: "Test Author".to_string(),
biography_id: Some(99999), // Non-existent biography ID
};
let result = author_default.create(&pool).await;
// This should fail if there's a foreign key constraint
// If no constraint exists, it will succeed (documents current behavior)
match result {
Ok(created) => {
// No foreign key constraint - this is valid behavior
assert!(created.id > 0);
assert_eq!(created.biography_id, Some(99999));
}
Err(e) => {
// Foreign key constraint violation
let error_str = e.to_string();
assert!(
error_str.contains("foreign")
|| error_str.contains("constraint")
|| error_str.contains("violates")
);
}
}
}
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
async fn test_error_connection_handling(pool: PgPool) {
// Test behavior with a closed/invalid pool
// Note: This is tricky to test without actually closing the pool
// Instead, we test with extremely long string that might cause issues
let author_default = TestAuthorDefault {
id: None,
name: "A".repeat(10000), // Very long string - might hit database limits
biography_id: None,
};
let result = author_default.create(&pool).await;
// This documents current behavior - might succeed or fail depending on DB limits
match result {
Ok(created) => {
assert!(created.id > 0);
assert_eq!(created.name.len(), 10000);
}
Err(e) => {
// Some kind of database limit hit
assert!(!e.to_string().is_empty());
}
}
}
mod sql_validation_tests {
use super::*;
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
async fn test_sql_generation_no_defaultable_fields(pool: PgPool) {
// Test SQL generation when no defaultable fields have None values
let author_default = TestAuthorDefault {
id: Some(100),
name: "Test Name".to_string(),
biography_id: Some(1),
};
// Capture the SQL by creating a custom query that logs the generated SQL
// Since we can't directly inspect the generated SQL from the macro,
// we test the behavior indirectly by ensuring all fields are included
let created = author_default.create(&pool).await.unwrap();
// Verify all fields were properly inserted
assert_eq!(created.id, 100);
assert_eq!(created.name, "Test Name");
assert_eq!(created.biography_id, Some(1));
// Verify the record exists in database with all expected values
let found: TestAuthor = sqlx::query_as!(
TestAuthor,
"SELECT id, name, biography_id FROM authors WHERE id = $1",
100
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(found.id, 100);
assert_eq!(found.name, "Test Name");
assert_eq!(found.biography_id, Some(1));
}
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
async fn test_sql_generation_with_defaultable_none(pool: PgPool) {
// Test SQL generation when defaultable fields are None (should be excluded)
let author_default = TestAuthorDefault {
id: None, // This should be excluded from INSERT
name: "Auto ID Test".to_string(),
biography_id: None,
};
let created = author_default.create(&pool).await.unwrap();
// ID should be auto-generated (not explicitly set)
assert!(created.id > 0);
assert_eq!(created.name, "Auto ID Test");
assert_eq!(created.biography_id, None);
// Verify the generated ID is actually from database auto-increment
// by checking it's different from any manually set values
assert_ne!(created.id, 100); // Different from previous test
}
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
async fn test_sql_generation_mixed_defaultable_fields(pool: PgPool) {
// Test SQL with multiple defaultable fields where some are None
let multi_default = MultiDefaultableDefault {
id: None, // Should be excluded
name: Some("Explicit Name".to_string()), // Should be included
biography_id: Some(1), // Should be included
};
let created = multi_default.create(&pool).await.unwrap();
// Verify the mixed field inclusion worked correctly
assert!(created.id > 0); // Auto-generated
assert_eq!(created.name, "Explicit Name"); // Explicitly set
assert_eq!(created.biography_id, Some(1)); // Explicitly set
}
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
async fn test_placeholder_ordering_consistency(pool: PgPool) {
// Test that placeholders are ordered correctly when fields are dynamically included
// Create multiple records with different field combinations
// First: only non-defaultable fields
let record1 = MultiDefaultableDefault {
id: None,
name: None,
biography_id: Some(1),
};
// Second: all fields explicit
let record2 = MultiDefaultableDefault {
id: Some(201),
name: Some("Full Record".to_string()),
biography_id: Some(1),
};
// Third: mixed combination
let record3 = MultiDefaultableDefault {
id: None,
name: Some("Mixed Record".to_string()),
biography_id: None,
};
// All should succeed with correct placeholder ordering
let result1 = record1.create(&pool).await;
let result2 = record2.create(&pool).await;
let result3 = record3.create(&pool).await;
// Handle record1 based on whether name has a database default
match result1 {
Ok(created1) => {
assert!(created1.id > 0);
assert_eq!(created1.biography_id, Some(1));
}
Err(_) => {
// Expected if name field has no database default
}
}
let created2 = result2.unwrap();
assert_eq!(created2.id, 201);
assert_eq!(created2.name, "Full Record");
assert_eq!(created2.biography_id, Some(1));
let created3 = result3.unwrap();
assert!(created3.id > 0);
assert_eq!(created3.name, "Mixed Record");
assert_eq!(created3.biography_id, None);
}
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
async fn test_field_inclusion_logic(pool: PgPool) {
// Test that the field inclusion logic works correctly
// by creating records that should result in different SQL queries
let minimal = TestAuthorDefault {
id: None,
name: "Minimal".to_string(),
biography_id: None,
};
let maximal = TestAuthorDefault {
id: Some(300),
name: "Maximal".to_string(),
biography_id: Some(1),
};
let created_minimal = minimal.create(&pool).await.unwrap();
let created_maximal = maximal.create(&pool).await.unwrap();
// Minimal should have auto-generated ID, explicit name, NULL biography_id
assert!(created_minimal.id > 0);
assert_eq!(created_minimal.name, "Minimal");
assert_eq!(created_minimal.biography_id, None);
// Maximal should have all explicit values
assert_eq!(created_maximal.id, 300);
assert_eq!(created_maximal.name, "Maximal");
assert_eq!(created_maximal.biography_id, Some(1));
// Verify they are different records
assert_ne!(created_minimal.id, created_maximal.id);
}
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
async fn test_returning_clause_functionality(pool: PgPool) {
// Test that the RETURNING * clause works correctly with dynamic fields
let author_default = TestAuthorDefault {
id: None, // Should be populated by RETURNING clause
name: "Return Test".to_string(),
biography_id: None,
};
let created = author_default.create(&pool).await.unwrap();
// Verify RETURNING clause populated all fields correctly
assert!(created.id > 0); // Database-generated ID returned
assert_eq!(created.name, "Return Test"); // Explicit value returned
assert_eq!(created.biography_id, None); // NULL value returned correctly
// Double-check by querying the database directly
let verified: TestAuthor = sqlx::query_as!(
TestAuthor,
"SELECT id, name, biography_id FROM authors WHERE id = $1",
created.id
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(verified.id, created.id);
assert_eq!(verified.name, created.name);
assert_eq!(verified.biography_id, created.biography_id);
}
#[sqlx::test(fixtures("../tests/fixtures/simple_struct.sql"))]
async fn test_query_parameter_binding_order(pool: PgPool) {
// Test that query parameters are bound in the correct order
// This is critical for the dynamic SQL generation
// Create a record where the parameter order matters
let test_record = MultiDefaultableDefault {
id: Some(400), // This should be bound first (if included)
name: Some("Param Order Test".to_string()), // This should be bound second (if included)
biography_id: Some(1), // This should be bound last
};
let created = test_record.create(&pool).await.unwrap();
// Verify all parameters were bound correctly
assert_eq!(created.id, 400);
assert_eq!(created.name, "Param Order Test");
assert_eq!(created.biography_id, Some(1));
// Test with different parameter inclusion order
let test_record2 = MultiDefaultableDefault {
id: None, // Excluded - should not affect parameter order
name: Some("No ID Test".to_string()), // Should be bound first now
biography_id: Some(1), // Should be bound second now
};
let created2 = test_record2.create(&pool).await.unwrap();
assert!(created2.id > 0); // Auto-generated
assert_eq!(created2.name, "No ID Test");
assert_eq!(created2.biography_id, Some(1));
}
}
}

6
tests/fixtures/composite_key.sql vendored Normal file
View File

@@ -0,0 +1,6 @@
INSERT INTO UserRoles (user_id, role_id, assigned_at)
VALUES
(1, 1, '2024-01-01 10:00:00+00:00'),
(1, 2, '2024-01-02 11:00:00+00:00'),
(2, 1, '2024-01-03 12:00:00+00:00'),
(3, 3, '2024-01-04 13:00:00+00:00');

8
tests/fixtures/generated.sql vendored Normal file
View File

@@ -0,0 +1,8 @@
INSERT INTO products (name, price, discount_percent)
VALUES ('Laptop', 999.99, 10);
INSERT INTO products (sku_number, name, price, discount_percent)
VALUES (5000, 'Mouse', 29.99, 5);
INSERT INTO products (name, price)
VALUES ('Keyboard', 79.99);

View File

@@ -1,6 +1,7 @@
INSERT INTO biographies (content)
VALUES ('Some text'),
('Some other text');
('Some other text'),
('Biography for no one');
INSERT INTO authors (name, biography_id)
VALUES ('J.R.R. Tolkien', 2),

64
tests/generated.rs Normal file
View File

@@ -0,0 +1,64 @@
use georm::{Defaultable, Georm};
use sqlx::types::BigDecimal;
mod models;
use models::{Product, ProductDefault};
#[sqlx::test()]
async fn create_without_generated_values(pool: sqlx::PgPool) -> sqlx::Result<()> {
let base = ProductDefault {
name: "Desktop".to_owned(),
price: BigDecimal::from(2000),
discount_percent: 5,
sku_number: None,
};
let result = base.create(&pool).await?;
assert_eq!(BigDecimal::from(1900), result.final_price.unwrap());
Ok(())
}
#[sqlx::test()]
async fn create_with_manual_generated_value(pool: sqlx::PgPool) -> sqlx::Result<()> {
let base = ProductDefault {
name: "Monitor".to_owned(),
price: BigDecimal::from(750),
discount_percent: 10,
sku_number: Some(12345),
};
let result = base.create(&pool).await?;
assert_eq!(12345, result.sku_number);
assert_eq!("Monitor", result.name);
Ok(())
}
#[sqlx::test(fixtures("generated"))]
async fn update_does_not_change_generated_always_field(pool: sqlx::PgPool) -> sqlx::Result<()> {
let products = Product::find_all(&pool).await?;
dbg!(&products);
let mut product = products.first().unwrap().clone();
let original_final_price = product.clone().final_price;
product.name = "Gaming Laptop".to_owned();
product.final_price = Some(BigDecimal::from(1000000));
dbg!(&product);
let updated = product.update(&pool).await?;
assert_eq!(original_final_price, updated.final_price);
assert_eq!("Gaming Laptop", updated.name);
Ok(())
}
#[sqlx::test(fixtures("generated"))]
async fn upsert_handles_generated_fields(pool: sqlx::PgPool) -> sqlx::Result<()> {
let product = Product::find_by_name("Laptop".to_owned(), &pool).await?;
let mut modified_product = product.clone();
modified_product.price = BigDecimal::from(1200);
let upserted = modified_product.upsert(&pool).await?;
// price is updated
assert_eq!(upserted.price, BigDecimal::from(1200));
// final_price is re-calculated by the DB
// 1200 * (1 - 10 / 100.0) = 1080
assert_eq!(BigDecimal::from(1080), upserted.final_price.unwrap());
Ok(())
}

View File

@@ -1,14 +1,20 @@
use georm::Georm;
use sqlx::{Postgres, types::BigDecimal};
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq, Default)]
#[georm(table = "biographies")]
#[derive(Debug, Georm, PartialEq, Eq, Default)]
#[georm(
table = "biographies",
one_to_one = [{
name = "author", remote_id = "biography_id", table = "authors", entity = Author
}]
)]
pub struct Biography {
#[georm(id)]
pub id: i32,
pub content: String,
}
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq, Default)]
#[derive(Debug, Georm, PartialEq, Eq, Default)]
#[georm(table = "authors")]
pub struct Author {
#[georm(id)]
@@ -30,7 +36,7 @@ impl Ord for Author {
}
}
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq, Default)]
#[derive(Debug, Georm, PartialEq, Eq, Default)]
#[georm(
table = "books",
one_to_many = [
@@ -63,7 +69,7 @@ impl Ord for Book {
}
}
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq)]
#[derive(Debug, Georm, PartialEq, Eq)]
#[georm(table = "reviews")]
pub struct Review {
#[georm(id)]
@@ -73,7 +79,7 @@ pub struct Review {
pub review: String,
}
#[derive(Debug, sqlx::FromRow, Georm, PartialEq, Eq)]
#[derive(Debug, Georm, PartialEq, Eq)]
#[georm(
table = "genres",
many_to_many = [{
@@ -89,3 +95,40 @@ pub struct Genre {
id: i32,
name: String,
}
#[derive(Debug, Georm, PartialEq, Eq, Default)]
#[georm(table = "UserRoles")]
pub struct UserRole {
#[georm(id)]
pub user_id: i32,
#[georm(id)]
pub role_id: i32,
#[georm(defaultable)]
pub assigned_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Georm, PartialEq, Default, Clone)]
#[georm(table = "products")]
pub struct Product {
#[georm(id, generated_always)]
pub id: i32,
#[georm(generated)]
pub sku_number: i32,
pub name: String,
pub price: BigDecimal,
pub discount_percent: i32,
#[georm(generated_always)]
pub final_price: Option<BigDecimal>, // Apparently this can be null ?
}
impl Product {
#[allow(dead_code)]
pub async fn find_by_name<'e, E>(name: String, executor: E) -> ::sqlx::Result<Self>
where
E: sqlx::Executor<'e, Database = Postgres>,
{
::sqlx::query_as!(Self, "SELECT * FROM products WHERE name = $1", name)
.fetch_one(executor)
.await
}
}

View File

@@ -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(())
}

View File

@@ -60,7 +60,10 @@ async fn create_fails_if_already_exists(pool: sqlx::PgPool) -> sqlx::Result<()>
let result = author.create(&pool).await;
assert!(result.is_err());
let error = result.err().unwrap();
assert_eq!("error returned from database: duplicate key value violates unique constraint \"authors_pkey\"", error.to_string());
assert_eq!(
"error returned from database: duplicate key value violates unique constraint \"authors_pkey\"",
error.to_string()
);
Ok(())
}
@@ -112,7 +115,7 @@ async fn should_create_if_does_not_exist(pool: sqlx::PgPool) -> sqlx::Result<()>
name: "Miura Kentaro".into(),
..Default::default()
};
author.create_or_update(&pool).await?;
author.upsert(&pool).await?;
let all_authors = Author::find_all(&pool).await?;
assert_eq!(1, all_authors.len());
Ok(())
@@ -127,7 +130,7 @@ async fn should_update_if_exist(pool: sqlx::PgPool) -> sqlx::Result<()> {
name: "Miura Kentaro".into(),
..Default::default()
};
author.create_or_update(&pool).await?;
author.upsert(&pool).await?;
let mut all_authors = Author::find_all(&pool).await?;
all_authors.sort();
assert_eq!(3, all_authors.len());
@@ -140,12 +143,12 @@ async fn delete_by_id_should_delete_only_one_entry(pool: sqlx::PgPool) -> sqlx::
let id = 2;
let all_authors = Author::find_all(&pool).await?;
assert_eq!(3, all_authors.len());
assert!(all_authors.iter().any(|author| author.get_id() == &id));
assert!(all_authors.iter().any(|author| author.get_id() == id));
let result = Author::delete_by_id(&pool, &id).await?;
assert_eq!(1, result);
let all_authors = Author::find_all(&pool).await?;
assert_eq!(2, all_authors.len());
assert!(all_authors.iter().all(|author| author.get_id() != &id));
assert!(all_authors.iter().all(|author| author.get_id() != id));
Ok(())
}