From a6c19f0d22bf4000be7e1488f4e48c56ebd34917 Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Thu, 5 Jun 2025 00:49:11 +0200 Subject: [PATCH] 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. --- LICENSE.gpl-3.0.md => LICENSE.GPL.md | 0 README.md | 733 +++++++++++++++++++++++---- 2 files changed, 623 insertions(+), 110 deletions(-) rename LICENSE.gpl-3.0.md => LICENSE.GPL.md (100%) diff --git a/LICENSE.gpl-3.0.md b/LICENSE.GPL.md similarity index 100% rename from LICENSE.gpl-3.0.md rename to LICENSE.GPL.md diff --git a/README.md b/README.md index 3bcbac5..deb50d4 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

Georm

- A simple, opinionated SQLx ORM for PostgreSQL + A simple, type-safe SQLx ORM for PostgreSQL

@@ -24,90 +24,257 @@ docs.rs docs + + + License + -## What is Georm? +## Overview -Georm is a quite simple ORM built around -[SQLx](https://crates.io/crates/sqlx) that gives access to a few -useful functions when interacting with a database, implementing -automatically the most basic SQL interactions you’re tired of writing. +Georm is a lightweight, opinionated Object-Relational Mapping (ORM) library built on top of [SQLx](https://crates.io/crates/sqlx) for PostgreSQL. It provides a clean, type-safe interface for common database operations while leveraging SQLx's compile-time query verification. -## Why is Georm? +### Key Features -I wanted an ORM that’s easy and straightforward to use. I am aware -some other projects exist, such as -[SeaORM](https://www.sea-ql.org/SeaORM/), but they generally don’t fit -my needs and/or my wants of a simple interface. I ended up writing the -ORM I wanted to use. +- **Type Safety**: Compile-time verified SQL queries using SQLx macros +- **Zero Runtime Cost**: No reflection or runtime query building +- **Simple API**: Intuitive derive macros for common operations +- **Relationship Support**: One-to-one, one-to-many, and many-to-many relationships +- **Defaultable Fields**: Easy entity creation with database defaults and auto-generated values +- **PostgreSQL Native**: Optimized for PostgreSQL features and data types -## How is Georm? +## Quick Start -I use it in a few projects, and I’m quite happy with it right now. But -of course, I’m open to constructive criticism and suggestions! +### Installation -## How can I use it? +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", features = ["runtime-tokio-rustls", "postgres", "macros"] } +georm = "0.1" ``` -As Georm relies heavily on the macro -[`query_as!`](https://docs.rs/sqlx/latest/sqlx/macro.query_as.html), -the `macros` feature is not optional. Declare your tables in your -Postgres database (you may want to use SQLx’s `migrate` feature for -this), and then declare their equivalent in Rust. +### Basic Usage + +1. **Define your database schema**: ```sql -CREATE TABLE biographies ( - id SERIAL PRIMARY KEY, - content TEXT NOT NULL -); - CREATE TABLE authors ( id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, - biography_id INT, - FOREIGN KEY (biography_id) REFERENCES biographies(id) + email VARCHAR(255) UNIQUE NOT NULL +); + +CREATE TABLE posts ( + id SERIAL PRIMARY KEY, + title VARCHAR(200) NOT NULL, + content TEXT NOT NULL, + published BOOLEAN DEFAULT FALSE, + author_id INT NOT NULL REFERENCES authors(id), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); ``` -```rust -pub struct Author { - pub id: i32, - pub name: String, -} -``` +2. **Define your Rust entities**: -To link a struct to a table in your database, derive the -`sqlx::FromRow` and the `georm::Georm` traits. ```rust -#[derive(sqlx::FromRow, Georm)] -pub struct Author { - pub id: i32, - pub name: String, -} -``` +use georm::Georm; -Now, indicate with the `georm` proc-macro which table they refer to. -```rust #[derive(sqlx::FromRow, Georm)] #[georm(table = "authors")] pub struct Author { + #[georm(id)] pub id: i32, pub name: String, + pub email: String, +} + +#[derive(sqlx::FromRow, 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, } ``` -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 +use sqlx::PgPool; + +async fn example(pool: &PgPool) -> sqlx::Result<()> { + // Create an author + let author = Author { + id: 0, // Will be auto-generated + name: "Jane Doe".to_string(), + email: "jane@example.com".to_string(), + }; + let author = author.create(pool).await?; + + // Create a post + let post = Post { + id: 0, + title: "Hello, Georm!".to_string(), + content: "This is my first post using Georm.".to_string(), + published: false, + author_id: author.id, + created_at: chrono::Utc::now(), + }; + let post = post.create(pool).await?; + + // Find all posts + let all_posts = Post::find_all(pool).await?; + + // Get the post's author + let post_author = post.get_author(pool).await?; + + println!("Post '{}' by {}", post.title, post_author.name); + + Ok(()) +} +``` + +## Advanced Features + +### Defaultable Fields + +For fields with database defaults or auto-generated values, use the `defaultable` attribute: + ```rust #[derive(sqlx::FromRow, Georm)] -#[georm(table = "authors")] +#[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, // DEFAULT NOW() + pub author_id: i32, +} +``` + +This generates a `PostDefault` struct for easier creation: + +```rust +use georm::Defaultable; + +let post_default = PostDefault { + id: None, // Let database auto-generate + title: "My Post".to_string(), + published: None, // Use database default + created_at: None, // Use database default (NOW()) + author_id: 42, +}; + +let created_post = post_default.create(pool).await?; +``` + +### Relationships + +Georm supports comprehensive relationship modeling with two approaches: field-level relationships for foreign keys and struct-level relationships for reverse lookups. + +#### Field-Level Relationships (Foreign Keys) + +Use the `relation` attribute on foreign key fields to generate lookup methods: + +```rust +#[derive(sqlx::FromRow, 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(sqlx::FromRow, 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, +} +``` + +**Generated method**: `post.get_category(pool).await? -> Option` + +#### 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(sqlx::FromRow, 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` + +##### One-to-Many Relationships + +```rust +#[derive(sqlx::FromRow, 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, @@ -115,83 +282,429 @@ 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` +- `author.get_comments(pool).await? -> Vec` -## Entity relationship +##### 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)] #[georm( table = "books", - one_to_one = [ - { name = "draft", remote_id = "book_id", table = "drafts", entity = Draft } - ], - one_to_many = [ - { name = "reviews", remote_id = "book_id", table = "reviews", entity = Review }, - { name = "reprints", remote_id = "book_id", table = "reprints", entity = Reprint } - ], 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(sqlx::FromRow, 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.get_books(pool).await? -> Vec` -## Roadmap / TODO +#### Relationship Attribute Reference -The following features are being considered for future development: +| 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(sqlx::FromRow, 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, +} +``` + +**Generated methods**: +- `post.get_author(pool).await? -> Author` (from field relation) +- `post.get_category(pool).await? -> Option` (nullable field relation) +- `post.get_comments(pool).await? -> Vec` (one-to-many) +- `post.get_tags(pool).await? -> Vec` (many-to-many) + +## API Reference + +### Core Operations + +All entities implementing `Georm` get these methods: + +```rust +// Query operations +Post::find_all(pool).await?; // Find all posts +Post::find(pool, &post_id).await?; // Find by ID + +// Mutation operations +post.create(pool).await?; // Insert new record +post.update(pool).await?; // Update existing record +post.create_or_update(pool).await?; // Upsert operation +post.delete(pool).await?; // Delete this record +Post::delete_by_id(pool, &post_id).await?; // Delete by ID + +// Utility +post.get_id(); // Get entity ID +``` + +### Defaultable Operations + +Entities with defaultable fields get a companion `Default` struct: + +```rust +// Create with defaults +post_default.create(pool).await?; +``` + +## Configuration + +### Attributes Reference + +#### Struct-level attributes + +```rust +#[georm( + table = "table_name", // Required: database table name + one_to_one = [{ /* ... */ }], // Optional: one-to-one relationships + one_to_many = [{ /* ... */ }], // Optional: one-to-many relationships + many_to_many = [{ /* ... */ }] // Optional: many-to-many relationships +)] +``` + +#### Field-level attributes + +```rust +#[georm(id)] // Mark as primary key +#[georm(defaultable)] // Mark as defaultable field +#[georm(relation = { /* ... */ })] // Define relationship +``` + +## Performance + +Georm is designed for zero runtime overhead: + +- **Compile-time queries**: All SQL is verified at compile time +- **No reflection**: Direct field access, no runtime introspection +- **Minimal allocations**: Efficient use of owned vs borrowed data +- **SQLx integration**: Leverages SQLx's optimized PostgreSQL driver + +## Comparison + +| Feature | Georm | SeaORM | Diesel | +|----------------------+-------+--------+--------| +| Compile-time safety | ✅ | ✅ | ✅ | +| Relationship support | ✅ | ✅ | ✅ | +| Async support | ✅ | ✅ | ⚠️ | +| Learning curve | Low | Medium | High | +| Macro simplicity | ✅ | ❌ | ❌ | +| Advanced queries | ❌ | ✅ | ✅ | + +## Roadmap ### High Priority -- **Transaction Support**: Add comprehensive transaction support with - transaction-aware CRUD methods and relationship handling for atomic - operations across multiple entities -- **Race Condition Fix**: Replace the current `create_or_update` - implementation with database-specific UPSERT operations (PostgreSQL - `ON CONFLICT`, MySQL `ON DUPLICATE KEY UPDATE`, SQLite `ON - CONFLICT`) to prevent race conditions +- **Transaction Support**: Comprehensive transaction handling with atomic operations +- **Race Condition Fix**: Database-native UPSERT operations to replace current `create_or_update` ### Medium Priority -- **Multi-Database Support**: Extend Georm to support MySQL and SQLite - in addition to PostgreSQL, with database-specific optimizations and - dialect handling -- **Relationship Optimization**: Implement eager loading and N+1 query - prevention with circular dependency protection to dramatically - improve performance when working with related entities -- **Composite Primary Keys**: Add support for entities with multiple - primary key fields using auto-generated ID structs and type-safe - composite key handling -- **Soft Delete**: Implement optional soft delete functionality with - `deleted_at` timestamps, allowing entities to be marked as deleted - without physical removal +- **Multi-Database Support**: MySQL and SQLite support with feature flags +- **Relationship Optimization**: Eager loading and N+1 query prevention +- **Composite Primary Keys**: Multi-field primary key support +- **Soft Delete**: Optional soft delete with `deleted_at` timestamps ### Lower Priority -- **Migration Support**: Add optional migration utilities that - leverage SQLx's existing infrastructure for schema generation, - verification, and evolution -- **Enhanced Error Handling**: Consider implementing custom error - types with better categorization and operation context while - maintaining compatibility with SQLx errors -- **Many-to-Many Relationship Improvements**: Add direct methods to - add or remove items from many-to-many relationships without manually - handling the join table +- **Migration Support**: Schema generation and evolution utilities +- **Enhanced Error Handling**: Custom error types with better context -### Recently Completed -- ✅ **Defaultable Fields**: Support for fields with database defaults - or auto-generated values, creating companion structs with optional - fields for easier entity creation +## Contributing + +We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details. + +### Development Setup + +#### Prerequisites + +- **Rust 1.81+**: 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 +``` + +#### Nix Development Environment (Optional) + +If you use [Nix](https://nixos.org/), you can use the provided flake for a reproducible development environment: + +```bash +# Enter the development shell with all tools pre-installed +nix develop + +# Or use direnv for automatic environment activation +direnv allow +``` + +The Nix flake provides: +- Exact Rust version (1.81) with required components +- All development tools (just, cargo-deny, sqlx-cli, bacon) +- LSP support (rust-analyzer) +- SQL tooling (sqls for SQL language server) + +**Nix flake contents:** +- **Rust toolchain**: Specified version with rustfmt, clippy, and rust-analyzer +- **Development tools**: just, cargo-deny, sqlx-cli, bacon +- **SQL tools**: sqls (SQL language server) +- **Platform support**: Currently x86_64-linux (can be extended) + +#### 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 the simplicity of Rails' Active Record and Django's ORM