Skip to content
Merged

216 #223

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions crates/entity-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TransitionError>`; 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 {}
4 changes: 3 additions & 1 deletion crates/entity-derive-impl/src/entity/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions crates/entity-derive-impl/src/entity/parse/entity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,13 +145,15 @@ mod helpers;
mod index;
mod join;
mod projection;
mod transition;
mod upsert;

pub use attrs::EntityAttrs;
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)]
Expand Down
41 changes: 41 additions & 0 deletions crates/entity-derive-impl/src/entity/parse/entity/constructor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = fields
.iter()
.map(super::super::field::FieldDef::name_str)
Expand All @@ -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<entity_core::TransitionError>"
)
.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 {
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions crates/entity-derive-impl/src/entity/parse/entity/def.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ use super::{
CompositeIndexDef, ProjectionDef,
helpers::HasManyDef,
join::JoinDef,
transition::TransitionDef,
upsert::UpsertDef
};

Expand Down Expand Up @@ -241,6 +242,12 @@ pub struct EntityDef {
/// Non-empty when the entity generates a `{Entity}View`.
pub joins: Vec<JoinDef>,

/// State-machine transitions from `#[transition(...)]`.
///
/// Each entry generates a locking `transition_to_{target}` method
/// on the transaction adapter.
pub transitions: Vec<TransitionDef>,

/// Enable aggregate root pattern.
///
/// When `true`, generates `New{Name}` structs for create-only DTOs,
Expand Down
176 changes: 176 additions & 0 deletions crates/entity-derive-impl/src/entity/parse/entity/transition.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// SPDX-FileCopyrightText: 2025-2026 RAprogramm <andrey.rozanov.vl@gmail.com>
// 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<String>,

/// Target status (snake_case, as written).
pub target: String,

/// Additional entity columns patched by the transition.
pub sets: Vec<String>
}

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<Vec<TransitionDef>> {
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<TransitionDef> {
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<Vec<TransitionDef>> {
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"));
}
}
Loading
Loading