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
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
87 changes: 86 additions & 1 deletion crates/entity-derive-impl/src/entity/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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<TokenStream> = 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
}
}
}

Expand Down
51 changes: 51 additions & 0 deletions crates/entity-derive/tests/cases/pass/update_builders.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// SPDX-FileCopyrightText: 2025-2026 RAprogramm <andrey.rozanov.vl@gmail.com>
// 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<Uuid>,

#[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"));
}
80 changes: 80 additions & 0 deletions crates/entity-derive/tests/postgres.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uuid>
}

#[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;
}
}
10 changes: 10 additions & 0 deletions wiki/Atributos.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions wiki/Attributes-en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions wiki/Атрибуты.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions wiki/属性.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions wiki/속성.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down