diff --git a/README.md b/README.md index 83da95f..4bfed11 100644 --- a/README.md +++ b/README.md @@ -481,7 +481,20 @@ let profile = pool.update(id, patch).await?; ``` In Rust code: `None` = leave, `Some(None)` = SET NULL, -`Some(Some(v))` = SET v. +`Some(Some(v))` = SET v. Chainable setters say the same thing without +the nesting — `set_{field}` for every updatable column, `clear_{field}` +for the nullable ones, `expecting_version` where `#[version]` is +declared: + +```rust,ignore +let patch = UpdateProfileRequest::default() + .set_display_name("Neo".into()) // SET display_name = 'Neo' + .clear_nickname(); // SET nickname = NULL + +let profile = pool.update(id, patch).await?; +``` + +Struct-literal construction keeps working; the setters are additive. ### Bulk Operations diff --git a/crates/entity-derive-impl/src/entity/dto.rs b/crates/entity-derive-impl/src/entity/dto.rs index 3d12f11..8fc9117 100644 --- a/crates/entity-derive-impl/src/entity/dto.rs +++ b/crates/entity-derive-impl/src/entity/dto.rs @@ -37,7 +37,7 @@ //! ``` use proc_macro2::TokenStream; -use quote::quote; +use quote::{format_ident, quote}; use super::parse::{EntityDef, FieldDef}; use crate::utils::marker; @@ -201,6 +201,7 @@ fn generate_update_dto(entity: &EntityDef) -> TokenStream { let extra_derives = dto_extra_derives(); let marker = marker::generated(); + let builders = generate_update_builders(entity, &name); quote! { #marker @@ -211,6 +212,90 @@ fn generate_update_dto(entity: &EntityDef) -> TokenStream { #version_field } + #builders + } +} + +/// Generate chainable setters for the update DTO. +/// +/// A patch built from a struct literal has to spell the wrapping out — +/// `Some(value)` for a plain column and `Some(Some(value))` for a +/// nullable one, where `Some(None)` means "write NULL". The setters say +/// the same thing in the caller's words, and struct-literal +/// construction keeps working unchanged. +fn generate_update_builders(entity: &EntityDef, name: &syn::Ident) -> TokenStream { + let fields = entity.update_fields(); + if fields.is_empty() { + return TokenStream::new(); + } + + let vis = &entity.vis; + let methods: Vec = fields + .iter() + .flat_map(|f| { + let field = f.name(); + let setter = format_ident!("set_{}", f.name_str()); + if f.is_option() { + let inner = f.option_inner_type(); + let clear = format_ident!("clear_{}", f.name_str()); + let set_doc = + format!("Set `{}` to the given value.", f.name_str()); + let clear_doc = format!( + "Write NULL to `{}`.\n\nLeaving the field untouched keeps the stored value; this asks for the column to be cleared.", + f.name_str() + ); + vec![ + quote! { + #[doc = #set_doc] + #[must_use] + #vis fn #setter(mut self, value: #inner) -> Self { + self.#field = Some(Some(value)); + self + } + }, + quote! { + #[doc = #clear_doc] + #[must_use] + #vis fn #clear(mut self) -> Self { + self.#field = Some(None); + self + } + }, + ] + } else { + let ty = f.ty(); + let set_doc = format!("Set `{}` to the given value.", f.name_str()); + vec![quote! { + #[doc = #set_doc] + #[must_use] + #vis fn #setter(mut self, value: #ty) -> Self { + self.#field = Some(value); + self + } + }] + } + }) + .collect(); + + let version_method = entity.version_field().map(|f| { + let ty = f.ty(); + quote! { + /// Record the version the caller observed. + /// + /// The update applies only while the row still carries it. + #[must_use] + #vis fn expecting_version(mut self, version: #ty) -> Self { + self.expected_version = version; + self + } + } + }); + + quote! { + impl #name { + #(#methods)* + #version_method + } } } diff --git a/crates/entity-derive/tests/cases/pass/update_builders.rs b/crates/entity-derive/tests/cases/pass/update_builders.rs new file mode 100644 index 0000000..d982c36 --- /dev/null +++ b/crates/entity-derive/tests/cases/pass/update_builders.rs @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Chainable setters on the update DTO say what a struct literal spells +//! out with nested `Option`s. + +use entity_derive::Entity; +use uuid::Uuid; + +#[derive(Debug, Clone, Entity)] +#[entity(table = "parcels")] +pub struct Parcel { + #[id] + pub id: Uuid, + + #[field(create, update, response)] + pub status: String, + + #[field(create, update, response)] + pub courier_id: Option, + + #[version] + #[field(response)] + #[auto] + pub version: i32, +} + +fn main() { + let courier = Uuid::nil(); + + let built = UpdateParcelRequest::default() + .set_status("accepted".to_owned()) + .set_courier_id(courier) + .expecting_version(3); + + assert_eq!(built.status.as_deref(), Some("accepted")); + assert_eq!(built.courier_id, Some(Some(courier))); + assert_eq!(built.expected_version, 3); + + let cleared = UpdateParcelRequest::default().clear_courier_id(); + assert_eq!(cleared.courier_id, Some(None), "clear_ asks for NULL"); + assert_eq!(cleared.status, None, "an untouched field stays absent"); + + // The struct literal keeps working. + let literal = UpdateParcelRequest { + status: Some("cancelled".to_owned()), + courier_id: None, + expected_version: 1, + }; + assert_eq!(literal.status.as_deref(), Some("cancelled")); +} diff --git a/crates/entity-derive/tests/postgres.rs b/crates/entity-derive/tests/postgres.rs index daa0689..c2e6318 100644 --- a/crates/entity-derive/tests/postgres.rs +++ b/crates/entity-derive/tests/postgres.rs @@ -2635,3 +2635,83 @@ mod commands { db.teardown().await; } } + +/// Update-DTO setters have to produce the same patch a struct literal +/// does, including asking for NULL. +mod update_builders { + use entity_derive::Entity; + use uuid::Uuid; + + use crate::pg; + + #[derive(Debug, Clone, Entity)] + #[entity(table = "shipments_b", migrations)] + pub struct Shipment { + #[id] + pub id: Uuid, + + #[field(create, update, response)] + pub status: String, + + #[field(create, update, response)] + pub courier_id: Option + } + + #[tokio::test] + async fn setters_and_clear_reach_the_row() { + let Some(db) = pg::provision("builders", &[Shipment::MIGRATION_UP]).await else { + return; + }; + let pool = db.pool(); + + let courier = Uuid::now_v7(); + let shipment = pool + .create(CreateShipmentRequest { + status: "created".to_owned(), + courier_id: Some(courier) + }) + .await + .expect("create failed"); + + let patched = pool + .update( + shipment.id, + UpdateShipmentRequest::default().set_status("accepted".to_owned()) + ) + .await + .expect("update failed"); + assert_eq!(patched.status, "accepted"); + assert_eq!( + patched.courier_id, + Some(courier), + "an untouched field must keep its stored value" + ); + + let cleared = pool + .update( + shipment.id, + UpdateShipmentRequest::default().clear_courier_id() + ) + .await + .expect("update failed"); + assert_eq!( + cleared.courier_id, None, + "clear_ must write NULL, not leave the column alone" + ); + assert_eq!( + cleared.status, "accepted", + "clearing one column must not touch another" + ); + + let reassigned = pool + .update( + shipment.id, + UpdateShipmentRequest::default().set_courier_id(courier) + ) + .await + .expect("update failed"); + assert_eq!(reassigned.courier_id, Some(courier)); + + db.teardown().await; + } +} diff --git a/wiki/Atributos.md b/wiki/Atributos.md index 7435da7..f63ed5f 100644 --- a/wiki/Atributos.md +++ b/wiki/Atributos.md @@ -645,6 +645,16 @@ pub struct Post { Las actualizaciones generadas son parches parciales reales: la cláusula SET se construye en tiempo de ejecución con los campos realmente presentes; los omitidos no se tocan. Las columnas anulables usan doble `Option` (`None` = dejar, `Some(None)` = poner NULL, `Some(Some(v))` = poner v) mediante `entity_core::serde_helpers::double_option`. +Los setters encadenables expresan el mismo parche sin el anidamiento: `set_{field}` para cada columna actualizable, `clear_{field}` para las anulables y `expecting_version` cuando hay `#[version]`. + +```rust +let patch = UpdateUserRequest::default() + .set_name("Neo".into()) + .clear_nickname(); +``` + +La construcción con literal de estructura sigue funcionando; los setters son aditivos. + ```rust // {} → nothing changes // {"nickname": null} → nickname = NULL diff --git a/wiki/Attributes-en.md b/wiki/Attributes-en.md index 94e65d4..7a8bcdf 100644 --- a/wiki/Attributes-en.md +++ b/wiki/Attributes-en.md @@ -754,6 +754,16 @@ pub struct Post { Generated updates are true partial patches: the UPDATE SET clause is built at runtime from the fields actually present, so omitted fields stay untouched. Nullable columns use double-`Option` (`None` = leave, `Some(None)` = SET NULL, `Some(Some(v))` = SET v) via `entity_core::serde_helpers::double_option`. +Chainable setters express the same patch without the nesting: `set_{field}` for every updatable column, `clear_{field}` for the nullable ones, and `expecting_version` where `#[version]` is declared. + +```rust +let patch = UpdateUserRequest::default() + .set_name("Neo".into()) + .clear_nickname(); +``` + +Struct-literal construction keeps working; the setters are additive. + ```rust // {} → nothing changes // {"nickname": null} → nickname = NULL diff --git "a/wiki/\320\220\321\202\321\200\320\270\320\261\321\203\321\202\321\213.md" "b/wiki/\320\220\321\202\321\200\320\270\320\261\321\203\321\202\321\213.md" index 3e39c9a..f113c8e 100644 --- "a/wiki/\320\220\321\202\321\200\320\270\320\261\321\203\321\202\321\213.md" +++ "b/wiki/\320\220\321\202\321\200\320\270\320\261\321\203\321\202\321\213.md" @@ -705,6 +705,16 @@ pub struct Post { Генерируемые обновления — настоящие частичные патчи: SET-клауза строится в рантайме из реально присутствующих полей, пропущенные поля не трогаются. Nullable-колонки используют двойной `Option` (`None` = не менять, `Some(None)` = записать NULL, `Some(Some(v))` = записать v) через `entity_core::serde_helpers::double_option`. +Цепочечные сеттеры выражают тот же патч без вложенности: `set_{field}` для каждой обновляемой колонки, `clear_{field}` для nullable и `expecting_version`, если объявлен `#[version]`. + +```rust +let patch = UpdateUserRequest::default() + .set_name("Neo".into()) + .clear_nickname(); +``` + +Конструирование через структурный литерал продолжает работать — сеттеры только дополняют его. + ```rust // {} → nothing changes // {"nickname": null} → nickname = NULL diff --git "a/wiki/\345\261\236\346\200\247.md" "b/wiki/\345\261\236\346\200\247.md" index 3724a0a..b692505 100644 --- "a/wiki/\345\261\236\346\200\247.md" +++ "b/wiki/\345\261\236\346\200\247.md" @@ -644,6 +644,16 @@ pub struct Post { 生成的更新是真正的部分补丁:SET 子句在运行时由实际存在的字段构建,省略的字段保持不变。可空列通过 `entity_core::serde_helpers::double_option` 使用双重 `Option`(`None` = 保留,`Some(None)` = 置 NULL,`Some(Some(v))` = 置 v)。 +链式 setter 无需嵌套即可表达同一个补丁:每个可更新列都有 `set_{field}`,可空列另有 `clear_{field}`,声明了 `#[version]` 时还有 `expecting_version`。 + +```rust +let patch = UpdateUserRequest::default() + .set_name("Neo".into()) + .clear_nickname(); +``` + +结构体字面量的写法继续可用,setter 只是增量补充。 + ```rust // {} → nothing changes // {"nickname": null} → nickname = NULL diff --git "a/wiki/\354\206\215\354\204\261.md" "b/wiki/\354\206\215\354\204\261.md" index 13b98c9..7d41a99 100644 --- "a/wiki/\354\206\215\354\204\261.md" +++ "b/wiki/\354\206\215\354\204\261.md" @@ -645,6 +645,16 @@ pub struct Post { 생성된 업데이트는 진정한 부분 패치입니다. SET 절은 실제로 존재하는 필드로 런타임에 구성되며 생략된 필드는 변경되지 않습니다. Nullable 컬럼은 `entity_core::serde_helpers::double_option`을 통한 이중 `Option`을 사용합니다(`None` = 유지, `Some(None)` = NULL 설정, `Some(Some(v))` = v 설정). +체이닝 가능한 세터는 중첩 없이 같은 패치를 표현합니다. 업데이트 가능한 모든 컬럼에는 `set_{field}`, nullable 컬럼에는 `clear_{field}`, `#[version]`이 선언된 경우 `expecting_version`이 생성됩니다. + +```rust +let patch = UpdateUserRequest::default() + .set_name("Neo".into()) + .clear_nickname(); +``` + +구조체 리터럴 생성은 그대로 동작합니다. 세터는 추가된 것일 뿐입니다. + ```rust // {} → nothing changes // {"nickname": null} → nickname = NULL