Skip to content
Merged

183 #190

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
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConstraintError>`; 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<ConstraintError>`; without
the flag behavior is unchanged.

### Trigram Search

Expand Down
2 changes: 1 addition & 1 deletion crates/entity-derive-impl/src/entity/parse/entity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 11 additions & 2 deletions crates/entity-derive-impl/src/entity/parse/entity/constructor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}
};
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
})
}
}
Expand Down
8 changes: 7 additions & 1 deletion crates/entity-derive-impl/src/entity/parse/entity/def.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,5 +250,11 @@ pub struct EntityDef {
pub upsert: Option<UpsertDef>,

/// 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<super::CustomConstraintDef>
}
89 changes: 89 additions & 0 deletions crates/entity-derive-impl/src/entity/parse/entity/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>
}

/// 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<Vec<CustomConstraintDef>> {
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<CustomConstraintDef> {
let mut name: Option<String> = None;
let mut kind: Option<String> = None;
let mut field: Option<String> = 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.
Expand Down
163 changes: 151 additions & 12 deletions crates/entity-derive-impl/src/entity/sql/postgres/constraints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,35 +46,61 @@ impl Context<'_> {

let table = self.entity.table_name();
let mut arms: Vec<TokenStream> = Vec::new();
let mut covered: std::collections::HashSet<String> = 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)),
});
}
}
}

Expand Down Expand Up @@ -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!();
}
}
11 changes: 10 additions & 1 deletion crates/entity-derive/tests/cases/pass/typed_constraints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ impl From<ConstraintError> 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,
Expand All @@ -46,13 +51,17 @@ pub struct User {

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

#[field(create, response)]
pub referral_code: Option<String>,
}

async fn exercise(pool: sqlx::PgPool) -> Result<(), AppError> {
let result = pool
.create(CreateUserRequest {
email: "a@b.c".into(),
name: "A".into(),
referral_code: None,
})
.await;
if let Err(AppError::Constraint(violation)) = &result {
Expand Down
Loading
Loading