georm/src/lib.rs
Lucien Cartier-Tilet 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

655 lines
23 KiB
Rust

//! # Georm
//!
//! A simple, type-safe PostgreSQL ORM built on SQLx with zero runtime overhead.
//!
//! ## Quick Start
//!
//! ```ignore
//! 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,
//! 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
//! ```
//!
//! ## Core CRUD Operations
//!
//! ### 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
//!
//! ### 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.create_or_update(pool)` - True PostgreSQL upsert using `ON CONFLICT`, returns final entity
//! - `entity.delete(pool)` - Delete this record, returns affected row count
//! - `entity.get_id()` - Get reference to the entity's ID (`&Id` for simple keys, owned for composite)
//!
//! ```ignore
//! // 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**: `create_or_update()` uses `INSERT ... ON CONFLICT ... DO UPDATE` for atomic upsert operations
//! - **Prepared statements**: All queries use parameter binding for security and performance
//! - **Compile-time verification**: SQLx macros verify all generated SQL against your database schema at compile time
//!
//! ## Primary Keys and Identifiers
//!
//! ### Simple Primary Keys
//!
//! Primary key fields can have any name (not just "id"):
//!
//! ```ignore
//! #[derive(Georm)]
//! #[georm(table = "books")]
//! pub struct Book {
//! #[georm(id)]
//! ident: i32, // Custom field name for primary key
//! title: String,
//! }
//!
//! // Works the same way
//! let book = Book::find(&pool, &1).await?;
//! ```
//!
//! ### Composite Primary Keys
//!
//! Mark multiple fields with `#[georm(id)]` for composite keys:
//!
//! ```ignore
//! #[derive(Georm)]
//! #[georm(table = "user_roles")]
//! pub struct UserRole {
//! #[georm(id)]
//! user_id: i32,
//! #[georm(id)]
//! role_id: i32,
//! assigned_at: chrono::DateTime<chrono::Utc>,
//! }
//! ```
//!
//! This automatically generates a composite ID struct following the `{EntityName}Id` pattern:
//!
//! ```ignore
//! // 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")]
//! 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
//! }
//!
//! 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_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)]
//! id: i32,
//! username: String,
//! }
//! ```
//!
//! **Generated instance method**: `user.get_profile(pool).await? -> sqlx::Result<Option<Profile>>`
//!
//! #### One-to-Many Relationships
//!
//! ```ignore
//! #[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)]
//! id: i32,
//! name: String,
//! }
//! ```
//!
//! **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
//!
//! 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)
//! );
//! ```
//!
//! ```ignore
//! #[derive(Georm)]
//! #[georm(
//! table = "books",
//! many_to_many = [{
//! 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)]
//! id: i32,
//! 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)]
//! id: i32,
//! name: String,
//! }
//! ```
//!
//! **Generated instance methods**:
//! - `book.get_genres(pool).await? -> sqlx::Result<Vec<Genre>>`
//! - `genre.get_books(pool).await? -> sqlx::Result<Vec<Book>>`
//!
//! #### Struct-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"` |
//! | `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
//!
//! 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 Support
//!
//! 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
//! 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;
mod georm;
pub use georm::Georm;
mod defaultable;
pub use defaultable::Defaultable;