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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ tracing-subscriber = "0.3"
| **PATCH Semantics** | Dynamic UPDATE SET; double-`Option` distinguishes "leave" from "set NULL" |
| **Optimistic Locking** | `#[version]` — guarded, auto-incremented version column |
| **Typed Constraint Errors** | `typed_constraints` — violations resolved to `ConstraintError` with field info |
| **OpenAPI Overrides** | `#[schema(...)]` forwarded verbatim to the generated create/response DTOs and joined views |
| **Embedded Value Objects** | `#[embed(prefix, fields(...))]` — structs flattened to prefixed columns |
| **Relations** | `#[belongs_to]`, `#[has_many]` and many-to-many via `through = "junction"` |
| **Ownership Scoping** | `#[owner]` generates `find_by_id_scoped` / `list_by_owner` / `update_scoped` / `delete_scoped` |
Expand Down Expand Up @@ -251,6 +252,7 @@ tracing-subscriber = "0.3"
#[has_many(E, through = "t")] // Many-to-many via junction table
#[projection(Name: fields)] // Partial view
#[scope(name: col_a | col_b)] // list_{name} over an OR group of columns
#[schema(value_type = T)] // OpenAPI type override, forwarded to the generated structs
```

### Transactional Outbox
Expand Down
8 changes: 7 additions & 1 deletion crates/entity-derive-impl/src/entity/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,10 @@ fn generate_create_dto(entity: &EntityDef) -> TokenStream {
let n = f.name();
let t = f.ty();
let garde = garde_attr(f, 0);
let schema = f.schema_attr();
quote! {
#garde
#schema
pub #n: #t
}
});
Expand Down Expand Up @@ -310,7 +312,11 @@ fn generate_response_dto(entity: &EntityDef) -> TokenStream {
let field_defs = fields.iter().map(|f| {
let n = f.name();
let t = f.ty();
quote! { pub #n: #t }
let schema = f.schema_attr();
quote! {
#schema
pub #n: #t
}
});

let extra_derives_api = if cfg!(feature = "api") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,8 @@ fn expand_embed_fields(fields: &mut Vec<FieldDef>) -> darling::Result<()> {
doc: None,
validation: ValidationConfig::default(),
example: None,
map: MapConfig::default()
map: MapConfig::default(),
schema: None
});
}
insertions.push((idx + 1, synthetic));
Expand Down
26 changes: 24 additions & 2 deletions crates/entity-derive-impl/src/entity/parse/field.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ mod example;
mod expose;
mod filter;
pub mod map;
mod schema;
mod storage;
mod validation;

Expand Down Expand Up @@ -214,7 +215,13 @@ pub struct FieldDef {
///
/// Parsed from `#[map(...)]` attributes for transforming fields
/// in the `From<Row> for Entity` implementation.
pub map: MapConfig
pub map: MapConfig,

/// `OpenAPI` schema override carried from `#[schema(...)]`.
///
/// The token list utoipa is given verbatim on every generated
/// struct that derives `utoipa::ToSchema`.
pub schema: Option<proc_macro2::TokenStream>
}

impl FieldDef {
Expand All @@ -234,6 +241,7 @@ impl FieldDef {
let doc = extract_doc_comments(&field.attrs);
let validation = validation::parse_validation_attrs(&field.attrs);
let example = example::parse_example_attr(&field.attrs);
let schema = schema::parse_schema_attr(&field.attrs);

let mut sortable = false;
let mut embed: Option<EmbedConfig> = None;
Expand Down Expand Up @@ -286,10 +294,24 @@ impl FieldDef {
doc,
validation,
example,
map
map,
schema
})
}

/// The `#[schema(...)]` attribute to place on a generated field.
///
/// Empty unless the entity declared one and the `api` feature is on:
/// with it off nothing generated derives `utoipa::ToSchema`, and the
/// attribute would land on a struct that cannot interpret it.
#[must_use]
pub fn schema_attr(&self) -> proc_macro2::TokenStream {
match &self.schema {
Some(tokens) if cfg!(feature = "api") => quote::quote! { #[schema(#tokens)] },
_ => proc_macro2::TokenStream::new()
}
}

/// Get the field name as an identifier.
#[must_use]
pub const fn name(&self) -> &Ident {
Expand Down
86 changes: 86 additions & 0 deletions crates/entity-derive-impl/src/entity/parse/field/schema.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// SPDX-FileCopyrightText: 2025-2026 RAprogramm <andrey.rozanov.vl@gmail.com>
// SPDX-License-Identifier: MIT

//! `OpenAPI` schema overrides carried from the entity to the generated
//! structs.
//!
//! A column whose Rust type says little to `OpenAPI` — `serde_json::Value`
//! for a JSONB column, a newtype over a primitive — documents as a
//! free-form object. utoipa fixes that with `#[schema(...)]` on the
//! field; this module carries such an attribute from the entity onto
//! every generated struct that derives `utoipa::ToSchema`.
//!
//! ```rust,ignore
//! #[field(create, response)]
//! #[schema(value_type = Option<SizeCm>)]
//! pub size_cm: Option<serde_json::Value>,
//! ```
//!
//! The tokens are forwarded verbatim: what utoipa accepts inside
//! `#[schema(...)]` is what works here, and nothing is validated twice.
//! With the `api` feature off no generated struct derives `ToSchema`, so
//! the attribute is dropped rather than emitted onto a struct that
//! cannot interpret it.

use proc_macro2::TokenStream;
use syn::{Attribute, Meta};

/// Extract the token list of a field's `#[schema(...)]` attribute.
///
/// Returns `None` when the field carries no such attribute, or carries
/// one in a form utoipa does not use (`#[schema]`, `#[schema = ...]`).
pub fn parse_schema_attr(attrs: &[Attribute]) -> Option<TokenStream> {
attrs.iter().find_map(|attr| match &attr.meta {
Meta::List(list) if list.path.is_ident("schema") => Some(list.tokens.clone()),
_ => None
})
}

#[cfg(test)]
mod tests {
use quote::quote;
use syn::{Field, parse_quote};

use super::parse_schema_attr;

fn field(tokens: TokenStreamAlias) -> Field {
Field::parse_named.parse2(tokens).expect("field parses")
}

type TokenStreamAlias = proc_macro2::TokenStream;
use syn::parse::Parser as _;

#[test]
fn extracts_the_token_list() {
let f = field(quote! {
#[schema(value_type = Option<SizeCm>)]
pub size_cm: Option<serde_json::Value>
});

let tokens = parse_schema_attr(&f.attrs).expect("attribute found");

assert_eq!(tokens.to_string(), "value_type = Option < SizeCm >");
}

#[test]
fn absent_attribute_yields_none() {
let f: Field = parse_quote! { pub name: String };

assert!(parse_schema_attr(&f.attrs).is_none());
}

#[test]
fn path_and_name_value_forms_are_ignored() {
let bare = field(quote! {
#[schema]
pub name: String
});
let named = field(quote! {
#[schema = "text"]
pub name: String
});

assert!(parse_schema_attr(&bare.attrs).is_none());
assert!(parse_schema_attr(&named.attrs).is_none());
}
}
6 changes: 5 additions & 1 deletion crates/entity-derive-impl/src/entity/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@ pub fn generate(entity: &EntityDef) -> TokenStream {
.map(|f| {
let name = f.name();
let ty = f.ty();
quote! { pub #name: #ty }
let schema = f.schema_attr();
quote! {
#schema
pub #name: #ty
}
})
.collect();
let join_fields: Vec<TokenStream> = entity
Expand Down
2 changes: 1 addition & 1 deletion crates/entity-derive-impl/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ use proc_macro::TokenStream;
Entity,
attributes(
entity, field, id, auto, owner, sort, version, embed, validate, belongs_to, has_many,
projection, filter, command, example, column, map, join, transition, scope
projection, filter, command, example, column, map, join, transition, scope, schema
)
)]
pub fn derive_entity(input: TokenStream) -> TokenStream {
Expand Down
41 changes: 41 additions & 0 deletions crates/entity-derive/tests/cases/pass/schema_override.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// SPDX-FileCopyrightText: 2025-2026 RAprogramm <andrey.rozanov.vl@gmail.com>
// SPDX-License-Identifier: MIT

use entity_derive::Entity;
use uuid::Uuid;

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
pub struct SizeCm {
pub length: i32,
pub width: i32,
pub height: i32,
}

#[derive(Debug, Clone, Entity, serde::Serialize, serde::Deserialize)]
#[join(airports as origin, on = origin_iata = iata, fields(
lat as origin_lat: f64
))]
#[entity(table = "parcels")]
pub struct Parcel {
#[id]
pub id: Uuid,

#[field(create, response)]
pub origin_iata: String,

/// A JSONB column documents as a free-form object without the
/// override; with it the schema names the shape it really carries.
#[field(create, response)]
#[schema(value_type = Option<SizeCm>)]
pub size_cm: Option<serde_json::Value>,
}

fn assert_schema<T: utoipa::ToSchema>() {}

fn main() {
assert_schema::<CreateParcelRequest>();
assert_schema::<ParcelResponse>();
assert_schema::<ParcelView>();

let _size: Option<serde_json::Value> = None::<serde_json::Value>;
}
21 changes: 21 additions & 0 deletions wiki/Atributos.md
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,26 @@ pub struct User { /* ... */ }

**Generado:** Método `find_posts()` en repositorio.

### `#[schema(...)]`

Sobrescribe cómo se documenta un campo en OpenAPI. La lista de tokens se
entrega a utoipa tal cual y aparece en cada estructura generada que
deriva `ToSchema`: los DTO de creación y respuesta y la vista unida.

Una columna JSONB tipada como `serde_json::Value` se documenta como un
objeto libre; la sobrescritura nombra la forma que realmente lleva:

```rust
#[field(create, response)]
#[schema(value_type = Option<SizeCm>)]
pub size_cm: Option<serde_json::Value>,
```

Sin la característica `api` nada generado deriva `ToSchema` y el atributo
se descarta. Los DTO de actualización no lo llevan: la macro reescribe
los tipos de sus campos para la semántica PATCH, así que un `value_type`
declarado contradiría el tipo generado.

### `#[projection(Name: fields)]`

Genera estructura de vista parcial (nivel de entidad).
Expand Down Expand Up @@ -654,6 +674,7 @@ pub struct Post {
| Usar comandos de dominio | `commands` en entidad + `#[command(...)]` |
| Definir relación | `#[belongs_to(Entity)]` o `#[has_many(Entity)]` |
| Vista parcial de entidad | `#[projection(Name: fields)]` |
| Sobrescribir el tipo OpenAPI de un campo | `#[schema(value_type = T)]` |

### DTO de actualización: semántica PATCH

Expand Down
22 changes: 22 additions & 0 deletions wiki/Attributes-en.md
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,27 @@ pub struct User { /* ... */ }

**Generated:** `find_posts()` method in repository.

### `#[schema(...)]`

Overrides how a field documents itself in OpenAPI. The token list is
handed to utoipa verbatim and lands on every generated struct that
derives `ToSchema` — the create and response DTOs and the joined view.

A JSONB column typed as `serde_json::Value` documents as a free-form
object; the override names the shape it actually carries:

```rust
#[field(create, response)]
#[schema(value_type = Option<SizeCm>)]
pub size_cm: Option<serde_json::Value>,
```

Without the `api` feature nothing generated derives `ToSchema`, and the
attribute is dropped rather than emitted onto a struct that cannot
interpret it. Update DTOs do not carry it: the macro rewrites their
field types for PATCH semantics, so a declared `value_type` would
contradict the type actually generated.

### `#[projection(Name: fields)]`

Generate partial view struct (entity-level).
Expand Down Expand Up @@ -763,6 +784,7 @@ pub struct Post {
| Use multi-entity transactions | `transactions` on entity |
| Define relationship | `#[belongs_to(Entity)]` or `#[has_many(Entity)]` |
| Partial entity view | `#[projection(Name: fields)]` |
| Override an OpenAPI field type | `#[schema(value_type = T)]` |

### Update DTOs: PATCH semantics

Expand Down
21 changes: 21 additions & 0 deletions wiki/Атрибуты.md
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,26 @@ pub struct User { /* ... */ }

**Генерируется:** метод `find_posts()` в репозитории.

### `#[schema(...)]`

Переопределяет описание поля в OpenAPI. Список токенов передаётся в
utoipa без изменений и попадает на каждую сгенерированную структуру с
`ToSchema` — DTO создания и ответа, а также объединённое представление.

Колонка JSONB с типом `serde_json::Value` документируется как объект
произвольной формы; переопределение называет реальную форму:

```rust
#[field(create, response)]
#[schema(value_type = Option<SizeCm>)]
pub size_cm: Option<serde_json::Value>,
```

Без возможности `api` ни одна сгенерированная структура не выводит
`ToSchema`, поэтому атрибут отбрасывается. DTO обновления его не несут:
макрос переписывает типы их полей ради семантики PATCH, и объявленный
`value_type` противоречил бы сгенерированному типу.

### `#[projection(Name: fields)]`

Генерирует структуру частичного представления (на уровне сущности).
Expand Down Expand Up @@ -714,6 +734,7 @@ pub struct Post {
| Использовать транзакции с несколькими сущностями | `transactions` на сущности |
| Определить связь | `#[belongs_to(Entity)]` или `#[has_many(Entity)]` |
| Частичное представление сущности | `#[projection(Name: fields)]` |
| Переопределить тип поля в OpenAPI | `#[schema(value_type = T)]` |

### Update DTO: семантика PATCH

Expand Down
19 changes: 19 additions & 0 deletions wiki/属性.md
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,24 @@ pub struct User { /* ... */ }

**生成:** repository中的 `find_posts()` 方法。

### `#[schema(...)]`

覆盖字段在 OpenAPI 中的描述方式。词元列表原样交给 utoipa,出现在每个派生
`ToSchema` 的生成结构上——创建与响应 DTO,以及联接视图。

类型为 `serde_json::Value` 的 JSONB 列会被记录为自由形式对象;覆盖后可以
写出它实际承载的结构:

```rust
#[field(create, response)]
#[schema(value_type = Option<SizeCm>)]
pub size_cm: Option<serde_json::Value>,
```

未启用 `api` 特性时,任何生成结构都不派生 `ToSchema`,该属性会被丢弃。
更新 DTO 不携带它:宏为 PATCH 语义重写了字段类型,声明的 `value_type`
会与实际生成的类型矛盾。

### `#[projection(Name: fields)]`

生成部分视图结构(实体级)。
Expand Down Expand Up @@ -653,6 +671,7 @@ pub struct Post {
| 使用领域命令 | 实体上 `commands` + `#[command(...)]` |
| 定义关系 | `#[belongs_to(Entity)]` 或 `#[has_many(Entity)]` |
| 实体部分视图 | `#[projection(Name: fields)]` |
| 覆盖字段的 OpenAPI 类型 | `#[schema(value_type = T)]` |

### 更新 DTO:PATCH 语义

Expand Down
Loading
Loading