diff --git a/README.md b/README.md index 68d1804..0a3bd69 100644 --- a/README.md +++ b/README.md @@ -372,8 +372,22 @@ match pool.create(dto).await { ``` Covers unique columns, `belongs_to` foreign keys, column checks and -`unique_index` names. The custom `error` type must implement -`From`; without the flag behavior is unchanged. +`unique_index` names. Constraints the macro cannot infer — foreign keys +over natural keys, custom-named CHECKs, indexes from hand-written +migrations — are declared explicitly and take precedence over derived +entries with the same name: + +```rust,ignore +#[entity( + table = "orders", + typed_constraints, + constraint(name = "orders_currency_fkey", kind = "foreign_key", field = "currency"), + constraint(name = "orders_window_check", kind = "check"), +)] +``` + +The custom `error` type must implement `From`; without +the flag behavior is unchanged. ### Trigram Search diff --git a/crates/entity-derive-impl/src/entity/parse/entity.rs b/crates/entity-derive-impl/src/entity/parse/entity.rs index ba06cd6..9aebfab 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity.rs @@ -148,7 +148,7 @@ mod upsert; pub use attrs::EntityAttrs; pub use def::EntityDef; -pub use helpers::HasManyDef; +pub use helpers::{CustomConstraintDef, HasManyDef}; pub use index::CompositeIndexDef; pub use projection::{ProjectionDef, parse_projection_attrs}; pub use upsert::UpsertAction; 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 46973b5..b8ed5f2 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::{command::parse_command_attrs, field::FieldDef, returning::ReturningMode}, CompositeIndexDef, EntityAttrs, EntityDef, - helpers::{parse_api_attr, parse_has_many_attrs, parse_index_attrs}, + helpers::{parse_api_attr, parse_constraint_attrs, parse_has_many_attrs, parse_index_attrs}, parse_projection_attrs, upsert::{UpsertAction, UpsertDef} }; @@ -132,6 +132,14 @@ impl EntityDef { let command_defs = parse_command_attrs(&input.attrs).map_err(darling::Error::from)?; let api_config = parse_api_attr(&input.attrs); let indexes = parse_index_attrs(&input.attrs); + let custom_constraints = + parse_constraint_attrs(&input.attrs).map_err(darling::Error::from)?; + if !custom_constraints.is_empty() && !attrs.typed_constraints { + return Err(darling::Error::custom( + "constraint(...) requires #[entity(typed_constraints)]" + ) + .with_span(&input.ident)); + } let doc = extract_doc_comments(&input.attrs); let id_field_index = fields @@ -263,7 +271,8 @@ impl EntityDef { indexes, aggregate_root: attrs.aggregate_root, upsert: attrs.upsert, - typed_constraints: attrs.typed_constraints + typed_constraints: attrs.typed_constraints, + custom_constraints }) } } diff --git a/crates/entity-derive-impl/src/entity/parse/entity/def.rs b/crates/entity-derive-impl/src/entity/parse/entity/def.rs index 188cbc6..f684f83 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity/def.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity/def.rs @@ -250,5 +250,11 @@ pub struct EntityDef { pub upsert: Option, /// Whether typed constraint-violation errors are enabled. - pub typed_constraints: bool + pub typed_constraints: bool, + + /// Custom constraint declarations from `#[entity(constraint(...))]`. + /// + /// Merged into the `typed_constraints` registry, taking precedence + /// over auto-derived entries with the same name. + pub custom_constraints: Vec } diff --git a/crates/entity-derive-impl/src/entity/parse/entity/helpers.rs b/crates/entity-derive-impl/src/entity/parse/entity/helpers.rs index 441b5e4..c7400ff 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity/helpers.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity/helpers.rs @@ -195,6 +195,95 @@ pub fn parse_api_attr(attrs: &[Attribute]) -> ApiConfig { ApiConfig::default() } +/// A custom constraint declaration from `#[entity(constraint(...))]`. +/// +/// Extends the `typed_constraints` registry with constraints the macro +/// cannot infer: foreign keys over natural keys, custom-named CHECK +/// constraints, indexes created in hand-written migrations. +#[derive(Debug, Clone)] +pub struct CustomConstraintDef { + /// Constraint name as reported by the database. + pub name: String, + + /// Constraint kind (`unique`, `foreign_key` or `check`). + pub kind: String, + + /// Entity field the constraint maps to, when known. + pub field: Option +} + +/// Parse `constraint(...)` declarations from `#[entity(...)]` attributes. +/// +/// # Errors +/// +/// Returns an error for a missing `name`/`kind`, an unknown `kind`, or +/// an unknown option inside `constraint(...)`. +pub fn parse_constraint_attrs(attrs: &[Attribute]) -> syn::Result> { + let mut constraints = Vec::new(); + + for attr in attrs { + if !attr.path().is_ident("entity") { + continue; + } + + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("constraint") { + constraints.push(parse_constraint_content(&meta)?); + } else if meta.input.peek(syn::Token![=]) { + let _: syn::Token![=] = meta.input.parse()?; + let _: syn::Expr = meta.input.parse()?; + } else if meta.input.peek(syn::token::Paren) { + let content; + syn::parenthesized!(content in meta.input); + let _: proc_macro2::TokenStream = content.parse()?; + } + Ok(()) + })?; + } + + Ok(constraints) +} + +/// Parse the body of one `constraint(...)` declaration. +fn parse_constraint_content( + meta: &syn::meta::ParseNestedMeta<'_> +) -> syn::Result { + let mut name: Option = None; + let mut kind: Option = None; + let mut field: Option = None; + + meta.parse_nested_meta(|nested| { + if nested.path.is_ident("name") { + let value: syn::LitStr = nested.value()?.parse()?; + name = Some(value.value()); + } else if nested.path.is_ident("kind") { + let value: syn::LitStr = nested.value()?.parse()?; + let v = value.value(); + if !matches!(v.as_str(), "unique" | "foreign_key" | "check") { + return Err(nested.error(format!( + "unknown constraint kind `{v}`; expected unique, foreign_key or check" + ))); + } + kind = Some(v); + } else if nested.path.is_ident("field") { + let value: syn::LitStr = nested.value()?.parse()?; + field = Some(value.value()); + } else { + return Err(nested.error("unknown constraint option; expected name, kind, field")); + } + Ok(()) + })?; + + let name = name.ok_or_else(|| meta.error("constraint requires name = \"...\""))?; + let kind = kind.ok_or_else(|| meta.error("constraint requires kind = \"...\""))?; + + Ok(CustomConstraintDef { + name, + kind, + field + }) +} + /// Parse `index(...)` and `unique_index(...)` from `#[entity(...)]` attribute. /// /// Extracts composite index definitions from the entity attribute. diff --git a/crates/entity-derive-impl/src/entity/sql/postgres/constraints.rs b/crates/entity-derive-impl/src/entity/sql/postgres/constraints.rs index ce5a94e..b68e5ab 100644 --- a/crates/entity-derive-impl/src/entity/sql/postgres/constraints.rs +++ b/crates/entity-derive-impl/src/entity/sql/postgres/constraints.rs @@ -46,35 +46,61 @@ impl Context<'_> { let table = self.entity.table_name(); let mut arms: Vec = Vec::new(); + let mut covered: std::collections::HashSet = std::collections::HashSet::new(); + + for custom in &self.entity.custom_constraints { + let name = custom.name.clone(); + let kind = match custom.kind.as_str() { + "unique" => quote! { ::entity_core::ConstraintKind::Unique }, + "foreign_key" => quote! { ::entity_core::ConstraintKind::ForeignKey }, + _ => quote! { ::entity_core::ConstraintKind::Check } + }; + let field = match &custom.field { + Some(f) => quote! { Some(#f) }, + None => quote! { None } + }; + covered.insert(name.clone()); + arms.push(quote! { + #name => Some((#kind, #field)), + }); + } for field in self.entity.all_fields() { let column = field.name_str(); if field.column.unique { let name = format!("{table}_{column}_key"); - arms.push(quote! { - #name => Some((::entity_core::ConstraintKind::Unique, Some(#column))), - }); + if covered.insert(name.clone()) { + arms.push(quote! { + #name => Some((::entity_core::ConstraintKind::Unique, Some(#column))), + }); + } } if field.belongs_to().is_some() { let name = format!("{table}_{column}_fkey"); - arms.push(quote! { - #name => Some((::entity_core::ConstraintKind::ForeignKey, Some(#column))), - }); + if covered.insert(name.clone()) { + arms.push(quote! { + #name => Some((::entity_core::ConstraintKind::ForeignKey, Some(#column))), + }); + } } if field.column.check.is_some() { let name = format!("{table}_{column}_check"); - arms.push(quote! { - #name => Some((::entity_core::ConstraintKind::Check, Some(#column))), - }); + if covered.insert(name.clone()) { + arms.push(quote! { + #name => Some((::entity_core::ConstraintKind::Check, Some(#column))), + }); + } } } for index in &self.entity.indexes { if index.unique { let name = index.name_or_default(table); - arms.push(quote! { - #name => Some((::entity_core::ConstraintKind::Unique, None)), - }); + if covered.insert(name.clone()) { + arms.push(quote! { + #name => Some((::entity_core::ConstraintKind::Unique, None)), + }); + } } } @@ -176,3 +202,116 @@ mod tests { assert!(ctx.constraint_map_err().is_empty()); } } + +#[cfg(test)] +mod custom_constraint_tests { + use quote::quote; + use syn::DeriveInput; + + use super::super::context::Context; + use crate::entity::parse::EntityDef; + + fn parse_entity(tokens: proc_macro2::TokenStream) -> EntityDef { + let input: DeriveInput = syn::parse2(tokens).expect("test entity must parse"); + EntityDef::from_derive_input(&input).expect("test entity must be valid") + } + + #[test] + fn custom_constraints_join_the_registry() { + let entity = parse_entity(quote! { + #[entity( + table = "orders", + typed_constraints, + error = "MyError", + constraint(name = "orders_currency_fkey", kind = "foreign_key", field = "currency"), + constraint(name = "orders_window_check", kind = "check") + )] + pub struct Order { + #[id] + pub id: uuid::Uuid, + #[field(create, response)] + pub currency: String, + } + }); + let code = Context::new(&entity).constraint_mapper().to_string(); + assert!(code.contains("orders_currency_fkey")); + assert!(code.contains("ForeignKey , Some (\"currency\")")); + assert!(code.contains("orders_window_check")); + assert!(code.contains("Check , None")); + } + + #[test] + fn custom_entry_overrides_derived_name() { + let entity = parse_entity(quote! { + #[entity( + table = "users", + typed_constraints, + error = "MyError", + constraint(name = "users_email_key", kind = "unique", field = "primary_email") + )] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, response)] + #[column(unique)] + pub email: String, + } + }); + let code = Context::new(&entity).constraint_mapper().to_string(); + assert_eq!(code.matches("users_email_key").count(), 1); + assert!(code.contains("Some (\"primary_email\")")); + } + + #[test] + fn constraint_without_typed_constraints_rejected() { + let input: DeriveInput = syn::parse_quote! { + #[entity(table = "orders", constraint(name = "x", kind = "check"))] + pub struct Order { + #[id] + pub id: uuid::Uuid, + } + }; + let err = EntityDef::from_derive_input(&input).unwrap_err(); + assert!( + err.to_string() + .contains("requires #[entity(typed_constraints)]") + ); + } + + #[test] + fn unknown_kind_rejected() { + let input: DeriveInput = syn::parse_quote! { + #[entity(table = "orders", typed_constraints, constraint(name = "x", kind = "exclusion"))] + pub struct Order { + #[id] + pub id: uuid::Uuid, + } + }; + assert!(EntityDef::from_derive_input(&input).is_err()); + } +} + +#[cfg(all(test, not(feature = "postgres")))] +mod feature_gating_tests { + use quote::quote; + use syn::DeriveInput; + + use crate::entity::parse::EntityDef; + + #[test] + fn repository_impl_omitted_without_postgres_feature() { + let input: DeriveInput = syn::parse_quote! { + #[entity(table = "users")] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, response)] + pub name: String, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let code = crate::entity::sql::generate(&entity).to_string(); + assert!(code.is_empty()); + let _ = quote!(); + } +} diff --git a/crates/entity-derive/tests/cases/pass/typed_constraints.rs b/crates/entity-derive/tests/cases/pass/typed_constraints.rs index cf36750..c755606 100644 --- a/crates/entity-derive/tests/cases/pass/typed_constraints.rs +++ b/crates/entity-derive/tests/cases/pass/typed_constraints.rs @@ -35,7 +35,12 @@ impl From for AppError { } #[derive(Debug, Clone, Entity)] -#[entity(table = "users", typed_constraints, error = "AppError")] +#[entity( + table = "users", + typed_constraints, + error = "AppError", + constraint(name = "users_referral_fkey", kind = "foreign_key", field = "referral_code") +)] pub struct User { #[id] pub id: Uuid, @@ -46,6 +51,9 @@ pub struct User { #[field(create, update, response)] pub name: String, + + #[field(create, response)] + pub referral_code: Option, } async fn exercise(pool: sqlx::PgPool) -> Result<(), AppError> { @@ -53,6 +61,7 @@ async fn exercise(pool: sqlx::PgPool) -> Result<(), AppError> { .create(CreateUserRequest { email: "a@b.c".into(), name: "A".into(), + referral_code: None, }) .await; if let Err(AppError::Constraint(violation)) = &result { diff --git a/wiki/Atributos.md b/wiki/Atributos.md index b4ac95f..e2afa31 100644 --- a/wiki/Atributos.md +++ b/wiki/Atributos.md @@ -724,3 +724,16 @@ pub name: String, let dto: CreateUserRequest = serde_json::from_str(body)?; garde::Validate::validate(&dto)?; ``` + +### `constraint(...)` (opcional, junto con `typed_constraints`) + +Declara constraints que la macro no puede inferir — claves foráneas sobre claves naturales, CHECKs con nombres personalizados, índices de migraciones manuales — para que las violaciones se resuelvan a `ConstraintError` con el campo declarado. Tipos: `unique`, `foreign_key`, `check`. Las entradas personalizadas tienen prioridad sobre las derivadas con el mismo nombre. Requiere `typed_constraints`. + +```rust +#[entity( + table = "orders", + typed_constraints, + constraint(name = "orders_currency_fkey", kind = "foreign_key", field = "currency"), + constraint(name = "orders_window_check", kind = "check"), +)] +``` diff --git a/wiki/Attributes-en.md b/wiki/Attributes-en.md index 7d3a9a5..d7cd4ef 100644 --- a/wiki/Attributes-en.md +++ b/wiki/Attributes-en.md @@ -784,3 +784,16 @@ pub name: String, let dto: CreateUserRequest = serde_json::from_str(body)?; garde::Validate::validate(&dto)?; ``` + +### `constraint(...)` (optional, with `typed_constraints`) + +Declare constraints the macro cannot infer — foreign keys over natural keys, custom-named CHECK constraints, indexes from hand-written migrations — so violations resolve to `ConstraintError` with the declared field. Kinds: `unique`, `foreign_key`, `check`. Custom entries take precedence over derived entries with the same name. Requires `typed_constraints`. + +```rust +#[entity( + table = "orders", + typed_constraints, + constraint(name = "orders_currency_fkey", kind = "foreign_key", field = "currency"), + constraint(name = "orders_window_check", kind = "check"), +)] +``` 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 295b660..ad05942 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" @@ -784,3 +784,16 @@ pub name: String, let dto: CreateUserRequest = serde_json::from_str(body)?; garde::Validate::validate(&dto)?; ``` + +### `constraint(...)` (опциональный, вместе с `typed_constraints`) + +Объявляет constraint'ы, которые макрос не может вывести — внешние ключи по натуральным ключам, CHECK'и с кастомными именами, индексы из ручных миграций — чтобы нарушения резолвились в `ConstraintError` с указанным полем. Виды: `unique`, `foreign_key`, `check`. Кастомные записи имеют приоритет над выведенными с тем же именем. Требует `typed_constraints`. + +```rust +#[entity( + table = "orders", + typed_constraints, + constraint(name = "orders_currency_fkey", kind = "foreign_key", field = "currency"), + constraint(name = "orders_window_check", kind = "check"), +)] +``` diff --git "a/wiki/\345\261\236\346\200\247.md" "b/wiki/\345\261\236\346\200\247.md" index 7d0bf66..d278130 100644 --- "a/wiki/\345\261\236\346\200\247.md" +++ "b/wiki/\345\261\236\346\200\247.md" @@ -724,3 +724,16 @@ pub name: String, let dto: CreateUserRequest = serde_json::from_str(body)?; garde::Validate::validate(&dto)?; ``` + +### `constraint(...)`(可选,需配合 `typed_constraints`) + +声明宏无法推断的约束——基于自然键的外键、自定义命名的 CHECK 约束、手写迁移中的索引——使违规解析为携带所声明字段的 `ConstraintError`。类型:`unique`、`foreign_key`、`check`。自定义条目优先于同名的派生条目。需要 `typed_constraints`。 + +```rust +#[entity( + table = "orders", + typed_constraints, + constraint(name = "orders_currency_fkey", kind = "foreign_key", field = "currency"), + constraint(name = "orders_window_check", kind = "check"), +)] +``` diff --git "a/wiki/\354\206\215\354\204\261.md" "b/wiki/\354\206\215\354\204\261.md" index 487684e..aca6d93 100644 --- "a/wiki/\354\206\215\354\204\261.md" +++ "b/wiki/\354\206\215\354\204\261.md" @@ -724,3 +724,16 @@ pub name: String, let dto: CreateUserRequest = serde_json::from_str(body)?; garde::Validate::validate(&dto)?; ``` + +### `constraint(...)` (선택, `typed_constraints`와 함께) + +매크로가 유추할 수 없는 제약을 선언합니다 — 자연 키에 대한 외래 키, 커스텀 이름의 CHECK 제약, 수동 마이그레이션의 인덱스 — 위반이 선언된 필드와 함께 `ConstraintError`로 해석되도록 합니다. 종류: `unique`, `foreign_key`, `check`. 커스텀 항목은 같은 이름의 파생 항목보다 우선합니다. `typed_constraints`가 필요합니다. + +```rust +#[entity( + table = "orders", + typed_constraints, + constraint(name = "orders_currency_fkey", kind = "foreign_key", field = "currency"), + constraint(name = "orders_window_check", kind = "check"), +)] +```