From 31e6d6cd2a2bbb44d2d1b069f9664f018e259ed0 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Sun, 26 Jul 2026 19:03:21 +0700 Subject: [PATCH] #217 feat: domain operations writing declared columns --- README.md | 28 ++++- .../src/entity/api/handlers.rs | 9 +- .../src/entity/api/router.rs | 3 +- .../src/entity/parse/command/parser.rs | 36 ++++++ .../src/entity/parse/command/types.rs | 14 ++- .../src/entity/parse/entity/constructor.rs | 27 +++- .../src/entity/repository.rs | 45 +++++++ .../src/entity/sql/postgres.rs | 3 + .../src/entity/sql/postgres/domain_ops.rs | 115 ++++++++++++++++++ .../cases/fail/command_sets_unknown_column.rs | 21 ++++ .../fail/command_sets_unknown_column.stderr | 5 + .../tests/cases/pass/command_sets.rs | 52 ++++++++ crates/entity-derive/tests/postgres.rs | 88 ++++++++++++++ wiki/Comandos.md | 15 +++ wiki/Commands-en.md | 15 +++ ...20\274\320\260\320\275\320\264\321\213.md" | 15 +++ "wiki/\345\221\275\344\273\244.md" | 15 +++ .../\354\273\244\353\247\250\353\223\234.md" | 15 +++ 18 files changed, 513 insertions(+), 8 deletions(-) create mode 100644 crates/entity-derive-impl/src/entity/sql/postgres/domain_ops.rs create mode 100644 crates/entity-derive/tests/cases/fail/command_sets_unknown_column.rs create mode 100644 crates/entity-derive/tests/cases/fail/command_sets_unknown_column.stderr create mode 100644 crates/entity-derive/tests/cases/pass/command_sets.rs diff --git a/README.md b/README.md index 6fc92f8..821d504 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 diff --git a/crates/entity-derive-impl/src/entity/api/handlers.rs b/crates/entity-derive-impl/src/entity/api/handlers.rs index f50c5d7..f1361cc 100644 --- a/crates/entity-derive-impl/src/entity/api/handlers.rs +++ b/crates/entity-derive-impl/src/entity/api/handlers.rs @@ -358,7 +358,8 @@ mod tests { requires_id, result_type: None, kind, - security: None + security: None, + sets: Vec::new() } } @@ -374,7 +375,8 @@ mod tests { requires_id, result_type: None, kind, - security + security, + sets: Vec::new() } } @@ -389,7 +391,8 @@ mod tests { requires_id: false, result_type: Some(result_type), kind, - security: None + security: None, + sets: Vec::new() } } diff --git a/crates/entity-derive-impl/src/entity/api/router.rs b/crates/entity-derive-impl/src/entity/api/router.rs index ba41cdf..bf67fcb 100644 --- a/crates/entity-derive-impl/src/entity/api/router.rs +++ b/crates/entity-derive-impl/src/entity/api/router.rs @@ -278,7 +278,8 @@ mod tests { requires_id, result_type: None, kind, - security: None + security: None, + sets: Vec::new() } } diff --git a/crates/entity-derive-impl/src/entity/parse/command/parser.rs b/crates/entity-derive-impl/src/entity/parse/command/parser.rs index 2f12fa6..d86ac9f 100644 --- a/crates/entity-derive-impl/src/entity/parse/command/parser.rs +++ b/crates/entity-derive-impl/src/entity/parse/command/parser.rs @@ -205,6 +205,42 @@ fn parse_single_command(attr: &Attribute) -> syn::Result { } } } + "sets" => { + let content; + syn::parenthesized!(content in input); + while !content.is_empty() { + let column: Ident = content.parse()?; + content.parse::()?; + let expression: syn::LitStr = content.parse()?; + cmd.sets.push((column.to_string(), expression.value())); + if content.peek(syn::Token![,]) { + content.parse::()?; + } + } + 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::::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()?; diff --git a/crates/entity-derive-impl/src/entity/parse/command/types.rs b/crates/entity-derive-impl/src/entity/parse/command/types.rs index 5acb69d..82e25c7 100644 --- a/crates/entity-derive-impl/src/entity/parse/command/types.rs +++ b/crates/entity-derive-impl/src/entity/parse/command/types.rs @@ -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 + pub security: Option, + + /// 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 { @@ -209,7 +218,8 @@ impl CommandDef { requires_id: false, result_type: None, kind: CommandKindHint::default(), - security: None + security: None, + sets: Vec::new() } } 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 ca3bbcb..1d08dc6 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity/constructor.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity/constructor.rs @@ -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 }, @@ -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 = fields .iter() .map(super::super::field::FieldDef::name_str) @@ -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. /// diff --git a/crates/entity-derive-impl/src/entity/repository.rs b/crates/entity-derive-impl/src/entity/repository.rs index 88f7751..b9677e9 100644 --- a/crates/entity-derive-impl/src/entity/repository.rs +++ b/crates/entity-derive-impl/src/entity/repository.rs @@ -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); @@ -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 = 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::>() + .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 diff --git a/crates/entity-derive-impl/src/entity/sql/postgres.rs b/crates/entity-derive-impl/src/entity/sql/postgres.rs index 38ff707..3032ec1 100644 --- a/crates/entity-derive-impl/src/entity/sql/postgres.rs +++ b/crates/entity-derive-impl/src/entity/sql/postgres.rs @@ -65,6 +65,7 @@ mod bulk; mod constraints; mod context; mod crud; +mod domain_ops; mod lookup; mod notify; mod outbox; @@ -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(); @@ -163,6 +165,7 @@ pub fn generate(entity: &EntityDef) -> TokenStream { #soft_delete_impls #scoped_impls #participant_scope_impls + #domain_operation_impls #save_impl } } diff --git a/crates/entity-derive-impl/src/entity/sql/postgres/domain_ops.rs b/crates/entity-derive-impl/src/entity/sql/postgres/domain_ops.rs new file mode 100644 index 0000000..22dad52 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/sql/postgres/domain_ops.rs @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Domain-operation generators for `PostgreSQL`. +//! +//! A command declaring `sets(...)` writes named columns that are +//! deliberately not `#[field(update)]` — keeping them out of the public +//! patch DTO and out of the upsert SET list, which is the whole point +//! of declaring them here instead: +//! +//! ```rust,ignore +//! #[command(VerifyPassport, payload(passport_provider), +//! sets(passport_verified = "true", passport_verified_at = "NOW()"))] +//! ``` +//! +//! ```sql +//! UPDATE users SET passport_verified = true, passport_verified_at = NOW(), +//! passport_provider = $1 +//! WHERE id = $2 RETURNING ... +//! ``` +//! +//! The expressions are written by the developer and land in the +//! statement verbatim, exactly like `#[column(default = "...")]`. + +use convert_case::{Case, Casing}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +use super::context::Context; +use crate::{ + entity::parse::{CommandDef, CommandSource}, + utils::tracing::instrument +}; + +impl Context<'_> { + /// Generate every declared domain operation. + /// + /// # Returns + /// + /// Empty `TokenStream` when no command declares `sets(...)`. + pub fn domain_operation_methods(&self) -> TokenStream { + let methods: Vec = self + .entity + .command_defs() + .iter() + .filter(|cmd| !cmd.sets.is_empty()) + .map(|cmd| self.domain_operation(cmd)) + .collect(); + + quote! { #(#methods)* } + } + + /// Generate one domain-operation implementation. + fn domain_operation(&self, cmd: &CommandDef) -> TokenStream { + let Self { + entity_name, + row_name, + table, + columns_str, + id_name, + soft_delete, + .. + } = self; + + let method_name = format_ident!("{}", cmd.name.to_string().to_case(Case::Snake)); + let command_struct = cmd.struct_name(&self.entity.name_str()); + + let payload: Vec = match &cmd.source { + CommandSource::Fields(fields) => fields.clone(), + _ => Vec::new() + }; + + let mut assignments: Vec = cmd + .sets + .iter() + .map(|(column, expression)| format!("{column} = {expression}")) + .collect(); + for (index, field) in payload.iter().enumerate() { + assignments.push(format!("{field} = ${}", index + 1)); + } + + let id_placeholder = payload.len() + 1; + let deleted_filter = if *soft_delete { + " AND deleted_at IS NULL" + } else { + "" + }; + let sql = format!( + "UPDATE {table} SET {} WHERE {id_name} = ${id_placeholder}{deleted_filter} \ + RETURNING {columns_str}", + assignments.join(", ") + ); + + let binds = payload + .iter() + .map(|field| quote! { .bind(&command.#field) }); + let span = instrument(&entity_name.to_string(), &method_name.to_string()); + + quote! { + #span + async fn #method_name( + &self, + command: #command_struct, + ) -> Result<#entity_name, Self::Error> { + let row: #row_name = sqlx::query_as(#sql) + #(#binds)* + .bind(&command.id) + .fetch_optional(self) + .await? + .ok_or_else(|| sqlx::Error::RowNotFound)?; + Ok(#entity_name::from(row)) + } + } + } +} diff --git a/crates/entity-derive/tests/cases/fail/command_sets_unknown_column.rs b/crates/entity-derive/tests/cases/fail/command_sets_unknown_column.rs new file mode 100644 index 0000000..1faacf9 --- /dev/null +++ b/crates/entity-derive/tests/cases/fail/command_sets_unknown_column.rs @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! A domain operation writing a column the entity does not have is +//! rejected at the declaration. + +use entity_derive::Entity; +use uuid::Uuid; + +#[derive(Entity)] +#[entity(table = "users", commands)] +#[command(VerifyPassport, sets(passport_checked = "true"))] +pub struct User { + #[id] + pub id: Uuid, + + #[field(response)] + pub passport_verified: bool, +} + +fn main() {} diff --git a/crates/entity-derive/tests/cases/fail/command_sets_unknown_column.stderr b/crates/entity-derive/tests/cases/fail/command_sets_unknown_column.stderr new file mode 100644 index 0000000..d4e535f --- /dev/null +++ b/crates/entity-derive/tests/cases/fail/command_sets_unknown_column.stderr @@ -0,0 +1,5 @@ +error: command `VerifyPassport` writes column `passport_checked`, which is not a field of this entity + --> tests/cases/fail/command_sets_unknown_column.rs:13:12 + | +13 | pub struct User { + | ^^^^ diff --git a/crates/entity-derive/tests/cases/pass/command_sets.rs b/crates/entity-derive/tests/cases/pass/command_sets.rs new file mode 100644 index 0000000..82ed325 --- /dev/null +++ b/crates/entity-derive/tests/cases/pass/command_sets.rs @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! A command declaring `sets(...)` writes columns that stay out of the +//! public patch DTO. + +use chrono::{DateTime, Utc}; +use entity_derive::Entity; +use uuid::Uuid; + +#[derive(Debug, Clone, Entity)] +#[entity(table = "users", commands)] +#[command(VerifyPassport, payload(passport_provider), sets( + passport_verified = "true", + passport_verified_at = "NOW()" +))] +pub struct User { + #[id] + pub id: Uuid, + + #[field(create, update, response)] + pub name: String, + + #[field(response)] + pub passport_verified: bool, + + #[field(response)] + pub passport_provider: Option, + + #[field(response)] + pub passport_verified_at: Option>, +} + +async fn exercise(pool: sqlx::PgPool, id: Uuid) -> Result<(), sqlx::Error> { + let user: User = pool + .verify_passport(VerifyPassportUser { + id, + passport_provider: Some("gov".into()), + }) + .await?; + let _ = user; + Ok(()) +} + +fn main() { + // The patch DTO carries only the update-marked column. + let patch = UpdateUserRequest { + name: Some("Ada".into()), + }; + assert_eq!(patch.name.as_deref(), Some("Ada")); + let _ = exercise; +} diff --git a/crates/entity-derive/tests/postgres.rs b/crates/entity-derive/tests/postgres.rs index 163cee9..7d434c9 100644 --- a/crates/entity-derive/tests/postgres.rs +++ b/crates/entity-derive/tests/postgres.rs @@ -2815,3 +2815,91 @@ mod scopes { db.teardown().await; } } + +/// Domain operations write named columns that the public patch DTO +/// deliberately does not carry. +mod domain_operations { + use chrono::{DateTime, Utc}; + use entity_derive::Entity; + use uuid::Uuid; + + use crate::pg; + + #[derive(Debug, Clone, Entity)] + #[entity(table = "citizens", migrations, commands)] + #[command( + VerifyPassport, + payload(passport_provider), + sets(passport_verified = "true", passport_verified_at = "NOW()") + )] + pub struct Citizen { + #[id] + pub id: Uuid, + + #[field(create, update, response)] + pub name: String, + + #[field(response)] + #[column(default = "false")] + pub passport_verified: bool, + + #[field(response)] + pub passport_provider: Option, + + #[field(response)] + pub passport_verified_at: Option> + } + + #[tokio::test] + async fn the_operation_writes_exactly_its_columns() { + let Some(db) = pg::provision("domainop", &[Citizen::MIGRATION_UP]).await else { + return; + }; + let pool = db.pool(); + + let citizen = pool + .create(CreateCitizenRequest { + name: "Ada".to_owned() + }) + .await + .expect("create failed"); + assert!(!citizen.passport_verified); + assert!(citizen.passport_verified_at.is_none()); + + let verified = pool + .verify_passport(VerifyPassportCitizen { + id: citizen.id, + passport_provider: Some("gov".to_owned()) + }) + .await + .expect("the domain operation must apply"); + + assert!( + verified.passport_verified, + "the fixed expression must apply" + ); + assert_eq!( + verified.passport_provider.as_deref(), + Some("gov"), + "the payload column must be bound" + ); + assert!( + verified.passport_verified_at.is_some(), + "the second fixed expression must apply too" + ); + assert_eq!( + verified.name, "Ada", + "a column the operation does not name must stay untouched" + ); + + let missing = pool + .verify_passport(VerifyPassportCitizen { + id: Uuid::now_v7(), + passport_provider: None + }) + .await; + assert!(missing.is_err(), "an unknown id must not report success"); + + db.teardown().await; + } +} diff --git a/wiki/Comandos.md b/wiki/Comandos.md index 80b8d93..1c43c80 100644 --- a/wiki/Comandos.md +++ b/wiki/Comandos.md @@ -141,6 +141,21 @@ Usa solo campos especificados (añade `requires_id` automáticamente): // Generado: UpdateProfileUser { id, name, bio, avatar } ``` +### Operación de Dominio + +Un comando con `sets(...)` escribe columnas nombradas directamente, sin que tengan que ser `#[field(update)]`, de modo que no aparecen ni en el DTO de parche público ni en la lista SET del upsert: + +```rust +#[command(VerifyPassport, payload(passport_provider), sets( + passport_verified = "true", + passport_verified_at = "NOW()" +))] +// Genera: VerifyPassportUser { id, passport_provider } +// pool.verify_passport(command) -> User +``` + +Un solo UPDATE escribe las expresiones fijas más las columnas del payload y nada más. Las expresiones llegan a la sentencia tal cual, como en `#[column(default = "...")]`; los nombres de columna se verifican contra la entidad en compilación. + ### Comando Solo ID Añade solo el campo ID: diff --git a/wiki/Commands-en.md b/wiki/Commands-en.md index cca41c0..35cd20f 100644 --- a/wiki/Commands-en.md +++ b/wiki/Commands-en.md @@ -141,6 +141,21 @@ Uses only specified fields (adds `requires_id` automatically): // Generated: UpdateProfileUser { id, name, bio, avatar } ``` +### Domain Operation + +A command declaring `sets(...)` writes named columns directly, without them being `#[field(update)]` — so they stay out of the public patch DTO and out of the upsert SET list: + +```rust +#[command(VerifyPassport, payload(passport_provider), sets( + passport_verified = "true", + passport_verified_at = "NOW()" +))] +// Generated: VerifyPassportUser { id, passport_provider } +// pool.verify_passport(command) -> User +``` + +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. + ### ID-Only Command Adds only the ID field: diff --git "a/wiki/\320\232\320\276\320\274\320\260\320\275\320\264\321\213.md" "b/wiki/\320\232\320\276\320\274\320\260\320\275\320\264\321\213.md" index 5631702..37b9af0 100644 --- "a/wiki/\320\232\320\276\320\274\320\260\320\275\320\264\321\213.md" +++ "b/wiki/\320\232\320\276\320\274\320\260\320\275\320\264\321\213.md" @@ -141,6 +141,21 @@ pub trait UserCommandHandler: Send + Sync { // Генерируется: UpdateProfileUser { id, name, bio, avatar } ``` +### Доменная операция + +Команда с `sets(...)` пишет названные колонки напрямую, и им не нужно быть `#[field(update)]` — то есть они не попадают ни в публичный patch-DTO, ни в SET-список апсерта: + +```rust +#[command(VerifyPassport, payload(passport_provider), sets( + passport_verified = "true", + passport_verified_at = "NOW()" +))] +// Генерируется: VerifyPassportUser { id, passport_provider } +// pool.verify_passport(command) -> User +``` + +Один UPDATE пишет фиксированные выражения плюс колонки из payload и ничего больше. Выражения попадают в запрос дословно, как в `#[column(default = "...")]`; имена колонок проверяются по сущности на компиляции. + ### Команда только с ID Добавляет только поле ID: diff --git "a/wiki/\345\221\275\344\273\244.md" "b/wiki/\345\221\275\344\273\244.md" index 725d870..18eba8f 100644 --- "a/wiki/\345\221\275\344\273\244.md" +++ "b/wiki/\345\221\275\344\273\244.md" @@ -141,6 +141,21 @@ pub trait UserCommandHandler: Send + Sync { // 生成:UpdateProfileUser { id, name, bio, avatar } ``` +### 领域操作 + +声明了 `sets(...)` 的命令直接写入指定的列,这些列无需标记 `#[field(update)]`,因此既不会出现在公开的补丁 DTO 中,也不会进入 upsert 的 SET 列表: + +```rust +#[command(VerifyPassport, payload(passport_provider), sets( + passport_verified = "true", + passport_verified_at = "NOW()" +))] +// 生成:VerifyPassportUser { id, passport_provider } +// pool.verify_passport(command) -> User +``` + +一条 UPDATE 只写入固定表达式和 payload 列,别的都不动。表达式与 `#[column(default = "...")]` 一样原样进入语句;列名在编译期对照实体校验。 + ### 仅ID命令 只添加ID字段: diff --git "a/wiki/\354\273\244\353\247\250\353\223\234.md" "b/wiki/\354\273\244\353\247\250\353\223\234.md" index 7755e2c..af2367c 100644 --- "a/wiki/\354\273\244\353\247\250\353\223\234.md" +++ "b/wiki/\354\273\244\353\247\250\353\223\234.md" @@ -141,6 +141,21 @@ pub trait UserCommandHandler: Send + Sync { // 생성됨: UpdateProfileUser { id, name, bio, avatar } ``` +### 도메인 오퍼레이션 + +`sets(...)`를 선언한 커맨드는 지정한 컬럼을 직접 씁니다. 해당 컬럼이 `#[field(update)]`일 필요가 없으므로 공개 패치 DTO에도, upsert의 SET 목록에도 들어가지 않습니다: + +```rust +#[command(VerifyPassport, payload(passport_provider), sets( + passport_verified = "true", + passport_verified_at = "NOW()" +))] +// 생성: VerifyPassportUser { id, passport_provider } +// pool.verify_passport(command) -> User +``` + +하나의 UPDATE가 고정 표현식과 페이로드 컬럼만 씁니다. 표현식은 `#[column(default = "...")]`과 마찬가지로 그대로 문장에 들어가며, 컬럼 이름은 컴파일 타임에 엔티티와 대조됩니다. + ### ID만 있는 커맨드 ID 필드만 추가합니다: