Skip to content
Merged
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
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ tracing-subscriber = "0.3"
| **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)) |
| **CQRS Commands** | Business-oriented command pattern |
| **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 |

Expand Down Expand Up @@ -288,6 +288,32 @@ 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.

### Domain Operations

Columns that must change only through a named operation cannot be
`#[field(update)]` — that would put them in the public patch DTO and in
the upsert SET list. Declare the operation instead:

```rust,ignore
#[derive(Entity)]
#[entity(table = "citizens", commands)]
#[command(VerifyPassport, payload(passport_provider), sets(
passport_verified = "true",
passport_verified_at = "NOW()"
))]
pub struct Citizen { /* ... */ }

let verified = pool.verify_passport(VerifyPassportCitizen {
id,
passport_provider: Some("gov".into()),
}).await?;
```

One UPDATE writes the fixed expressions plus the payload columns and
nothing else. The expressions land in the statement verbatim, like
`#[column(default = "...")]`; the column names are checked against the
entity at compile time.

### Participant Scopes

"Rows where this principal takes part in any role" is an OR over several
Expand Down
9 changes: 6 additions & 3 deletions crates/entity-derive-impl/src/entity/api/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,8 @@ mod tests {
requires_id,
result_type: None,
kind,
security: None
security: None,
sets: Vec::new()
}
}

Expand All @@ -374,7 +375,8 @@ mod tests {
requires_id,
result_type: None,
kind,
security
security,
sets: Vec::new()
}
}

Expand All @@ -389,7 +391,8 @@ mod tests {
requires_id: false,
result_type: Some(result_type),
kind,
security: None
security: None,
sets: Vec::new()
}
}

Expand Down
3 changes: 2 additions & 1 deletion crates/entity-derive-impl/src/entity/api/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,8 @@ mod tests {
requires_id,
result_type: None,
kind,
security: None
security: None,
sets: Vec::new()
}
}

Expand Down
36 changes: 36 additions & 0 deletions crates/entity-derive-impl/src/entity/parse/command/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,42 @@ fn parse_single_command(attr: &Attribute) -> syn::Result<CommandDef> {
}
}
}
"sets" => {
let content;
syn::parenthesized!(content in input);
while !content.is_empty() {
let column: Ident = content.parse()?;
content.parse::<syn::Token![=]>()?;
let expression: syn::LitStr = content.parse()?;
cmd.sets.push((column.to_string(), expression.value()));
if content.peek(syn::Token![,]) {
content.parse::<syn::Token![,]>()?;
}
}
if cmd.sets.is_empty() {
return Err(syn::Error::new(
option_name.span(),
"sets(...) needs at least one `column = \"expression\"` pair"
));
}
cmd.requires_id = true;
cmd.kind = CommandKindHint::Update;
}
"payload" if input.peek(syn::token::Paren) => {
let content;
syn::parenthesized!(content in input);
let fields =
syn::punctuated::Punctuated::<Ident, syn::Token![,]>::parse_terminated(
&content
)?;
if fields.is_empty() {
return Err(syn::Error::new(
option_name.span(),
"payload(...) needs at least one column"
));
}
cmd.source = CommandSource::Fields(fields.into_iter().collect());
}
"payload" => {
let _: syn::Token![=] = input.parse()?;
let payload_lit: syn::LitStr = input.parse()?;
Expand Down
14 changes: 12 additions & 2 deletions crates/entity-derive-impl/src/entity/parse/command/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,16 @@ pub struct CommandDef {
///
/// When set, overrides the entity-level default security.
/// Use `"none"` to make a command public.
pub security: Option<String>
pub security: Option<String>,

/// Columns the command writes with a fixed SQL expression.
///
/// Declared as `sets(column = "expression", ...)`. Non-empty turns
/// the command into a domain operation: the repository gains a
/// method writing exactly these columns plus the payload ones,
/// without the columns having to be `#[field(update)]` and so
/// without them leaking into the public patch DTO.
pub sets: Vec<(String, String)>
}

impl CommandDef {
Expand All @@ -209,7 +218,8 @@ impl CommandDef {
requires_id: false,
result_type: None,
kind: CommandKindHint::default(),
security: None
security: None,
sets: Vec::new()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ use syn::DeriveInput;
use super::{
super::{
ColumnConfig, MapConfig,
command::parse_command_attrs,
command::{CommandDef, parse_command_attrs},
field::{ExposeConfig, FieldDef, FilterConfig, StorageConfig, ValidationConfig},
returning::ReturningMode
},
Expand Down Expand Up @@ -145,6 +145,7 @@ impl EntityDef {
let scopes =
super::scope::parse_scope_attrs(&input.attrs).map_err(darling::Error::from)?;
validate_scopes(&scopes, &fields, input)?;
validate_command_sets(&command_defs, &fields, input)?;
let field_names: Vec<String> = fields
.iter()
.map(super::super::field::FieldDef::name_str)
Expand Down Expand Up @@ -464,6 +465,30 @@ fn validate_sql_names(
Ok(())
}

/// Check that a command's `sets(...)` names real columns.
///
/// The expressions land in the statement verbatim, so a typo in a
/// column name would only surface when the operation runs.
fn validate_command_sets(
commands: &[CommandDef],
fields: &[FieldDef],
input: &DeriveInput
) -> darling::Result<()> {
for command in commands.iter().filter(|c| !c.sets.is_empty()) {
for (column, _) in &command.sets {
if !fields.iter().any(|f| &f.name_str() == column) {
return Err(darling::Error::custom(format!(
"command `{}` writes column `{column}`, which is not a field of this entity",
command.name
))
.with_span(&input.ident));
}
}
}

Ok(())
}

/// Check that every column a scope names exists, and that the OR-ed
/// ones share a type.
///
Expand Down
45 changes: 45 additions & 0 deletions crates/entity-derive-impl/src/entity/repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ pub fn generate(entity: &EntityDef) -> TokenStream {
let upsert_method = generate_upsert_method(entity);
let scoped_methods = generate_scoped_methods(entity, id_type);
let participant_scopes = generate_scope_methods(entity);
let domain_operations = generate_domain_operations(entity);
let relation_methods = generate_relation_methods(entity, id_type);
let projection_methods = generate_projection_methods(entity, id_type);
let soft_delete_methods = generate_soft_delete_methods(entity, id_type);
Expand Down Expand Up @@ -194,11 +195,55 @@ pub fn generate(entity: &EntityDef) -> TokenStream {

#participant_scopes

#domain_operations

#save_method
}
}
}

/// Generate the trait side of every command declaring `sets(...)`.
///
/// A domain operation writes named columns that are deliberately not
/// `#[field(update)]`, so they stay out of the public patch DTO and out
/// of the upsert SET list.
fn generate_domain_operations(entity: &EntityDef) -> TokenStream {
let entity_name = entity.name();
let entity_name_str = entity.name_str();

let methods: Vec<TokenStream> = entity
.command_defs()
.iter()
.filter(|cmd| !cmd.sets.is_empty())
.map(|cmd| {
let method_name = format_ident!("{}", cmd.name.to_string().to_case(Case::Snake));
let command_struct = cmd.struct_name(&entity_name_str);
let written = cmd
.sets
.iter()
.map(|(column, expression)| format!("`{column}` = `{expression}`"))
.collect::<Vec<_>>()
.join(", ");
let doc = format!(
"Apply the `{}` domain operation.\n\nWrites {written} plus the columns carried \
by the command, and nothing else. Returns the updated row, or the row-not-found \
error when the id does not exist.",
cmd.name
);

quote! {
#[doc = #doc]
async fn #method_name(
&self,
command: #command_struct,
) -> Result<#entity_name, Self::Error>;
}
})
.collect();

quote! { #(#methods)* }
}

/// Generate the trait side of every `#[scope(...)]` declaration.
///
/// One method per scope, listing rows where the bound value appears in
Expand Down
3 changes: 3 additions & 0 deletions crates/entity-derive-impl/src/entity/sql/postgres.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ mod bulk;
mod constraints;
mod context;
mod crud;
mod domain_ops;
mod lookup;
mod notify;
mod outbox;
Expand Down Expand Up @@ -127,6 +128,7 @@ pub fn generate(entity: &EntityDef) -> TokenStream {
let soft_delete_impls = ctx.soft_delete_methods();
let scoped_impls = ctx.scoped_methods();
let participant_scope_impls = ctx.scope_methods();
let domain_operation_impls = ctx.domain_operation_methods();
let lookup_impls = ctx.lookup_methods();
let save_impl = ctx.save_method();
let constraint_mapper = ctx.constraint_mapper();
Expand Down Expand Up @@ -163,6 +165,7 @@ pub fn generate(entity: &EntityDef) -> TokenStream {
#soft_delete_impls
#scoped_impls
#participant_scope_impls
#domain_operation_impls
#save_impl
}
}
Expand Down
Loading