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
58 changes: 53 additions & 5 deletions book/src/scoped-repositories.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,56 @@ dereferenced, so they cannot be used to probe for the existence of foreign
ids. Replaying a cursor minted under a different scope yields well-defined
(scoped) but position-shifted results.

## Filtering on the scope column

By default the scope column generates no query surface of its own — every
read is already filtered by it, and per-scope listing *is* the ordinary
scoped `list_by_*(Only(value), ..)`. But some callers legitimately filter by
the scope column *through the normal query surface*: an all-access admin
listing that narrows to one tenant, for example. The scope value itself
(typically authz-derived) must never be touched by caller input — the
caller's choice belongs in the `Filters` struct like any other filter.

For that, the scope column may **opt into** `find_by = true`, `list_by` or
`list_for`:

```rust,ignore
partner_id(ty = "PartnerId", scope, find_by = true, list_for(by(created_at))),
```

This generates the usual fns (`find_by_partner_id`, `list_for_partner_id_by_*`)
and includes `partner_id: Option<PartnerId>` in the generated `Filters`
struct. The caller value **composes** with the scope — it can narrow, never
widen:

| Scope | Caller value | Result |
|-----------|--------------|---------------------------------------------------|
| `All` | none | unfiltered |
| `All` | `p` | `WHERE partner_id = p` |
| `Only(a)` | none | `WHERE partner_id = a` |
| `Only(a)` | `b` | `WHERE partner_id = b AND partner_id = a` — **empty unless `a == b`** |

Under `Only`, the column is simply double-specified — once as the caller's
filter, once as the scope conjunct, exactly like any other filter column. A
mismatching caller value is a contradictory predicate that honestly returns
an empty result (`NotFound`/`None` for `find_by_*`) instead of being
silently ignored — a caller filter can narrow but never widen the scope.
Both predicates are plain equalities, so the query stays sargable against a
scope-led index.

```rust,ignore
// admin listing: scope from authz, partner choice from the request
let scope = self.authz.enforce_permission(sub, obj, act).await?; // untouched
self.repo
.list_for_filters(
scope,
CustomerFilters { partner_id: request.partner_id, ..Default::default() },
sort,
args,
)
.await?
```

## Validation rules

The macro rejects at compile time:
Expand All @@ -121,14 +171,12 @@ The macro rejects at compile time:
- an `Option<T>` or `nullable`-annotated scope column (nullable scope columns
are not supported — every row must belong to exactly one scope)
- a `Forgettable<T>` scope column
- `find_by`, `list_by` or `list_for` on the scope column itself — every read
is already filtered by it; per-scope listing *is* the ordinary scoped
`list_by_*(Only(value), ..)`
- `scope` on nested repos — children are custody-guarded via their (scoped)
parent

The scope column also generates no `find_by_partner_id` accessors: the scope
argument replaces them.
Without an explicit opt-in (see above) the scope column generates no
`find_by_partner_id` accessors: `scope` flips the column's `find_by` default
to `false`, and the scope argument replaces them.

## Index requirements

Expand Down
14 changes: 4 additions & 10 deletions es-entity-macros/src/repo/list_for_filters_fn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,12 +479,6 @@ impl<'a> ListForFiltersFn<'a> {
};
let cursor_ident = cursor_struct.ident();

let n_filters: u32 = self
.for_columns
.iter()
.map(|c| if c.is_optional() { 2u32 } else { 1u32 })
.sum();

let destructure_tokens = cursor_struct.destructure_tokens();
let select_columns = cursor_struct.select_columns(None);
let cursor_arg_tokens = cursor_struct.query_arg_tokens();
Expand Down Expand Up @@ -570,28 +564,28 @@ impl<'a> ListForFiltersFn<'a> {
select_columns,
self.table_name,
filter_where,
cursor_struct.condition(n_filters + scope_offset, true),
cursor_struct.condition(param_idx - 1, true),
if delete == DeleteOption::No {
self.delete.not_deleted_condition()
} else {
""
},
cursor_struct.order_by(true),
n_filters + scope_offset + 1,
param_idx,
);
let desc_query = format!(
r#"SELECT {} FROM {} WHERE {}({}){} ORDER BY {} LIMIT ${}"#,
select_columns,
self.table_name,
filter_where,
cursor_struct.condition(n_filters + scope_offset, false),
cursor_struct.condition(param_idx - 1, false),
if delete == DeleteOption::No {
self.delete.not_deleted_condition()
} else {
""
},
cursor_struct.order_by(false),
n_filters + scope_offset + 1,
param_idx,
);
(asc_query, desc_query, fallback_arg_tokens)
};
Expand Down
63 changes: 56 additions & 7 deletions es-entity-macros/src/repo/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -600,7 +600,9 @@ mod tests {
}

#[test]
fn scope_column_with_query_flags_is_error() {
fn scope_column_composes_with_query_flags() {
// `scope` may coexist with `find_by = true`, `list_by` and `list_for`
// on the same column — the generated fns compose with the scope.
for extra in ["find_by = true", "list_by = true", "list_for"] {
let src = format!(
r#"
Expand All @@ -614,15 +616,62 @@ mod tests {
"#
);
let input: syn::DeriveInput = syn::parse_str(&src).unwrap();
let err = derive(input).unwrap_err();
assert!(
err.to_string()
.contains("cannot also be find_by, list_by or list_for"),
"unexpected error for `{extra}`: {err}"
);
derive(input)
.unwrap_or_else(|err| panic!("scope column with `{extra}` should derive: {err}"));
}
}

#[test]
fn scope_column_find_by_composes_via_conjunct() {
let input: syn::DeriveInput = parse_quote! {
#[es_repo(
entity = "User",
columns(partner_id(ty = "PartnerId", scope, find_by = true))
)]
struct Users {
pool: sqlx::PgPool,
}
};
let tokens = derive(input)
.expect("scoped repo should derive")
.to_string();
// explicit opt-in generates the find fns, scope argument included
assert!(tokens.contains("fn find_by_partner_id (& self , scope : impl Into < UserScope >"));
assert!(
tokens
.contains("fn maybe_find_by_partner_id (& self , scope : impl Into < UserScope >")
);
assert!(tokens.contains("WHERE partner_id = $1 AND partner_id = $2"));
}

#[test]
fn scope_column_list_for_composes_via_conjunct() {
let input: syn::DeriveInput = parse_quote! {
#[es_repo(
entity = "User",
columns(
partner_id(ty = "PartnerId", scope, list_for(by(created_at))),
status(ty = "String", list_for(by(created_at)))
)
)]
struct Users {
pool: sqlx::PgPool,
}
};
let tokens = derive(input)
.expect("scoped repo should derive")
.to_string();
// the Filters struct carries the scope column like any other
// list_for column
assert!(tokens.contains("pub struct UserFilters"));
assert!(tokens.contains("pub partner_id : Option < PartnerId >"));
assert!(tokens.contains("pub status : Option < String >"));
assert!(tokens.contains(
"fn list_for_partner_id_by_created_at (& self , scope : impl Into < UserScope >"
));
assert!(tokens.contains("(partner_id = $1) AND partner_id = $2"));
}

#[test]
fn scope_on_nested_child_repo_is_error() {
let input: syn::DeriveInput = parse_quote! {
Expand Down
20 changes: 4 additions & 16 deletions es-entity-macros/src/repo/options/columns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,7 @@ impl Columns {
}

pub fn all_find_by(&self) -> impl Iterator<Item = &Column> {
self.all
.iter()
.filter(|c| c.opts.find_by() && !c.opts.scope)
self.all.iter().filter(|c| c.opts.find_by())
}

pub fn all_list_by(&self) -> impl Iterator<Item = &Column> {
Expand All @@ -51,9 +49,6 @@ impl Columns {
/// `nullable`-annotated types are rejected — nullable scope columns are
/// a future feature)
/// - the scope column must not be `Forgettable<T>`
/// - the scope column must not also be a query column (`find_by`,
/// `list_by`, `list_for`) — every generated read is already filtered by
/// it, so per-tenant queries are the ordinary scoped fns
/// - the scope column must not be the `parent` column (nested repos
/// cannot be scoped — children are custody-guarded via their parent)
pub fn validate_scope(&self) -> darling::Result<()> {
Expand All @@ -78,15 +73,6 @@ impl Columns {
col.name(),
)));
}
if col.opts.find_by == Some(true)
|| col.opts.list_by == Some(true)
|| col.opts.list_for_opts.is_some()
{
return Err(darling::Error::custom(format!(
"scope column '{}' cannot also be find_by, list_by or list_for — every read is already filtered by it",
col.name(),
)));
}
if col.opts.parent_opts.is_some() {
return Err(darling::Error::custom(format!(
"scope column '{}' cannot be the parent column — nested repos cannot be scoped",
Expand Down Expand Up @@ -836,7 +822,9 @@ impl ColumnOpts {
}

fn find_by(&self) -> bool {
self.find_by.unwrap_or(true)
// `scope` flips the default to false — every read is already
// filtered by the scope column; explicit `find_by = true` opts in.
self.find_by.unwrap_or(!self.scope)
}

fn list_by(&self) -> bool {
Expand Down
Loading
Loading