diff --git a/README.md b/README.md index 821d504..d728911 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ tracing-subscriber = "0.3" | **Lifecycle Events** | `Created`, `Updated`, `Deleted` events | | **Real-Time Streams** | Postgres LISTEN/NOTIFY integration | | **Transactional Outbox** | `events(outbox)` — durable at-least-once event delivery with retry/backoff | -| **Lifecycle Hook Traits** | `{Entity}Hooks` trait emitted with `before_create` / `after_update` / etc.; invocation is currently manual at your service layer (tracking auto-invocation: [#127](https://github.com/RAprogramm/entity-derive/issues/127)) | +| **Lifecycle Hooks** | `{Entity}Hooks` trait plus `{Entity}Repo`, a repository that runs the hooks around every mutation | | **CQRS Commands** | Business-oriented command pattern; `sets(...)` turns one into a domain operation writing named columns | | **Soft Delete** | `deleted_at` timestamp support | | **Structured Logging** | Opt-in `tracing` feature wraps every generated async method in `#[tracing::instrument]` with `entity` + `op` fields | @@ -288,6 +288,29 @@ handlers must be idempotent. Composes with `streams`: NOTIFY wakes subscribers instantly, the outbox guarantees nothing is lost. Requires the `outbox` feature and `serde_json` in your crate. +### Lifecycle Hooks + +`#[entity(hooks)]` emits the `{Entity}Hooks` trait and `{Entity}Repo`, +a repository that owns a pool and a hooks implementation and runs the +hooks around every mutation: + +```rust,ignore +#[derive(Entity)] +#[entity(table = "users", hooks)] +pub struct User { /* ... */ } + +let repo = UserRepo::new(pool, Audit); + +let user = repo.create(dto).await?; // before_create → INSERT → after_create +let found = repo.find_by_id(id).await?; // reads carry no hooks +``` + +A failing `before_*` aborts before anything is written; `after_delete` +and `after_restore` run only when a row was actually affected. The hook +error only has to convert into the repository error, so hooks may keep +their own error type. The bare pool keeps working exactly as before — +the wrapper is opt-in. + ### Domain Operations Columns that must change only through a named operation cannot be diff --git a/crates/entity-derive-impl/Cargo.toml b/crates/entity-derive-impl/Cargo.toml index 125dc83..f47e5e2 100644 --- a/crates/entity-derive-impl/Cargo.toml +++ b/crates/entity-derive-impl/Cargo.toml @@ -49,8 +49,8 @@ events = [] # Generates command structs, the handler trait, and the dispatcher. commands = [] -# Generates `{Entity}Hooks` trait (currently manual-wiring; see #127 for -# auto-invocation plans). +# Generates the `{Entity}Hooks` trait and the `{Entity}Repo` wrapper +# that runs the hooks around every mutation. hooks = [] # Generates `{Entity}TransactionRepo` adapter, the (deprecated) `with_*` diff --git a/crates/entity-derive-impl/src/entity/hooks.rs b/crates/entity-derive-impl/src/entity/hooks.rs index 9532215..29636b2 100644 --- a/crates/entity-derive-impl/src/entity/hooks.rs +++ b/crates/entity-derive-impl/src/entity/hooks.rs @@ -6,24 +6,14 @@ //! Generates a hooks trait for entities with `#[entity(hooks)]`. //! Hooks provide before/after callbacks for CRUD operations. //! -//! # ⚠️ Status: trait emitted, invocation is manual (as of 0.8.2) +//! # Invocation //! -//! This module emits the `{Entity}Hooks` trait for the user to -//! implement. **The generated repository methods do not call it -//! automatically yet.** The generated `impl {Entity}Repository for -//! sqlx::PgPool` lives in a different crate from any user-provided -//! `impl …Hooks for PgPool`, and Rust's orphan rule prevents the user -//! from wiring the two together after the fact. -//! -//! Until full auto-invocation lands, the supported pattern is: -//! -//! 1. Implement `{Entity}Hooks` on a type you own (typically a wrapper around -//! `PgPool` or a service struct in your application). -//! 2. Call the hook methods explicitly at your handler / service layer around -//! the calls into the generated CRUD methods. See -//! `examples/hooks/src/main.rs` for the wiring pattern. -//! -//! Tracking auto-invocation: [issue #127](https://github.com/RAprogramm/entity-derive/issues/127). +//! The orphan rule keeps a user crate from implementing +//! `{Entity}Hooks` for `sqlx::PgPool`, so the repository impl on the +//! pool cannot call hooks — both the trait and the type are foreign +//! there. [`wrapper`] emits the type that can: `{Entity}Repo` owns +//! the pool and the hooks together and runs the hooks around every +//! mutation. The bare pool keeps working without hooks. //! //! # Generated Code //! @@ -43,7 +33,7 @@ //! } //! ``` //! -//! # Usage (manual wiring) +//! # Usage //! //! ```rust,ignore //! struct AppService { @@ -64,15 +54,13 @@ //! } //! } //! -//! // At your handler layer: -//! async fn create_user(svc: &AppService, mut req: CreateUserRequest) -> Result { -//! svc.before_create(&mut req).await?; -//! let user = ::create(&svc.pool, req).await?; -//! svc.after_create(&user).await?; -//! Ok(user) -//! } +//! // The generated repository runs them: +//! let repo = UserRepo::new(pool, AppService); +//! let user = repo.create(req).await?; // before_create → INSERT → after_create //! ``` +mod wrapper; + use proc_macro2::TokenStream; use quote::{format_ident, quote}; @@ -87,6 +75,8 @@ pub fn generate(entity: &EntityDef) -> TokenStream { return TokenStream::new(); } + let repo_wrapper = wrapper::generate(entity); + let vis = &entity.vis; let entity_name = entity.name(); let hooks_trait = format_ident!("{}Hooks", entity_name); @@ -107,15 +97,12 @@ pub fn generate(entity: &EntityDef) -> TokenStream { /// Implement this trait to add custom logic before/after CRUD operations. /// All methods have default no-op implementations. /// - /// # ⚠️ Invocation is manual + /// # Invocation /// - /// The generated `Repository` impl on `sqlx::PgPool` does **not** - /// call these hooks automatically. Implement the trait on a type - /// you own (e.g. a service struct that wraps the pool) and call - /// the hook methods explicitly around your repository calls. - /// See `examples/hooks` and the module docs for the wiring pattern. - /// - /// Tracking auto-invocation: . + /// Pair an implementation with a pool through the generated + /// repository — `{Entity}Repo::new(pool, hooks)` — and the + /// hooks run around every mutation. Calling the repository + /// methods on the bare pool skips them. /// /// # Error Handling /// @@ -132,6 +119,8 @@ pub fn generate(entity: &EntityDef) -> TokenStream { #delete_hooks #command_hooks } + + #repo_wrapper } } diff --git a/crates/entity-derive-impl/src/entity/hooks/wrapper.rs b/crates/entity-derive-impl/src/entity/hooks/wrapper.rs new file mode 100644 index 0000000..bff6dc2 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/hooks/wrapper.rs @@ -0,0 +1,279 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Hook-invoking repository wrapper. +//! +//! The orphan rule keeps a user crate from implementing +//! `{Entity}Hooks` for `sqlx::PgPool`, so the generated repository impl +//! on the pool cannot call hooks: both the trait and the type are +//! foreign to the crate that owns the hooks. `{Entity}Repo` is the +//! type that can — it owns the pool and the hooks together: +//! +//! ```rust,ignore +//! let repo = UserRepo::new(pool, Audit); +//! +//! let user = repo.create(dto).await?; // before_create → INSERT → after_create +//! let found = repo.find_by_id(id).await?; // no hooks for reads: straight to the pool +//! ``` +//! +//! Reads and everything else reach the pool through `Deref`, so the +//! wrapper answers every repository method. The mutating operations are +//! inherent methods on the wrapper, which take precedence over the +//! ones reached through `Deref` — that is what makes the hooks run. +//! +//! Using the bare pool keeps working exactly as before; the wrapper is +//! opt-in. + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +use crate::{ + entity::parse::{EntityDef, SqlLevel}, + utils::marker +}; + +/// Generate `{Entity}Repo` for an entity declaring `hooks`. +/// +/// Returns empty tokens when the entity has no hooks or generates no +/// repository implementation to delegate to. +pub fn generate(entity: &EntityDef) -> TokenStream { + if !entity.has_hooks() || entity.sql != SqlLevel::Full { + return TokenStream::new(); + } + + let vis = &entity.vis; + let entity_name = entity.name(); + let wrapper = entity.ident_with("", "Repo"); + let hooks_trait = format_ident!("{}Hooks", entity_name); + let error_type = entity.error_type(); + let marker = marker::generated(); + + let create = create_method(entity); + let update = update_method(entity); + let delete = delete_method(entity); + let soft_delete_extras = soft_delete_methods(entity); + let save = save_method(entity); + + let doc = format!( + "Repository for [`{entity_name}`] that invokes [`{hooks_trait}`].\n\n\ + Wraps a pool and a hooks implementation. Mutating operations run \ + `before_*`, the statement, then `after_*`; a failing `before_*` \ + aborts before anything is written. Reads and every other \ + repository method reach the pool unchanged.\n\n\ + The hook error only has to convert into the repository error, so \ + hooks may keep their own error type.\n\n\ + ```rust,ignore\n\ + let repo = {wrapper}::new(pool, MyHooks);\n\ + let created = repo.create(dto).await?;\n\ + ```" + ); + + quote! { + #marker + #[doc = #doc] + #vis struct #wrapper + where + H: #hooks_trait + { + pool: sqlx::PgPool, + hooks: H + } + + impl #wrapper + where + H: #hooks_trait, + #error_type: From<::Error> + { + /// Bind a pool to a hooks implementation. + pub const fn new(pool: sqlx::PgPool, hooks: H) -> Self { + Self { + pool, + hooks + } + } + + /// The wrapped pool, for statements this type does not cover. + pub const fn pool(&self) -> &sqlx::PgPool { + &self.pool + } + + /// The wrapped hooks. + pub const fn hooks(&self) -> &H { + &self.hooks + } + + #create + #update + #delete + #soft_delete_extras + #save + } + + #marker + /// Reads and unhooked operations go straight to the pool. + impl std::ops::Deref for #wrapper + where + H: #hooks_trait + { + type Target = sqlx::PgPool; + + fn deref(&self) -> &Self::Target { + &self.pool + } + } + + #marker + impl std::fmt::Debug for #wrapper + where + H: #hooks_trait + { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct(stringify!(#wrapper)).finish_non_exhaustive() + } + } + } +} + +/// `create` with `before_create` / `after_create` around it. +fn create_method(entity: &EntityDef) -> TokenStream { + if entity.create_fields().is_empty() { + return TokenStream::new(); + } + + let entity_name = entity.name(); + let create_dto = entity.ident_with("Create", "Request"); + let error_type = entity.error_type(); + let repo_trait = format_ident!("{}Repository", entity_name); + + quote! { + /// Create a row, running the create hooks around the INSERT. + /// + /// `before_create` may rewrite the DTO; returning an error from + /// it means nothing is written. + pub async fn create(&self, dto: #create_dto) -> Result<#entity_name, #error_type> { + let mut dto = dto; + self.hooks.before_create(&mut dto).await?; + let entity = ::create(&self.pool, dto).await?; + self.hooks.after_create(&entity).await?; + Ok(entity) + } + } +} + +/// `update` with `before_update` / `after_update` around it. +fn update_method(entity: &EntityDef) -> TokenStream { + if entity.update_fields().is_empty() { + return TokenStream::new(); + } + + let entity_name = entity.name(); + let update_dto = entity.ident_with("Update", "Request"); + let id_type = entity.id_field().ty(); + let error_type = entity.error_type(); + let repo_trait = format_ident!("{}Repository", entity_name); + + quote! { + /// Update a row, running the update hooks around the UPDATE. + /// + /// `before_update` may rewrite the patch; returning an error + /// from it means nothing is written. + pub async fn update( + &self, + id: #id_type, + dto: #update_dto + ) -> Result<#entity_name, #error_type> { + let mut dto = dto; + self.hooks.before_update(&id, &mut dto).await?; + let entity = ::update(&self.pool, id, dto).await?; + self.hooks.after_update(&entity).await?; + Ok(entity) + } + } +} + +/// `delete` with `before_delete` / `after_delete` around it. +fn delete_method(entity: &EntityDef) -> TokenStream { + let entity_name = entity.name(); + let id_type = entity.id_field().ty(); + let error_type = entity.error_type(); + let repo_trait = format_ident!("{}Repository", entity_name); + let doc = if entity.is_soft_delete() { + "Soft-delete a row, running the delete hooks around the UPDATE." + } else { + "Delete a row, running the delete hooks around the DELETE." + }; + + quote! { + #[doc = #doc] + /// + /// A failing `before_delete` aborts before anything is written. + /// `after_delete` runs only when a row was actually affected. + pub async fn delete(&self, id: #id_type) -> Result { + self.hooks.before_delete(&id).await?; + let removed = ::delete(&self.pool, id).await?; + if removed { + self.hooks.after_delete(&id).await?; + } + Ok(removed) + } + } +} + +/// `hard_delete` and `restore` for soft-delete entities. +fn soft_delete_methods(entity: &EntityDef) -> TokenStream { + if !entity.is_soft_delete() { + return TokenStream::new(); + } + + let entity_name = entity.name(); + let id_type = entity.id_field().ty(); + let error_type = entity.error_type(); + let repo_trait = format_ident!("{}Repository", entity_name); + + quote! { + /// Remove a row for good, running the hard-delete hooks. + pub async fn hard_delete(&self, id: #id_type) -> Result { + self.hooks.before_hard_delete(&id).await?; + let removed = ::hard_delete(&self.pool, id).await?; + if removed { + self.hooks.after_hard_delete(&id).await?; + } + Ok(removed) + } + + /// Bring a soft-deleted row back, running the restore hooks. + pub async fn restore(&self, id: #id_type) -> Result { + self.hooks.before_restore(&id).await?; + let restored = ::restore(&self.pool, id).await?; + if restored { + self.hooks.after_restore(&id).await?; + } + Ok(restored) + } + } +} + +/// `save` for aggregate roots, hooked like `create`. +fn save_method(entity: &EntityDef) -> TokenStream { + if !entity.is_aggregate_root() || entity.create_fields().is_empty() { + return TokenStream::new(); + } + + let entity_name = entity.name(); + let new_name = entity.ident_with("New", ""); + let error_type = entity.error_type(); + let repo_trait = format_ident!("{}Repository", entity_name); + + quote! { + /// Persist a new aggregate, running the create hooks around it. + /// + /// The aggregate is already built here, so `before_create` has + /// no DTO to rewrite; it runs as a guard and `after_create` + /// sees the persisted row. + pub async fn save(&self, new: #new_name) -> Result<#entity_name, #error_type> { + let entity = ::save(&self.pool, new).await?; + self.hooks.after_create(&entity).await?; + Ok(entity) + } + } +} diff --git a/crates/entity-derive/Cargo.toml b/crates/entity-derive/Cargo.toml index 3c03aca..c692be9 100644 --- a/crates/entity-derive/Cargo.toml +++ b/crates/entity-derive/Cargo.toml @@ -59,7 +59,8 @@ events = ["entity-derive-impl/events"] # CQRS command pattern: command structs + dispatcher. commands = ["entity-derive-impl/commands"] -# `{Entity}Hooks` trait. Manual wiring today; auto-invocation tracked in #127. +# `{Entity}Hooks` trait plus `{Entity}Repo`, the repository that runs +# the hooks around every mutation. hooks = ["entity-derive-impl/hooks"] # `{Entity}TransactionRepo` adapter and the `with_*` builder methods diff --git a/crates/entity-derive/tests/cases/pass/hooks_wrapper.rs b/crates/entity-derive/tests/cases/pass/hooks_wrapper.rs new file mode 100644 index 0000000..9fbe5da --- /dev/null +++ b/crates/entity-derive/tests/cases/pass/hooks_wrapper.rs @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! The generated repository wrapper invokes the hooks; the bare pool +//! keeps working without them. + +use entity_derive::{Entity, async_trait}; +use uuid::Uuid; + +#[derive(Debug, Clone, Entity)] +#[entity(table = "users", hooks)] +pub struct User { + #[id] + pub id: Uuid, + + #[field(create, update, response)] + pub name: String, +} + +struct Audit; + +#[async_trait] +impl UserHooks for Audit { + type Error = sqlx::Error; +} + +async fn exercise(pool: sqlx::PgPool, id: Uuid) -> Result<(), sqlx::Error> { + let repo = UserRepo::new(pool.clone(), Audit); + + let created: User = repo.create(CreateUserRequest { name: "Ada".into() }).await?; + let updated: User = repo + .update(created.id, UpdateUserRequest { name: Some("Grace".into()) }) + .await?; + let removed: bool = repo.delete(updated.id).await?; + + // Reads reach the pool through the wrapper. + let found: Option = repo.find_by_id(id).await?; + + // The bare pool still works, without hooks. + let listed: Vec = pool.list(10, 0).await?; + + let _ = (removed, found, listed, repo.pool(), repo.hooks()); + Ok(()) +} + +fn main() { + let _ = exercise; +} diff --git a/crates/entity-derive/tests/postgres.rs b/crates/entity-derive/tests/postgres.rs index 7d434c9..15b05e2 100644 --- a/crates/entity-derive/tests/postgres.rs +++ b/crates/entity-derive/tests/postgres.rs @@ -2903,3 +2903,243 @@ mod domain_operations { db.teardown().await; } } + +/// The hook-invoking wrapper: order of calls, and what a refusing +/// `before_*` must prevent. +mod hooks { + use std::sync::{Arc, Mutex}; + + use entity_derive::{Entity, async_trait}; + use uuid::Uuid; + + use crate::pg; + + #[derive(Debug, Clone, Entity)] + #[entity(table = "accounts_h", migrations, soft_delete, hooks)] + pub struct Account { + #[id] + pub id: Uuid, + + #[field(create, update, response)] + pub label: String, + + #[field(skip)] + pub deleted_at: Option> + } + + /// Records the calls it receives, and can refuse one of them. + #[derive(Clone)] + struct Recorder { + calls: Arc>>, + refuse: Option<&'static str> + } + + impl Recorder { + fn new(refuse: Option<&'static str>) -> Self { + Self { + calls: Arc::new(Mutex::new(Vec::new())), + refuse + } + } + + fn note(&self, call: &'static str) -> Result<(), sqlx::Error> { + self.calls + .lock() + .expect("the recorder lock is never poisoned") + .push(call); + if self.refuse == Some(call) { + return Err(sqlx::Error::RowNotFound); + } + Ok(()) + } + + fn calls(&self) -> Vec<&'static str> { + self.calls + .lock() + .expect("the recorder lock is never poisoned") + .clone() + } + } + + #[async_trait] + impl AccountHooks for Recorder { + type Error = sqlx::Error; + + async fn before_create(&self, dto: &mut CreateAccountRequest) -> Result<(), Self::Error> { + let trimmed = dto.label.trim().to_owned(); + dto.label = trimmed; + self.note("before_create") + } + + async fn after_create(&self, _entity: &Account) -> Result<(), Self::Error> { + self.note("after_create") + } + + async fn before_update( + &self, + _id: &Uuid, + _dto: &mut UpdateAccountRequest + ) -> Result<(), Self::Error> { + self.note("before_update") + } + + async fn after_update(&self, _entity: &Account) -> Result<(), Self::Error> { + self.note("after_update") + } + + async fn before_delete(&self, _id: &Uuid) -> Result<(), Self::Error> { + self.note("before_delete") + } + + async fn after_delete(&self, _id: &Uuid) -> Result<(), Self::Error> { + self.note("after_delete") + } + + async fn before_restore(&self, _id: &Uuid) -> Result<(), Self::Error> { + self.note("before_restore") + } + + async fn after_restore(&self, _id: &Uuid) -> Result<(), Self::Error> { + self.note("after_restore") + } + } + + #[tokio::test] + async fn hooks_run_around_every_mutation() { + let Some(db) = pg::provision("hooks", &[Account::MIGRATION_UP]).await else { + return; + }; + let pool = db.pool(); + + let recorder = Recorder::new(None); + let repo = AccountRepo::new(pool.clone(), recorder.clone()); + + let created = repo + .create(CreateAccountRequest { + label: " ledger ".to_owned() + }) + .await + .expect("create failed"); + assert_eq!( + created.label, "ledger", + "before_create must be able to rewrite the DTO before the INSERT" + ); + + repo.update( + created.id, + UpdateAccountRequest { + label: Some("cashbook".to_owned()) + } + ) + .await + .expect("update failed"); + + assert!(repo.delete(created.id).await.expect("delete failed")); + assert!(repo.restore(created.id).await.expect("restore failed")); + + assert_eq!( + recorder.calls(), + vec![ + "before_create", + "after_create", + "before_update", + "after_update", + "before_delete", + "after_delete", + "before_restore", + "after_restore", + ] + ); + + db.teardown().await; + } + + #[tokio::test] + async fn a_refusing_before_hook_writes_nothing() { + let Some(db) = pg::provision("hooksrefuse", &[Account::MIGRATION_UP]).await else { + return; + }; + let pool = db.pool(); + + let recorder = Recorder::new(Some("before_create")); + let repo = AccountRepo::new(pool.clone(), recorder.clone()); + + assert!( + repo.create(CreateAccountRequest { + label: "ledger".to_owned() + }) + .await + .is_err(), + "a refusing before_create must fail the call" + ); + assert!( + pool.list(10, 0).await.expect("list failed").is_empty(), + "a refused create must not have written a row" + ); + assert_eq!( + recorder.calls(), + vec!["before_create"], + "the after hook must not run when the before hook refused" + ); + + db.teardown().await; + } + + #[tokio::test] + async fn a_refusing_delete_hook_leaves_the_row() { + let Some(db) = pg::provision("hooksdel", &[Account::MIGRATION_UP]).await else { + return; + }; + let pool = db.pool(); + + let stored = pool + .create(CreateAccountRequest { + label: "ledger".to_owned() + }) + .await + .expect("create failed"); + + let repo = AccountRepo::new(pool.clone(), Recorder::new(Some("before_delete"))); + assert!(repo.delete(stored.id).await.is_err()); + assert!( + pool.find_by_id(stored.id) + .await + .expect("read failed") + .is_some(), + "a refused delete must leave the row in place" + ); + + db.teardown().await; + } + + #[tokio::test] + async fn reads_reach_the_pool_through_the_wrapper() { + let Some(db) = pg::provision("hooksread", &[Account::MIGRATION_UP]).await else { + return; + }; + let pool = db.pool(); + + let recorder = Recorder::new(None); + let repo = AccountRepo::new(pool.clone(), recorder.clone()); + let created = repo + .create(CreateAccountRequest { + label: "ledger".to_owned() + }) + .await + .expect("create failed"); + + let found = repo + .find_by_id(created.id) + .await + .expect("read through the wrapper failed") + .expect("row missing"); + assert_eq!(found.id, created.id); + assert_eq!( + recorder.calls(), + vec!["before_create", "after_create"], + "a read must not invoke any hook" + ); + + db.teardown().await; + } +} diff --git a/examples/hooks/src/main.rs b/examples/hooks/src/main.rs index 0a505b3..2a1fd48 100644 --- a/examples/hooks/src/main.rs +++ b/examples/hooks/src/main.rs @@ -9,15 +9,16 @@ //! - before_update, after_update //! - before_delete, after_delete //! -//! # Manual wiring required (as of 0.8.2) +//! # Invocation //! -//! The macro emits the `{Entity}Hooks` trait, but the generated -//! `Repository` impl on `sqlx::PgPool` does NOT call it automatically. -//! See the HTTP handlers below — each one calls `svc.before_*().await?` -//! and `svc.after_*().await?` explicitly around the repository call. -//! That is the supported pattern for now. +//! The macro emits the `{Entity}Hooks` trait together with +//! `{Entity}Repo`, a repository that owns a pool and a hooks +//! implementation and runs the hooks around every mutation. The +//! handlers below call `repo.create(dto)` and the hooks fire on their +//! own; reads reach the pool through the same value. //! -//! Auto-invocation is tracked in . +//! The bare pool keeps working without hooks, which is what the +//! `list_users` handler uses. use std::sync::Arc; @@ -86,6 +87,14 @@ impl std::fmt::Display for HookError { impl std::error::Error for HookError {} +// The wrapper needs the hook error to convert into the repository +// error, which for this entity is the sqlx one. +impl From for sqlx::Error { + fn from(e: HookError) -> Self { + Self::Protocol(e.to_string()) + } +} + struct MyUserHooks; #[async_trait] @@ -150,8 +159,7 @@ impl UserHooks for MyUserHooks { #[derive(Clone)] struct AppState { - pool: Arc, - hooks: Arc + repo: Arc> } // ============================================================================ @@ -160,56 +168,29 @@ struct AppState { async fn create_user( State(state): State, - Json(mut dto): Json + Json(dto): Json ) -> Result { - // Run before_create hook - state - .hooks - .before_create(&mut dto) - .await - .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; - + // before_create runs inside, and may rewrite the DTO. let user = state - .pool + .repo .create(dto) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // Run after_create hook - state - .hooks - .after_create(&user) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - Ok((StatusCode::CREATED, Json(UserResponse::from(user)))) } async fn update_user( State(state): State, Path(id): Path, - Json(mut dto): Json + Json(dto): Json ) -> Result { - // Run before_update hook - state - .hooks - .before_update(&id, &mut dto) - .await - .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; - let user = state - .pool + .repo .update(id, dto) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // Run after_update hook - state - .hooks - .after_update(&user) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - Ok(Json(UserResponse::from(user))) } @@ -217,27 +198,14 @@ async fn delete_user( State(state): State, Path(id): Path ) -> Result { - // Run before_delete hook - state - .hooks - .before_delete(&id) - .await - .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; - + // after_delete runs only when a row was actually affected. let deleted = state - .pool + .repo .delete(id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if deleted { - // Run after_delete hook - state - .hooks - .after_delete(&id) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - Ok(StatusCode::NO_CONTENT) } else { Err((StatusCode::NOT_FOUND, "User not found".into())) @@ -245,8 +213,9 @@ async fn delete_user( } async fn list_users(State(state): State) -> Result { + // Reads carry no hooks; the wrapper forwards them to the pool. let users = state - .pool + .repo .list(100, 0) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -289,8 +258,7 @@ async fn main() { .expect("Failed to run migrations"); let state = AppState { - pool: Arc::new(pool), - hooks: Arc::new(MyUserHooks) + repo: Arc::new(UserRepo::new(pool, MyUserHooks)) }; let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); diff --git a/wiki/Ganchos.md b/wiki/Ganchos.md index 24df86e..ec40d61 100644 --- a/wiki/Ganchos.md +++ b/wiki/Ganchos.md @@ -57,6 +57,27 @@ pub trait UserHooks: Send + Sync { } ``` +## Invocación de los Ganchos + +El atributo `hooks` genera además `{Entity}Repo`: un repositorio que posee un pool junto con una implementación de ganchos y los ejecuta alrededor de cada mutación: + +```rust +let repo = UserRepo::new(pool, MyUserHooks); + +let user = repo.create(dto).await?; // before_create → INSERT → after_create +let user = repo.update(id, patch).await?; // before_update → UPDATE → after_update +let gone = repo.delete(id).await?; // after_delete solo si se afectó una fila +let found = repo.find_by_id(id).await?; // las lecturas no llevan ganchos +``` + +Un `before_*` que falla aborta antes de escribir nada. Las lecturas y el resto de métodos del repositorio llegan al pool a través del envoltorio sin cambios, y el pool desnudo sigue funcionando sin ganchos: el envoltorio es opcional. + +Al error de los ganchos le basta con convertirse en el error del repositorio, así que pueden mantener su propio tipo: + +```rust +impl From for sqlx::Error { /* ... */ } +``` + ## Ejemplo de Implementación ```rust diff --git a/wiki/Hooks-en.md b/wiki/Hooks-en.md index 6131a9c..edd427a 100644 --- a/wiki/Hooks-en.md +++ b/wiki/Hooks-en.md @@ -57,6 +57,27 @@ pub trait UserHooks: Send + Sync { } ``` +## Invoking the Hooks + +The `hooks` attribute also generates `{Entity}Repo` — a repository that owns a pool and a hooks implementation and runs the hooks around every mutation: + +```rust +let repo = UserRepo::new(pool, MyUserHooks); + +let user = repo.create(dto).await?; // before_create → INSERT → after_create +let user = repo.update(id, patch).await?; // before_update → UPDATE → after_update +let gone = repo.delete(id).await?; // after_delete only when a row was affected +let found = repo.find_by_id(id).await?; // reads carry no hooks +``` + +A failing `before_*` aborts before anything is written. Reads and every other repository method reach the pool through the wrapper unchanged, and the bare pool keeps working without hooks — the wrapper is opt-in. + +The hook error only has to convert into the repository error, so hooks may keep their own error type: + +```rust +impl From for sqlx::Error { /* ... */ } +``` + ## Implementation Example ```rust diff --git "a/wiki/\320\245\321\203\320\272\320\270.md" "b/wiki/\320\245\321\203\320\272\320\270.md" index cc3e047..43113cb 100644 --- "a/wiki/\320\245\321\203\320\272\320\270.md" +++ "b/wiki/\320\245\321\203\320\272\320\270.md" @@ -57,6 +57,27 @@ pub trait UserHooks: Send + Sync { } ``` +## Вызов хуков + +Атрибут `hooks` генерирует ещё и `{Entity}Repo` — репозиторий, который держит пул вместе с реализацией хуков и вызывает их вокруг каждой мутации: + +```rust +let repo = UserRepo::new(pool, MyUserHooks); + +let user = repo.create(dto).await?; // before_create → INSERT → after_create +let user = repo.update(id, patch).await?; // before_update → UPDATE → after_update +let gone = repo.delete(id).await?; // after_delete только если строка затронута +let found = repo.find_by_id(id).await?; // на чтениях хуков нет +``` + +Ошибка в `before_*` прерывает операцию до записи. Чтения и все остальные методы репозитория проходят через обёртку к пулу без изменений, а голый пул продолжает работать без хуков — обёртка подключается по желанию. + +Ошибке хуков достаточно конвертироваться в ошибку репозитория, так что хуки могут иметь свой тип ошибки: + +```rust +impl From for sqlx::Error { /* ... */ } +``` + ## Пример реализации ```rust diff --git "a/wiki/\351\222\251\345\255\220.md" "b/wiki/\351\222\251\345\255\220.md" index 7d49f5c..680b72a 100644 --- "a/wiki/\351\222\251\345\255\220.md" +++ "b/wiki/\351\222\251\345\255\220.md" @@ -57,6 +57,27 @@ pub trait UserHooks: Send + Sync { } ``` +## 调用钩子 + +`hooks` 属性还会生成 `{Entity}Repo`——一个同时持有连接池和钩子实现的仓储,在每次写操作前后执行钩子: + +```rust +let repo = UserRepo::new(pool, MyUserHooks); + +let user = repo.create(dto).await?; // before_create → INSERT → after_create +let user = repo.update(id, patch).await?; // before_update → UPDATE → after_update +let gone = repo.delete(id).await?; // 仅当确有行受影响时才执行 after_delete +let found = repo.find_by_id(id).await?; // 读取不触发钩子 +``` + +`before_*` 失败会在写入之前中止操作。读取和其余仓储方法通过包装器原样转发到连接池,裸连接池也继续在没有钩子的情况下工作——包装器是可选的。 + +钩子的错误只需能转换成仓储错误,因此钩子可以保留自己的错误类型: + +```rust +impl From for sqlx::Error { /* ... */ } +``` + ## 实现示例 ```rust diff --git "a/wiki/\355\233\205.md" "b/wiki/\355\233\205.md" index efe540c..ac4e5ed 100644 --- "a/wiki/\355\233\205.md" +++ "b/wiki/\355\233\205.md" @@ -57,6 +57,27 @@ pub trait UserHooks: Send + Sync { } ``` +## 훅 호출 + +`hooks` 속성은 `{Entity}Repo`도 생성합니다. 풀과 훅 구현을 함께 소유하며 모든 변경 연산 주위에서 훅을 실행하는 리포지토리입니다: + +```rust +let repo = UserRepo::new(pool, MyUserHooks); + +let user = repo.create(dto).await?; // before_create → INSERT → after_create +let user = repo.update(id, patch).await?; // before_update → UPDATE → after_update +let gone = repo.delete(id).await?; // 실제로 행이 영향받은 경우에만 after_delete +let found = repo.find_by_id(id).await?; // 읽기에는 훅이 없습니다 +``` + +`before_*`가 실패하면 아무것도 기록되기 전에 중단됩니다. 읽기를 비롯한 나머지 리포지토리 메서드는 래퍼를 통해 그대로 풀로 전달되며, 순수한 풀은 훅 없이 계속 동작합니다 — 래퍼는 선택 사항입니다. + +훅 오류는 리포지토리 오류로 변환되기만 하면 되므로, 훅은 자체 오류 타입을 유지할 수 있습니다: + +```rust +impl From for sqlx::Error { /* ... */ } +``` + ## 구현 예제 ```rust