diff --git a/crates/entity-core/src/lib.rs b/crates/entity-core/src/lib.rs index b38fafc..4ad6a72 100644 --- a/crates/entity-core/src/lib.rs +++ b/crates/entity-core/src/lib.rs @@ -593,3 +593,33 @@ impl std::fmt::Display for ConstraintError { } impl std::error::Error for ConstraintError {} + +/// A state-machine transition was attempted from a status it is not +/// declared for. +/// +/// Produced by repository methods generated from `#[transition(...)]` +/// declarations. The consumer's error type must implement +/// `From`; map it to an HTTP 409 or a domain conflict. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransitionError { + /// Entity name the transition belongs to. + pub entity: &'static str, + + /// Current status of the row, `Debug`-formatted. + pub from: String, + + /// Target status of the attempted transition. + pub to: &'static str +} + +impl std::fmt::Display for TransitionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} cannot transition from `{}` to `{}`", + self.entity, self.from, self.to + ) + } +} + +impl std::error::Error for TransitionError {} diff --git a/crates/entity-derive-impl/src/entity/parse.rs b/crates/entity-derive-impl/src/entity/parse.rs index 5046b88..30638f8 100644 --- a/crates/entity-derive-impl/src/entity/parse.rs +++ b/crates/entity-derive-impl/src/entity/parse.rs @@ -120,7 +120,9 @@ mod uuid_version; pub use api::ApiConfig; pub use command::{CommandDef, CommandKindHint, CommandSource}; pub use dialect::DatabaseDialect; -pub use entity::{CompositeIndexDef, EntityDef, HasManyDef, ProjectionDef, UpsertAction}; +pub use entity::{ + CompositeIndexDef, EntityDef, HasManyDef, ProjectionDef, TransitionDef, UpsertAction +}; #[allow(unused_imports)] // Will be used for OpenAPI schema examples (#80) pub use field::ExampleValue; #[allow(unused_imports)] // Re-exported for migration generation tests diff --git a/crates/entity-derive-impl/src/entity/parse/entity.rs b/crates/entity-derive-impl/src/entity/parse/entity.rs index 8185069..645f0ee 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity.rs @@ -145,6 +145,7 @@ mod helpers; mod index; mod join; mod projection; +mod transition; mod upsert; pub use attrs::EntityAttrs; @@ -152,6 +153,7 @@ pub use def::EntityDef; pub use helpers::{CustomConstraintDef, HasManyDef}; pub use index::CompositeIndexDef; pub use projection::{ProjectionDef, parse_projection_attrs}; +pub use transition::TransitionDef; pub use upsert::UpsertAction; #[cfg(test)] diff --git a/crates/entity-derive-impl/src/entity/parse/entity/constructor.rs b/crates/entity-derive-impl/src/entity/parse/entity/constructor.rs index dce5060..7b77454 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity/constructor.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity/constructor.rs @@ -133,6 +133,8 @@ impl EntityDef { let api_config = parse_api_attr(&input.attrs); let indexes = parse_index_attrs(&input.attrs); let joins = super::join::parse_join_attrs(&input.attrs).map_err(darling::Error::from)?; + let transitions = super::transition::parse_transition_attrs(&input.attrs) + .map_err(darling::Error::from)?; let field_names: Vec = fields .iter() .map(super::super::field::FieldDef::name_str) @@ -156,6 +158,44 @@ impl EntityDef { .with_span(&input.ident)); } } + if !transitions.is_empty() { + if !attrs.transactions { + return Err(darling::Error::custom( + "transition(...) requires #[entity(transactions)]: transitions run on the transaction adapter" + ) + .with_span(&input.ident)); + } + let status_updatable = fields + .iter() + .any(|f| f.name_str() == "status" && f.in_update() && !f.is_auto()); + if !status_updatable { + return Err(darling::Error::custom( + "transition(...) requires a `status` field marked #[field(update)]" + ) + .with_span(&input.ident)); + } + let default_error = + quote::ToTokens::to_token_stream(&attrs.error).to_string() == "sqlx :: Error"; + if default_error { + return Err(darling::Error::custom( + "transition(...) requires a custom error type implementing From" + ) + .with_span(&input.ident)); + } + for t in &transitions { + for col in &t.sets { + let updatable = fields + .iter() + .any(|f| f.name_str() == *col && f.in_update() && !f.is_auto()); + if !updatable { + return Err(darling::Error::custom(format!( + "transition sets column `{col}` must be a #[field(update)] entity column" + )) + .with_span(&input.ident)); + } + } + } + } let custom_constraints = parse_constraint_attrs(&input.attrs).map_err(darling::Error::from)?; if !custom_constraints.is_empty() && !attrs.typed_constraints { @@ -294,6 +334,7 @@ impl EntityDef { extensions: attrs.migrations.extensions, indexes, joins, + transitions, aggregate_root: attrs.aggregate_root, upsert: attrs.upsert, typed_constraints: attrs.typed_constraints, diff --git a/crates/entity-derive-impl/src/entity/parse/entity/def.rs b/crates/entity-derive-impl/src/entity/parse/entity/def.rs index 56032b2..cd2c859 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity/def.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity/def.rs @@ -66,6 +66,7 @@ use super::{ CompositeIndexDef, ProjectionDef, helpers::HasManyDef, join::JoinDef, + transition::TransitionDef, upsert::UpsertDef }; @@ -241,6 +242,12 @@ pub struct EntityDef { /// Non-empty when the entity generates a `{Entity}View`. pub joins: Vec, + /// State-machine transitions from `#[transition(...)]`. + /// + /// Each entry generates a locking `transition_to_{target}` method + /// on the transaction adapter. + pub transitions: Vec, + /// Enable aggregate root pattern. /// /// When `true`, generates `New{Name}` structs for create-only DTOs, diff --git a/crates/entity-derive-impl/src/entity/parse/entity/transition.rs b/crates/entity-derive-impl/src/entity/parse/entity/transition.rs new file mode 100644 index 0000000..84ea2cf --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/entity/transition.rs @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! State-machine transition declarations from `#[transition(...)]`. +//! +//! ```rust,ignore +//! #[transition(created -> accepted, sets(courier_id, ticket_id))] +//! #[transition(accepted -> in_transit)] +//! #[transition(created | accepted -> cancelled)] +//! ``` +//! +//! Source and target statuses are snake_case idents mapping to +//! PascalCase variants of the entity's `status` field enum. `sets(...)` +//! lists additional entity columns patched together with the status. + +use convert_case::{Case, Casing}; +use syn::{Attribute, Ident}; + +/// One `#[transition(...)]` declaration. +#[derive(Debug, Clone)] +pub struct TransitionDef { + /// Allowed source statuses (snake_case, as written). + pub sources: Vec, + + /// Target status (snake_case, as written). + pub target: String, + + /// Additional entity columns patched by the transition. + pub sets: Vec +} + +impl TransitionDef { + /// PascalCase enum variant for a snake_case status ident. + #[must_use] + pub fn variant(status: &str) -> String { + status.to_case(Case::Pascal) + } + + /// Generated method name: `transition_to_{target}`. + #[must_use] + pub fn method_name(&self) -> String { + format!("transition_to_{}", self.target) + } +} + +/// Parse all `#[transition(...)]` attributes. +/// +/// # Errors +/// +/// Returns a `syn::Error` for malformed declarations: missing `->`, +/// empty sources, or an unknown option. +pub fn parse_transition_attrs(attrs: &[Attribute]) -> syn::Result> { + let mut transitions = Vec::new(); + + for attr in attrs { + if !attr.path().is_ident("transition") { + continue; + } + transitions.push(attr.parse_args_with(parse_transition_body)?); + } + + Ok(transitions) +} + +/// Parse the body of one `#[transition(...)]` attribute. +fn parse_transition_body(input: syn::parse::ParseStream<'_>) -> syn::Result { + let mut sources = Vec::new(); + let first: Ident = input.parse()?; + sources.push(first.to_string()); + while input.peek(syn::Token![|]) { + let _: syn::Token![|] = input.parse()?; + let next: Ident = input.parse()?; + sources.push(next.to_string()); + } + + let _: syn::Token![->] = input.parse()?; + let target: Ident = input.parse()?; + + let mut sets = Vec::new(); + if input.peek(syn::Token![,]) { + let _: syn::Token![,] = input.parse()?; + let sets_kw: Ident = input.parse()?; + if sets_kw != "sets" { + return Err(syn::Error::new( + sets_kw.span(), + "unknown transition option; expected `sets(column, ...)`" + )); + } + let content; + syn::parenthesized!(content in input); + while !content.is_empty() { + let col: Ident = content.parse()?; + sets.push(col.to_string()); + if content.peek(syn::Token![,]) { + let _: syn::Token![,] = content.parse()?; + } + } + if sets.is_empty() { + return Err(syn::Error::new( + sets_kw.span(), + "sets(...) requires at least one column" + )); + } + } + + Ok(TransitionDef { + sources, + target: target.to_string(), + sets + }) +} + +#[cfg(test)] +mod tests { + use quote::quote; + + use super::*; + + fn parse(tokens: proc_macro2::TokenStream) -> syn::Result> { + let input: syn::DeriveInput = syn::parse_quote! { + #tokens + pub struct Parcel { + pub id: uuid::Uuid, + } + }; + parse_transition_attrs(&input.attrs) + } + + #[test] + fn parses_simple_transition() { + let t = parse(quote! { + #[transition(accepted -> in_transit)] + }) + .expect("must parse"); + assert_eq!(t.len(), 1); + assert_eq!(t[0].sources, vec!["accepted"]); + assert_eq!(t[0].target, "in_transit"); + assert!(t[0].sets.is_empty()); + assert_eq!(t[0].method_name(), "transition_to_in_transit"); + } + + #[test] + fn parses_multi_source_and_sets() { + let t = parse(quote! { + #[transition(created | accepted -> cancelled)] + #[transition(created -> accepted, sets(courier_id, ticket_id))] + }) + .expect("must parse"); + assert_eq!(t[0].sources, vec!["created", "accepted"]); + assert_eq!(t[1].sets, vec!["courier_id", "ticket_id"]); + } + + #[test] + fn variant_is_pascal_case() { + assert_eq!(TransitionDef::variant("in_transit"), "InTransit"); + assert_eq!(TransitionDef::variant("created"), "Created"); + } + + #[test] + fn rejects_unknown_option() { + let err = parse(quote! { + #[transition(created -> accepted, with(courier_id))] + }) + .expect_err("unknown option must fail"); + assert!(err.to_string().contains("expected `sets")); + } + + #[test] + fn rejects_empty_sets() { + let err = parse(quote! { + #[transition(created -> accepted, sets())] + }) + .expect_err("empty sets must fail"); + assert!(err.to_string().contains("at least one column")); + } +} diff --git a/crates/entity-derive-impl/src/entity/transaction.rs b/crates/entity-derive-impl/src/entity/transaction.rs index d7560bd..d7e29fb 100644 --- a/crates/entity-derive-impl/src/entity/transaction.rs +++ b/crates/entity-derive-impl/src/entity/transaction.rs @@ -31,7 +31,7 @@ use proc_macro2::TokenStream; use quote::{format_ident, quote}; use super::{ - parse::{EntityDef, SqlLevel}, + parse::{EntityDef, SqlLevel, TransitionDef}, sql::postgres::Context }; use crate::utils::{marker, tracing::instrument}; @@ -221,6 +221,8 @@ fn generate_repo_adapter(entity: &EntityDef) -> TokenStream { } }; + let transition_methods = transition_methods(entity, &ctx, error_type); + let find_span = instrument(&entity_name_str, "tx.find_by_id"); let find_for_update_span = instrument(&entity_name_str, "tx.find_by_id_for_update"); let delete_op = if soft_delete { @@ -292,6 +294,8 @@ fn generate_repo_adapter(entity: &EntityDef) -> TokenStream { #update_method + #(#transition_methods)* + /// Delete an entity within the transaction. #delete_span pub async fn delete( @@ -319,6 +323,130 @@ fn generate_repo_adapter(entity: &EntityDef) -> TokenStream { } } +/// Generate `transition_to_{target}` methods from `#[transition(...)]` +/// declarations. +/// +/// Each method locks the row with `find_by_id_for_update`, verifies the +/// current status is one of the declared sources (typed +/// `entity_core::TransitionError` otherwise), patches `status` plus the +/// declared `sets(...)` columns in one UPDATE and returns the persisted +/// row. `Ok(None)` means the row does not exist. +fn transition_methods( + entity: &EntityDef, + ctx: &Context<'_>, + error_type: &syn::Path +) -> Vec { + if entity.transitions.is_empty() { + return Vec::new(); + } + + let entity_name = ctx.entity_name; + let entity_name_str = entity_name.to_string(); + let row_name = &ctx.row_name; + let table = &ctx.table; + let id_type = ctx.id_type; + let id_name = ctx.id_name; + let status_field = entity + .all_fields() + .iter() + .find(|f| f.name_str() == "status") + .expect("validated at parse time: transitions require a status field"); + let status_type = status_field.ty(); + + entity + .transitions + .iter() + .map(|t| { + let method_name = format_ident!("{}", t.method_name()); + let target_variant = format_ident!("{}", TransitionDef::variant(&t.target)); + let target_str = TransitionDef::variant(&t.target); + let source_variants = t + .sources + .iter() + .map(|s| format_ident!("{}", TransitionDef::variant(s))); + let span = instrument(&entity_name_str, &t.method_name()); + + let set_fields: Vec<_> = t + .sets + .iter() + .map(|col| { + entity + .all_fields() + .iter() + .find(|f| f.name_str() == *col) + .expect("validated at parse time: sets columns are entity fields") + }) + .collect(); + let params = set_fields.iter().map(|f| { + let name = f.name(); + let ty = f.option_inner_type(); + quote! { #name: #ty } + }); + let set_clause: String = std::iter::once("status = $1".to_string()) + .chain( + t.sets + .iter() + .enumerate() + .map(|(i, col)| format!("{col} = ${}", i + 2)) + ) + .collect::>() + .join(", "); + let id_placeholder = t.sets.len() + 2; + let sql = format!( + "UPDATE {table} SET {set_clause} WHERE {id_name} = ${id_placeholder} RETURNING *" + ); + let binds = set_fields.iter().map(|f| { + let name = f.name(); + quote! { .bind(&#name) } + }); + let doc = format!( + "Transition the row to `{target_str}` from {}, patching {}.\n\n\ + Locks the row for the duration of the transaction; returns\n\ + `Ok(None)` when the row does not exist and a typed\n\ + [`entity_core::TransitionError`] when the current status does\n\ + not allow this transition.", + t.sources.join("/"), + if t.sets.is_empty() { + "nothing else".to_string() + } else { + t.sets.join(", ") + } + ); + + quote! { + #[doc = #doc] + #span + pub async fn #method_name( + &mut self, + id: #id_type, + #(#params,)* + ) -> Result, #error_type> { + let Some(current) = self.find_by_id_for_update(id).await? else { + return Ok(None); + }; + #[allow(unreachable_patterns)] + let allowed = matches!(current.status, #(<#status_type>::#source_variants)|*); + if !allowed { + return Err(::entity_core::TransitionError { + entity: #entity_name_str, + from: format!("{:?}", current.status), + to: #target_str + } + .into()); + } + let row: #row_name = sqlx::query_as(#sql) + .bind(<#status_type>::#target_variant) + #(#binds)* + .bind(&id) + .fetch_one(&mut **self.tx) + .await?; + Ok(Some(#entity_name::from(row))) + } + } + }) + .collect() +} + /// Generate the builder extension trait. /// /// Creates an extension trait that adds `with_{entities}()` method to @@ -715,6 +843,33 @@ mod tx_upsert_tests { assert!(!code.contains("ok_or (sqlx :: Error :: RowNotFound) ?")); } + #[test] + fn transitions_generate_locking_methods() { + let entity = parse_entity(quote! { + #[entity(table = "parcels", transactions, error = "crate::AppError")] + #[transition(created -> accepted, sets(courier_id))] + #[transition(created | accepted -> cancelled)] + pub struct Parcel { + #[id] + pub id: uuid::Uuid, + #[field(update)] + pub status: ParcelStatus, + #[field(update)] + pub courier_id: Option, + } + }); + let code = generate(&entity).to_string(); + assert!(code.contains("pub async fn transition_to_accepted")); + assert!(code.contains("pub async fn transition_to_cancelled")); + assert!(code.contains("find_by_id_for_update")); + assert!(code.contains("TransitionError")); + assert!(code.contains( + "UPDATE parcels SET status = $1, courier_id = $2 WHERE id = $3 RETURNING *" + )); + assert!(code.contains("< ParcelStatus > :: Created | < ParcelStatus > :: Accepted")); + assert!(code.contains("courier_id : uuid :: Uuid")); + } + #[test] fn adapter_gains_locking_lookup() { let entity = parse_entity(quote! { diff --git a/crates/entity-derive-impl/src/lib.rs b/crates/entity-derive-impl/src/lib.rs index 6fee393..274f00d 100644 --- a/crates/entity-derive-impl/src/lib.rs +++ b/crates/entity-derive-impl/src/lib.rs @@ -470,7 +470,7 @@ use proc_macro::TokenStream; Entity, attributes( entity, field, id, auto, owner, sort, version, embed, validate, belongs_to, has_many, - projection, filter, command, example, column, map, join + projection, filter, command, example, column, map, join, transition ) )] pub fn derive_entity(input: TokenStream) -> TokenStream { diff --git a/wiki/Transactions-en.md b/wiki/Transactions-en.md index 5607448..3b7718e 100644 --- a/wiki/Transactions-en.md +++ b/wiki/Transactions-en.md @@ -84,6 +84,33 @@ Inside a transaction, you have access to these methods: `Error` above is the entity's configured `error = "..."` type (default `sqlx::Error`) — the same type the pool-backed repository returns. With `typed_constraints`, the adapter's write methods (`create`, `upsert`, `update`, `delete`) resolve violated constraint names to `entity_core::ConstraintError` exactly like the pool implementation, so an operation keeps its typed error behaviour when it moves inside a transaction. +## State-machine transitions + +`#[transition(...)]` declarations generate locking transition methods on the adapter: + +```rust +#[derive(Entity)] +#[entity(table = "parcels", transactions, error = "AppError")] +#[transition(created -> accepted, sets(courier_id, ticket_id))] +#[transition(accepted -> in_transit)] +#[transition(created | accepted -> cancelled)] +pub struct Parcel { + #[id] pub id: Uuid, + #[field(update)] pub status: ParcelStatus, + #[field(update)] pub courier_id: Option, + #[field(update)] pub ticket_id: Option, +} + +let mut tx = pool.begin().await?; +let parcel = ParcelTransactionRepo::new(&mut tx) + .transition_to_accepted(id, courier_id, ticket_id) + .await? + .ok_or(NotFound)?; +tx.commit().await?; +``` + +Each `transition_to_{target}(id, sets...)` locks the row with `SELECT ... FOR UPDATE`, verifies the current status is one of the declared sources, patches `status` plus the `sets(...)` columns in one UPDATE and returns the persisted row. `Ok(None)` means the row does not exist; a disallowed transition returns a typed `entity_core::TransitionError` (your error type must implement `From` — map it to HTTP 409). `sets` parameters take the unwrapped inner type of `Option` columns. Requires `transactions`, a `status` field marked `#[field(update)]` and a custom `error` type — all checked at compile time. + ## Basic Example ```rust