diff --git a/README.md b/README.md index 4bfed11..6fc92f8 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,7 @@ tracing-subscriber = "0.3" | **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` | +| **Participant Scopes** | `#[scope(name: col_a \| col_b)]` generates `list_{name}` over an OR group, optionally narrowed by `within` | | **Upsert** | `upsert(conflict = "…")` generates `INSERT ... ON CONFLICT DO UPDATE / DO NOTHING` | | **Aggregate Roots** | `#[entity(aggregate_root)]` with `New{T}` DTOs and transactional `save` | | **Transactions** | Multi-entity atomic operations | @@ -249,6 +250,7 @@ tracing-subscriber = "0.3" #[has_many(Entity)] // One-to-many relation #[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 ``` ### Transactional Outbox @@ -286,6 +288,28 @@ handlers must be idempotent. Composes with `streams`: NOTIFY wakes subscribers instantly, the outbox guarantees nothing is lost. Requires the `outbox` feature and `serde_json` in your crate. +### Participant Scopes + +"Rows where this principal takes part in any role" is an OR over several +columns holding the same kind of value. Declare it once: + +```rust,ignore +#[derive(Entity)] +#[entity(table = "disputes")] +#[scope(involving: requester_id | subject_id)] +#[scope(handled: requester_id | subject_id, within = parcel_id)] +pub struct Dispute { /* ... */ } + +let mine = pool.list_involving(user_id, 20, 0).await?; +let here = pool.list_handled(parcel_id, user_id, 20, 0).await?; +``` + +The value is bound once and matched against every declared column; +`within` narrows the group to one parent first. Columns are checked at +compile time: an unknown name or a group whose columns disagree on their +type is an error at the declaration. Soft-delete aware, ordered by id +descending. + ### Ownership Scoping Mark the column carrying the owning principal's id with `#[owner]` and the diff --git a/crates/entity-derive-impl/src/entity/parse.rs b/crates/entity-derive-impl/src/entity/parse.rs index 30638f8..5e8bf0b 100644 --- a/crates/entity-derive-impl/src/entity/parse.rs +++ b/crates/entity-derive-impl/src/entity/parse.rs @@ -121,7 +121,7 @@ pub use api::ApiConfig; pub use command::{CommandDef, CommandKindHint, CommandSource}; pub use dialect::DatabaseDialect; pub use entity::{ - CompositeIndexDef, EntityDef, HasManyDef, ProjectionDef, TransitionDef, UpsertAction + CompositeIndexDef, EntityDef, HasManyDef, ProjectionDef, ScopeDef, TransitionDef, UpsertAction }; #[allow(unused_imports)] // Will be used for OpenAPI schema examples (#80) pub use field::ExampleValue; diff --git a/crates/entity-derive-impl/src/entity/parse/entity.rs b/crates/entity-derive-impl/src/entity/parse/entity.rs index 645f0ee..1a3ad39 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity.rs @@ -145,6 +145,7 @@ mod helpers; mod index; mod join; mod projection; +mod scope; mod transition; mod upsert; @@ -153,6 +154,7 @@ pub use def::EntityDef; pub use helpers::{CustomConstraintDef, HasManyDef}; pub use index::CompositeIndexDef; pub use projection::{ProjectionDef, parse_projection_attrs}; +pub use scope::ScopeDef; pub use transition::TransitionDef; 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 386dcb8..ca3bbcb 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity/constructor.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity/constructor.rs @@ -65,7 +65,7 @@ use super::{ field::{ExposeConfig, FieldDef, FilterConfig, StorageConfig, ValidationConfig}, returning::ReturningMode }, - CompositeIndexDef, EntityAttrs, EntityDef, + CompositeIndexDef, EntityAttrs, EntityDef, ScopeDef, helpers::{parse_api_attr, parse_constraint_attrs, parse_has_many_attrs, parse_index_attrs}, parse_projection_attrs, upsert::{UpsertAction, UpsertDef} @@ -142,6 +142,9 @@ impl EntityDef { let joins = super::join::parse_join_attrs(&input.attrs).map_err(darling::Error::from)?; let transitions = super::transition::parse_transition_attrs(&input.attrs) .map_err(darling::Error::from)?; + let scopes = + super::scope::parse_scope_attrs(&input.attrs).map_err(darling::Error::from)?; + validate_scopes(&scopes, &fields, input)?; let field_names: Vec = fields .iter() .map(super::super::field::FieldDef::name_str) @@ -341,6 +344,7 @@ impl EntityDef { extensions: attrs.migrations.extensions, indexes, joins, + scopes, transitions, aggregate_root: attrs.aggregate_root, upsert: attrs.upsert, @@ -460,6 +464,56 @@ fn validate_sql_names( Ok(()) } +/// Check that every column a scope names exists, and that the OR-ed +/// ones share a type. +/// +/// The generated method binds one value against all of them, so a +/// mismatch would only surface as a decode error at run time. +fn validate_scopes( + scopes: &[ScopeDef], + fields: &[FieldDef], + input: &DeriveInput +) -> darling::Result<()> { + let column = |name: &str| fields.iter().find(|f| f.name_str() == name); + + for scope in scopes { + for name in &scope.columns { + if column(name).is_none() { + return Err(darling::Error::custom(format!( + "scope `{}` names column `{name}`, which is not a field of this entity", + scope.name + )) + .with_span(&input.ident)); + } + } + + if let Some(within) = &scope.within + && column(within).is_none() + { + return Err(darling::Error::custom(format!( + "scope `{}` narrows by `{within}`, which is not a field of this entity", + scope.name + )) + .with_span(&input.ident)); + } + + let mut declared = scope.columns.iter().filter_map(|name| { + column(name).map(|f| quote::ToTokens::to_token_stream(f.ty()).to_string()) + }); + if let Some(first) = declared.next() + && let Some(other) = declared.find(|ty| *ty != first) + { + return Err(darling::Error::custom(format!( + "scope `{}` ORs columns of different types (`{first}` and `{other}`); one value is bound against all of them", + scope.name + )) + .with_span(&input.ident)); + } + } + + Ok(()) +} + fn validate_upsert( upsert: &UpsertDef, fields: &[FieldDef], 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 cd2c859..c2ecb2d 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity/def.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity/def.rs @@ -63,7 +63,7 @@ use super::{ api::ApiConfig, command::CommandDef, dialect::DatabaseDialect, field::FieldDef, returning::ReturningMode, sql_level::SqlLevel, uuid_version::UuidVersion }, - CompositeIndexDef, ProjectionDef, + CompositeIndexDef, ProjectionDef, ScopeDef, helpers::HasManyDef, join::JoinDef, transition::TransitionDef, @@ -242,6 +242,12 @@ pub struct EntityDef { /// Non-empty when the entity generates a `{Entity}View`. pub joins: Vec, + /// Participant scopes from `#[scope(...)]`. + /// + /// Each entry generates a `list_{name}` method ORing the declared + /// columns against one bound value. + pub scopes: Vec, + /// State-machine transitions from `#[transition(...)]`. /// /// Each entry generates a locking `transition_to_{target}` method diff --git a/crates/entity-derive-impl/src/entity/parse/entity/scope.rs b/crates/entity-derive-impl/src/entity/parse/entity/scope.rs new file mode 100644 index 0000000..a767685 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/entity/scope.rs @@ -0,0 +1,173 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Participant scopes declared with `#[scope(...)]`. +//! +//! "Rows where this user takes part in any role" is an OR over several +//! columns holding the same kind of value, and it is the one query +//! shape that otherwise forces raw SQL: +//! +//! ```rust,ignore +//! #[scope(involving: requester_id | subject_id)] +//! #[scope(participating: sender_id | recipient_id | courier_id, within = parcel_id)] +//! ``` +//! +//! The first generates `list_involving(value, limit, offset)`; the +//! second narrows the OR group to one parent first, generating +//! `list_participating(parcel_id, value, limit, offset)`. + +use syn::{Attribute, Ident}; + +/// One `#[scope(...)]` declaration. +#[derive(Debug, Clone)] +pub struct ScopeDef { + /// Scope name, used for the generated method: `list_{name}`. + pub name: String, + /// Columns OR-ed against the bound value; at least two, since one + /// column is what a plain lookup already covers. + pub columns: Vec, + /// Optional column the scope is narrowed by first, AND-ed before + /// the OR group. + pub within: Option +} + +impl ScopeDef { + /// Generated method name. + #[must_use] + pub fn method_name(&self) -> String { + format!("list_{}", self.name) + } +} + +/// Parse every `#[scope(...)]` attribute on the entity. +/// +/// # Errors +/// +/// Returns a `syn::Error` for a missing name separator, fewer than two +/// OR-ed columns, or an unknown option. +pub fn parse_scope_attrs(attrs: &[Attribute]) -> syn::Result> { + let mut scopes = Vec::new(); + + for attr in attrs { + if !attr.path().is_ident("scope") { + continue; + } + scopes.push(attr.parse_args_with(parse_scope_body)?); + } + + Ok(scopes) +} + +/// Parse the body of one `#[scope(...)]` attribute. +fn parse_scope_body(input: syn::parse::ParseStream<'_>) -> syn::Result { + let name: Ident = input.parse()?; + input.parse::().map_err(|_| { + syn::Error::new( + name.span(), + "scope needs a name followed by `:` and the columns, for example \ + `#[scope(involving: requester_id | subject_id)]`" + ) + })?; + + let mut columns = Vec::new(); + let first: Ident = input.parse()?; + columns.push(first.to_string()); + while input.peek(syn::Token![|]) { + input.parse::()?; + let next: Ident = input.parse()?; + columns.push(next.to_string()); + } + + if columns.len() < 2 { + return Err(syn::Error::new( + first.span(), + "a scope ORs at least two columns; one column is what a lookup already does" + )); + } + + let mut within = None; + if input.peek(syn::Token![,]) { + input.parse::()?; + let option: Ident = input.parse()?; + if option != "within" { + return Err(syn::Error::new( + option.span(), + "unknown scope option; expected `within = column`" + )); + } + input.parse::()?; + let column: Ident = input.parse()?; + within = Some(column.to_string()); + } + + Ok(ScopeDef { + name: name.to_string(), + columns, + within + }) +} + +#[cfg(test)] +mod tests { + use quote::quote; + + use super::*; + + fn parse(tokens: proc_macro2::TokenStream) -> syn::Result> { + let input: syn::DeriveInput = syn::parse_quote! { + #tokens + pub struct Dispute { + pub id: uuid::Uuid, + } + }; + parse_scope_attrs(&input.attrs) + } + + #[test] + fn parses_a_plain_or_group() { + let scopes = parse(quote! { #[scope(involving: requester_id | subject_id)] }) + .expect("the declaration is valid"); + assert_eq!(scopes.len(), 1); + assert_eq!(scopes[0].name, "involving"); + assert_eq!(scopes[0].columns, vec!["requester_id", "subject_id"]); + assert!(scopes[0].within.is_none()); + assert_eq!(scopes[0].method_name(), "list_involving"); + } + + #[test] + fn parses_a_narrowed_group() { + let scopes = parse(quote! { + #[scope(participating: sender_id | recipient_id | courier_id, within = parcel_id)] + }) + .expect("the declaration is valid"); + assert_eq!(scopes[0].columns.len(), 3); + assert_eq!(scopes[0].within.as_deref(), Some("parcel_id")); + } + + #[test] + fn a_single_column_is_rejected() { + let err = parse(quote! { #[scope(involving: requester_id)] }) + .expect_err("one column is not a scope"); + assert!(err.to_string().contains("at least two"), "{err}"); + } + + #[test] + fn a_missing_separator_is_rejected() { + let err = parse(quote! { #[scope(involving requester_id | subject_id)] }) + .expect_err("the name has to be followed by a colon"); + assert!(err.to_string().contains('`'), "{err}"); + } + + #[test] + fn an_unknown_option_is_rejected() { + let err = parse(quote! { #[scope(involving: a | b, ordered = c)] }) + .expect_err("only `within` is understood"); + assert!(err.to_string().contains("within"), "{err}"); + } + + #[test] + fn no_attribute_yields_no_scopes() { + let scopes = parse(quote! { #[entity(table = "disputes")] }).expect("nothing to parse"); + assert!(scopes.is_empty()); + } +} diff --git a/crates/entity-derive-impl/src/entity/repository.rs b/crates/entity-derive-impl/src/entity/repository.rs index 175941b..88f7751 100644 --- a/crates/entity-derive-impl/src/entity/repository.rs +++ b/crates/entity-derive-impl/src/entity/repository.rs @@ -112,6 +112,7 @@ pub fn generate(entity: &EntityDef) -> TokenStream { let upsert_method = generate_upsert_method(entity); let scoped_methods = generate_scoped_methods(entity, id_type); + let participant_scopes = generate_scope_methods(entity); let relation_methods = generate_relation_methods(entity, id_type); let projection_methods = generate_projection_methods(entity, id_type); let soft_delete_methods = generate_soft_delete_methods(entity, id_type); @@ -191,11 +192,64 @@ pub fn generate(entity: &EntityDef) -> TokenStream { #scoped_methods + #participant_scopes + #save_method } } } +/// Generate the trait side of every `#[scope(...)]` declaration. +/// +/// One method per scope, listing rows where the bound value appears in +/// any of the declared columns. +fn generate_scope_methods(entity: &EntityDef) -> TokenStream { + let column_type = |name: &str| { + entity + .column_fields() + .into_iter() + .find(|f| f.name_str() == name) + .map(|f| f.ty().clone()) + .expect("scope columns are validated during parsing") + }; + + let methods: Vec = entity + .scopes + .iter() + .map(|scope| { + let method_name = format_ident!("{}", scope.method_name()); + let entity_name = entity.name(); + let value_type = column_type(&scope.columns[0]); + let within = scope.within.as_ref().map(|column| { + let name = format_ident!("{column}"); + let ty = column_type(column); + quote! { #name: #ty, } + }); + let doc = format!( + "List rows where `value` appears in {}{}.\n\nOrdered by id descending; soft-deleted rows are excluded.", + scope.columns.join(" or "), + scope + .within + .as_ref() + .map_or_else(String::new, |c| format!(", narrowed to one `{c}`")) + ); + + quote! { + #[doc = #doc] + async fn #method_name( + &self, + #within + value: #value_type, + limit: i64, + offset: i64, + ) -> Result, Self::Error>; + } + }) + .collect(); + + quote! { #(#methods)* } +} + /// Generate relation methods for `#[belongs_to]` and `#[has_many]`. /// /// For `#[belongs_to(Entity)]`, generates: diff --git a/crates/entity-derive-impl/src/entity/sql/postgres.rs b/crates/entity-derive-impl/src/entity/sql/postgres.rs index b15252d..38ff707 100644 --- a/crates/entity-derive-impl/src/entity/sql/postgres.rs +++ b/crates/entity-derive-impl/src/entity/sql/postgres.rs @@ -73,6 +73,7 @@ mod query; mod relations; mod save; mod scoped; +mod scopes; mod soft_delete; mod upsert; @@ -125,6 +126,7 @@ pub fn generate(entity: &EntityDef) -> TokenStream { let projection_impls = ctx.projection_methods(); let soft_delete_impls = ctx.soft_delete_methods(); let scoped_impls = ctx.scoped_methods(); + let participant_scope_impls = ctx.scope_methods(); let lookup_impls = ctx.lookup_methods(); let save_impl = ctx.save_method(); let constraint_mapper = ctx.constraint_mapper(); @@ -160,6 +162,7 @@ pub fn generate(entity: &EntityDef) -> TokenStream { #projection_impls #soft_delete_impls #scoped_impls + #participant_scope_impls #save_impl } } diff --git a/crates/entity-derive-impl/src/entity/sql/postgres/scopes.rs b/crates/entity-derive-impl/src/entity/sql/postgres/scopes.rs new file mode 100644 index 0000000..ea77cbe --- /dev/null +++ b/crates/entity-derive-impl/src/entity/sql/postgres/scopes.rs @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Participant-scope method generators for `PostgreSQL`. +//! +//! Generated from entity-level `#[scope(...)]` declarations: +//! +//! | Declaration | SQL | +//! |-------------|-----| +//! | `#[scope(involving: a \| b)]` | `SELECT ... WHERE (a = $1 OR b = $1) ...` | +//! | `#[scope(x: a \| b, within = p)]` | `SELECT ... WHERE p = $1 AND (a = $2 OR b = $2) ...` | +//! +//! One value is bound once and referenced by every branch of the OR +//! group. Soft-delete-aware, ordered by id descending like `list`. + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +use super::context::Context; +use crate::{entity::parse::ScopeDef, utils::tracing::instrument}; + +impl Context<'_> { + /// Generate every declared scope method. + /// + /// # Returns + /// + /// Empty `TokenStream` when the entity declares no scopes. + pub fn scope_methods(&self) -> TokenStream { + let methods: Vec = self + .entity + .scopes + .iter() + .map(|scope| self.scope_method(scope)) + .collect(); + + quote! { #(#methods)* } + } + + /// Generate one `list_{name}` implementation. + fn scope_method(&self, scope: &ScopeDef) -> TokenStream { + let Self { + entity_name, + row_name, + table, + columns_str, + id_name, + soft_delete, + .. + } = self; + + let method_name = format_ident!("{}", scope.method_name()); + let value_type = self.scope_value_type(scope); + let within = scope.within.as_ref().map(|column| { + let field = self + .entity + .column_fields() + .into_iter() + .find(|f| &f.name_str() == column) + .expect("scope columns are validated during parsing"); + (format_ident!("{column}"), field.ty().clone()) + }); + + let (prefix, value_index) = within.as_ref().map_or_else( + || (String::new(), 1), + |(column, _)| (format!("{column} = $1 AND "), 2) + ); + let group = scope + .columns + .iter() + .map(|column| format!("{column} = ${value_index}")) + .collect::>() + .join(" OR "); + let deleted_filter = if *soft_delete { + " AND deleted_at IS NULL" + } else { + "" + }; + let limit_index = value_index + 1; + let offset_index = value_index + 2; + let sql = format!( + "SELECT {columns_str} FROM {table} WHERE {prefix}({group}){deleted_filter} \ + ORDER BY {id_name} DESC LIMIT ${limit_index} OFFSET ${offset_index}" + ); + + let span = instrument(&entity_name.to_string(), &scope.method_name()); + let (within_param, within_bind) = within.map_or_else( + || (TokenStream::new(), TokenStream::new()), + |(name, ty)| (quote! { #name: #ty, }, quote! { .bind(&#name) }) + ); + + quote! { + #span + async fn #method_name( + &self, + #within_param + value: #value_type, + limit: i64, + offset: i64, + ) -> Result, Self::Error> { + let rows: Vec<#row_name> = sqlx::query_as(#sql) + #within_bind + .bind(&value) + .bind(limit) + .bind(offset) + .fetch_all(self) + .await?; + Ok(rows.into_iter().map(#entity_name::from).collect()) + } + } + } + + /// Type of the value bound against the OR group. + /// + /// Parsing guarantees the columns agree, so the first one decides. + fn scope_value_type(&self, scope: &ScopeDef) -> syn::Type { + let first = &scope.columns[0]; + self.entity + .column_fields() + .into_iter() + .find(|f| &f.name_str() == first) + .expect("scope columns are validated during parsing") + .ty() + .clone() + } +} diff --git a/crates/entity-derive-impl/src/lib.rs b/crates/entity-derive-impl/src/lib.rs index b62ea26..c67536e 100644 --- a/crates/entity-derive-impl/src/lib.rs +++ b/crates/entity-derive-impl/src/lib.rs @@ -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 + projection, filter, command, example, column, map, join, transition, scope ) )] pub fn derive_entity(input: TokenStream) -> TokenStream { diff --git a/crates/entity-derive/tests/cases/fail/scope_type_mismatch.rs b/crates/entity-derive/tests/cases/fail/scope_type_mismatch.rs new file mode 100644 index 0000000..ba972e6 --- /dev/null +++ b/crates/entity-derive/tests/cases/fail/scope_type_mismatch.rs @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! One value is bound against every column of the group, so the group +//! has to agree on a type. + +use entity_derive::Entity; +use uuid::Uuid; + +#[derive(Entity)] +#[entity(table = "disputes")] +#[scope(involving: requester_id | reference)] +pub struct Dispute { + #[id] + pub id: Uuid, + + #[field(create, response)] + pub requester_id: Uuid, + + #[field(create, response)] + pub reference: String, +} + +fn main() {} diff --git a/crates/entity-derive/tests/cases/fail/scope_type_mismatch.stderr b/crates/entity-derive/tests/cases/fail/scope_type_mismatch.stderr new file mode 100644 index 0000000..440a08b --- /dev/null +++ b/crates/entity-derive/tests/cases/fail/scope_type_mismatch.stderr @@ -0,0 +1,5 @@ +error: scope `involving` ORs columns of different types (`Uuid` and `String`); one value is bound against all of them + --> tests/cases/fail/scope_type_mismatch.rs:13:12 + | +13 | pub struct Dispute { + | ^^^^^^^ diff --git a/crates/entity-derive/tests/cases/fail/scope_unknown_column.rs b/crates/entity-derive/tests/cases/fail/scope_unknown_column.rs new file mode 100644 index 0000000..1d45ea2 --- /dev/null +++ b/crates/entity-derive/tests/cases/fail/scope_unknown_column.rs @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! A scope naming a column the entity does not have is rejected where +//! it is declared. + +use entity_derive::Entity; +use uuid::Uuid; + +#[derive(Entity)] +#[entity(table = "disputes")] +#[scope(involving: requester_id | reviewer_id)] +pub struct Dispute { + #[id] + pub id: Uuid, + + #[field(create, response)] + pub requester_id: Uuid, +} + +fn main() {} diff --git a/crates/entity-derive/tests/cases/fail/scope_unknown_column.stderr b/crates/entity-derive/tests/cases/fail/scope_unknown_column.stderr new file mode 100644 index 0000000..8337412 --- /dev/null +++ b/crates/entity-derive/tests/cases/fail/scope_unknown_column.stderr @@ -0,0 +1,5 @@ +error: scope `involving` names column `reviewer_id`, which is not a field of this entity + --> tests/cases/fail/scope_unknown_column.rs:13:12 + | +13 | pub struct Dispute { + | ^^^^^^^ diff --git a/crates/entity-derive/tests/cases/pass/scope_filters.rs b/crates/entity-derive/tests/cases/pass/scope_filters.rs new file mode 100644 index 0000000..aa6081c --- /dev/null +++ b/crates/entity-derive/tests/cases/pass/scope_filters.rs @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! `#[scope(...)]` generates a listing over an OR group of columns. + +use entity_derive::Entity; +use uuid::Uuid; + +#[derive(Debug, Clone, Entity)] +#[entity(table = "disputes")] +#[scope(involving: requester_id | subject_id)] +#[scope(handled: requester_id | subject_id, within = parcel_id)] +pub struct Dispute { + #[id] + pub id: Uuid, + + #[field(create, response)] + pub parcel_id: Uuid, + + #[field(create, response)] + pub requester_id: Uuid, + + #[field(create, response)] + pub subject_id: Uuid, +} + +async fn exercise(pool: sqlx::PgPool, user: Uuid, parcel: Uuid) -> Result<(), sqlx::Error> { + let _mine: Vec = pool.list_involving(user, 20, 0).await?; + let _here: Vec = pool.list_handled(parcel, user, 20, 0).await?; + Ok(()) +} + +fn main() { + let _ = exercise; +} diff --git a/crates/entity-derive/tests/postgres.rs b/crates/entity-derive/tests/postgres.rs index c2e6318..163cee9 100644 --- a/crates/entity-derive/tests/postgres.rs +++ b/crates/entity-derive/tests/postgres.rs @@ -2715,3 +2715,103 @@ mod update_builders { db.teardown().await; } } + +/// Participant scopes: one value matched against several roles, with +/// and without narrowing to a parent row. +mod scopes { + use entity_derive::Entity; + use uuid::Uuid; + + use crate::pg; + + #[derive(Debug, Clone, Entity)] + #[entity(table = "disputes", migrations)] + #[scope(involving: requester_id | subject_id)] + #[scope(handled: requester_id | subject_id, within = parcel_id)] + pub struct Dispute { + #[id] + pub id: Uuid, + + #[field(create, response)] + pub parcel_id: Uuid, + + #[field(create, response)] + pub requester_id: Uuid, + + #[field(create, response)] + pub subject_id: Uuid + } + + #[tokio::test] + async fn a_scope_matches_every_declared_role() { + let Some(db) = pg::provision("scopes", &[Dispute::MIGRATION_UP]).await else { + return; + }; + let pool = db.pool(); + + let ada = Uuid::now_v7(); + let grace = Uuid::now_v7(); + let stranger = Uuid::now_v7(); + let parcel = Uuid::now_v7(); + let other_parcel = Uuid::now_v7(); + + let requested = pool + .create(CreateDisputeRequest { + parcel_id: parcel, + requester_id: ada, + subject_id: grace + }) + .await + .expect("create failed"); + let subjected = pool + .create(CreateDisputeRequest { + parcel_id: other_parcel, + requester_id: grace, + subject_id: ada + }) + .await + .expect("create failed"); + pool.create(CreateDisputeRequest { + parcel_id: parcel, + requester_id: grace, + subject_id: stranger + }) + .await + .expect("create failed"); + + let ada_rows = pool + .list_involving(ada, 10, 0) + .await + .expect("scope query failed"); + let ada_ids: Vec = ada_rows.iter().map(|d| d.id).collect(); + assert_eq!(ada_ids.len(), 2, "both roles must match the same principal"); + assert!(ada_ids.contains(&requested.id) && ada_ids.contains(&subjected.id)); + + let narrowed = pool + .list_handled(parcel, ada, 10, 0) + .await + .expect("narrowed scope query failed"); + assert_eq!( + narrowed.len(), + 1, + "narrowing must drop the row belonging to another parcel" + ); + assert_eq!(narrowed[0].id, requested.id); + + assert!( + pool.list_involving(Uuid::now_v7(), 10, 0) + .await + .expect("scope query failed") + .is_empty(), + "an uninvolved principal matches nothing" + ); + + let page = pool + .list_involving(ada, 1, 0) + .await + .expect("scope query failed"); + assert_eq!(page.len(), 1, "the scope honours its pagination"); + + db.teardown().await; + } +} diff --git a/wiki/Atributos.md b/wiki/Atributos.md index f63ed5f..1e558b6 100644 --- a/wiki/Atributos.md +++ b/wiki/Atributos.md @@ -455,6 +455,20 @@ sqlx::query(Order::MIGRATION_UP).execute(&pool).await?; - El nombre declarado se verifica contra la constante `PG_TYPE` del enum en tiempo de compilación; una discrepancia rompe la compilación - El flag opcional `sqlx` de `ValueObject` genera impls `sqlx::Type` / `Encode` / `Decode`; omítelo si ya derivas `sqlx::Type` +### `#[scope(...)]` + +A nivel de entidad. Declara un listado sobre un grupo OR de columnas del mismo tipo: «filas donde este principal participa en cualquier rol». + +```rust +#[derive(Entity)] +#[entity(table = "disputes")] +#[scope(involving: requester_id | subject_id)] +#[scope(handled: requester_id | subject_id, within = parcel_id)] +pub struct Dispute { /* ... */ } +``` + +Genera: `list_involving(value, limit, offset)` y `list_handled(parcel_id, value, limit, offset)`. El valor se liga una vez y se compara con todas las columnas declaradas; `within` acota antes el grupo a un padre. Se exigen al menos dos columnas, que deben existir y coincidir en tipo — todo verificado en compilación. Respeta el borrado lógico y ordena por id descendente. + ### `#[owner]` Alcance por propietario a nivel de fila. Marca la columna con el id del propietario; el repositorio obtiene métodos con alcance que nunca revelan si una fila existe para otro propietario y respetan `soft_delete`. diff --git a/wiki/Attributes-en.md b/wiki/Attributes-en.md index 7a8bcdf..0a132cc 100644 --- a/wiki/Attributes-en.md +++ b/wiki/Attributes-en.md @@ -530,6 +530,20 @@ let taken = pool.exists_by_username(handle).await?; - With `migrations`, `unique + ci` emits `CREATE UNIQUE INDEX {table}_{column}_lower_key ON {table} (LOWER({column}))` instead of an inline `UNIQUE` constraint - With `typed_constraints`, violations of that index resolve to the field like any other unique constraint +### `#[scope(...)]` + +Entity-level. Declares a listing over an OR group of columns holding the same kind of value — "rows where this principal takes part in any role". + +```rust +#[derive(Entity)] +#[entity(table = "disputes")] +#[scope(involving: requester_id | subject_id)] +#[scope(handled: requester_id | subject_id, within = parcel_id)] +pub struct Dispute { /* ... */ } +``` + +Generated: `list_involving(value, limit, offset)` and `list_handled(parcel_id, value, limit, offset)`. The value is bound once and matched against every declared column; `within` narrows the group to one parent first. At least two columns are required, they must exist and must agree on their type — all checked at compile time. Soft-delete aware, ordered by id descending. + ### `#[owner]` Row-level ownership scoping. Marks the column carrying the owning principal's id; the repository gains scoped methods that never reveal whether a row exists for another owner and respect `soft_delete`. 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 f113c8e..caf569c 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" @@ -514,6 +514,20 @@ sqlx::query(Order::MIGRATION_UP).execute(&pool).await?; - Указанное имя сверяется с константой `PG_TYPE` enum'а на этапе компиляции; несовпадение ломает сборку - Опциональный флаг `sqlx` у `ValueObject` генерирует импелы `sqlx::Type` / `Encode` / `Decode`; не указывайте его, если уже деривите `sqlx::Type` +### `#[scope(...)]` + +Уровень сущности. Объявляет выборку по OR-группе колонок одного типа — «строки, где участник задействован в любой роли». + +```rust +#[derive(Entity)] +#[entity(table = "disputes")] +#[scope(involving: requester_id | subject_id)] +#[scope(handled: requester_id | subject_id, within = parcel_id)] +pub struct Dispute { /* ... */ } +``` + +Генерируется: `list_involving(value, limit, offset)` и `list_handled(parcel_id, value, limit, offset)`. Значение биндится один раз и сравнивается со всеми объявленными колонками; `within` сначала сужает группу до одного родителя. Нужны минимум две колонки, они должны существовать и совпадать по типу — всё проверяется на компиляции. Учитывает мягкое удаление, порядок по id по убыванию. + ### `#[owner]` Скоупинг строк по владельцу. Помечает колонку с id владельца; репозиторий получает scoped-методы, которые не раскрывают существование чужих строк и учитывают `soft_delete`. diff --git "a/wiki/\345\261\236\346\200\247.md" "b/wiki/\345\261\236\346\200\247.md" index b692505..efd45ce 100644 --- "a/wiki/\345\261\236\346\200\247.md" +++ "b/wiki/\345\261\236\346\200\247.md" @@ -454,6 +454,20 @@ sqlx::query(Order::MIGRATION_UP).execute(&pool).await?; - 声明的名称在编译期与枚举的 `PG_TYPE` 常量核对,不一致会导致构建失败 - `ValueObject` 的可选 `sqlx` 标志会生成 `sqlx::Type` / `Encode` / `Decode` 实现;若已自行 derive `sqlx::Type` 则省略 +### `#[scope(...)]` + +实体级。声明对同类型多个列的 OR 组查询——"该主体以任意角色参与的行"。 + +```rust +#[derive(Entity)] +#[entity(table = "disputes")] +#[scope(involving: requester_id | subject_id)] +#[scope(handled: requester_id | subject_id, within = parcel_id)] +pub struct Dispute { /* ... */ } +``` + +生成:`list_involving(value, limit, offset)` 和 `list_handled(parcel_id, value, limit, offset)`。值只绑定一次并与所有声明的列比较;`within` 会先把范围收窄到一个父行。至少需要两个列,且必须存在并类型一致——全部在编译期检查。支持软删除,按 id 倒序排列。 + ### `#[owner]` 行级所有权范围限定。标记承载所有者 id 的列后,仓库将获得范围限定方法——绝不泄露某行是否属于其他所有者,并遵循 `soft_delete`。 diff --git "a/wiki/\354\206\215\354\204\261.md" "b/wiki/\354\206\215\354\204\261.md" index 7d41a99..b4fd1aa 100644 --- "a/wiki/\354\206\215\354\204\261.md" +++ "b/wiki/\354\206\215\354\204\261.md" @@ -455,6 +455,20 @@ sqlx::query(Order::MIGRATION_UP).execute(&pool).await?; - 선언한 이름은 컴파일 타임에 enum의 `PG_TYPE` 상수와 대조 검증되며, 불일치 시 빌드가 실패합니다 - `ValueObject`의 선택적 `sqlx` 플래그는 `sqlx::Type` / `Encode` / `Decode` 구현을 생성합니다; 이미 `sqlx::Type`을 derive한다면 생략하세요 +### `#[scope(...)]` + +엔티티 수준. 같은 타입의 여러 컬럼에 대한 OR 그룹 조회를 선언합니다 — "이 주체가 어떤 역할로든 참여한 행". + +```rust +#[derive(Entity)] +#[entity(table = "disputes")] +#[scope(involving: requester_id | subject_id)] +#[scope(handled: requester_id | subject_id, within = parcel_id)] +pub struct Dispute { /* ... */ } +``` + +생성: `list_involving(value, limit, offset)`와 `list_handled(parcel_id, value, limit, offset)`. 값은 한 번만 바인딩되어 선언된 모든 컬럼과 비교되며, `within`은 먼저 그룹을 하나의 부모로 좁힙니다. 컬럼은 최소 두 개여야 하고, 실제로 존재하며 타입이 일치해야 합니다 — 모두 컴파일 타임에 검사됩니다. 소프트 삭제를 인식하고 id 내림차순으로 정렬합니다. + ### `#[owner]` 행 단위 소유권 스코핑. 소유 주체의 id를 담는 컬럼을 표시하면, 리포지토리에 스코프 메서드가 생성됩니다 — 다른 소유자의 행 존재 여부를 노출하지 않으며 `soft_delete`를 준수합니다.