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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/entity-derive-impl/src/entity/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions crates/entity-derive-impl/src/entity/parse/entity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ mod helpers;
mod index;
mod join;
mod projection;
mod scope;
mod transition;
mod upsert;

Expand All @@ -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;

Expand Down
56 changes: 55 additions & 1 deletion crates/entity-derive-impl/src/entity/parse/entity/constructor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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<String> = fields
.iter()
.map(super::super::field::FieldDef::name_str)
Expand Down Expand Up @@ -341,6 +344,7 @@ impl EntityDef {
extensions: attrs.migrations.extensions,
indexes,
joins,
scopes,
transitions,
aggregate_root: attrs.aggregate_root,
upsert: attrs.upsert,
Expand Down Expand Up @@ -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],
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 @@ -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,
Expand Down Expand Up @@ -242,6 +242,12 @@ pub struct EntityDef {
/// Non-empty when the entity generates a `{Entity}View`.
pub joins: Vec<JoinDef>,

/// Participant scopes from `#[scope(...)]`.
///
/// Each entry generates a `list_{name}` method ORing the declared
/// columns against one bound value.
pub scopes: Vec<ScopeDef>,

/// State-machine transitions from `#[transition(...)]`.
///
/// Each entry generates a locking `transition_to_{target}` method
Expand Down
173 changes: 173 additions & 0 deletions crates/entity-derive-impl/src/entity/parse/entity/scope.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// SPDX-FileCopyrightText: 2025-2026 RAprogramm <andrey.rozanov.vl@gmail.com>
// 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<String>,
/// Optional column the scope is narrowed by first, AND-ed before
/// the OR group.
pub within: Option<String>
}

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<Vec<ScopeDef>> {
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<ScopeDef> {
let name: Ident = input.parse()?;
input.parse::<syn::Token![:]>().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::<syn::Token![|]>()?;
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::<syn::Token![,]>()?;
let option: Ident = input.parse()?;
if option != "within" {
return Err(syn::Error::new(
option.span(),
"unknown scope option; expected `within = column`"
));
}
input.parse::<syn::Token![=]>()?;
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<Vec<ScopeDef>> {
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());
}
}
Loading