Skip to content

feat(macros): scope column composes with find_by/list_for filters - #170

Merged
bodymindarts merged 4 commits into
mainfrom
task/es-entity-scope-composes-filters-019fc340
Aug 3, 2026
Merged

feat(macros): scope column composes with find_by/list_for filters#170
bodymindarts merged 4 commits into
mainfrom
task/es-entity-scope-composes-filters-019fc340

Conversation

@bodymindarts

@bodymindarts bodymindarts commented Aug 2, 2026

Copy link
Copy Markdown
Member

Summary

Implements the drua task doc es-entity-dev/task-scope-column-composes-with-filters.md (origin: lana-bank deposit unification review, GaloyMoney/lana-bank#7758).

The shipped scoped-repositories validation (#166) rejected scope + find_by/list_by/list_for on the same column, contradicting the ratified v1 decision record ("the scope column stays a completely normal persisted column … can still be find_by/list_for"). This relaxes the validation and makes the predicates compose, so callers filter by the scope column through the normal Filters struct / query surface — the authz-derived scope value reaches the repo untouched.

Semantics

The scope column may opt into find_by = true / list_by / list_for. When it does, its generated fns treat it like any other filter column — under Only the column is simply double-specified, once as the caller's filter and once as the scope conjunct:

Scope Caller value SQL / 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 = aempty unless a == b

A mismatching caller value is a contradictory conjunct that honestly returns nothing (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 every arm stays sargable against a scope-led index. No special-casing in the emitters: an earlier revision short-circuited the mismatch in Rust, removed per review feedback in favor of this uniform conjunct (simpler codegen, no PartialEq requirement on the scope type).

Applies to find_by_{scope_col} / maybe_find_by_{scope_col}, list_for_{scope_col}_by_*, and list_for_filters* (specialized sargable arms and the COALESCE catch-all). list_by on the scope column (a sort variant — no filter value) works through the existing generic scoped path.

Backward compatibility

  • scope still flips the column's find_by default to false (now folded into ColumnOpts::find_by() per review) — without explicit opt-in, generated code for existing scoped repos is unchanged; unscoped repos untouched.
  • Opting the scope column into list_for adds a field to the generated Filters struct — a source-level change for exhaustive initializers in the opting repo only (use ..Default::default()).

Changes

  • options/columns.rs — drop the find_by/list_by/list_for rejection in validate_scope; scope-flipped find_by default lives in ColumnOpts::find_by() (self.find_by.unwrap_or(!self.scope))
  • book/src/scoped-repositories.md — new "Filtering on the scope column" section
  • tests: macro token tests (conjunct SQL shape, Filters field, opt-in fns), integration tests in tests/scoped_repo.rs (find_by + all filter combinations + cursor pagination + mismatch-with-cursor, via the default catch-all path) and new tests/scoped_repo_sargable.rs (same matrix through the sargable_filters specialized arms, perf(macros): gate sargable multi-filter matrix behind opt-in #168)

Verification

CI (GitHub Actions) runs fmt / clippy / nextest on each push. Initial revision also verified locally: nix flake check green, nix run .#nextest 256/256 passed.

Related

🤖 Generated with Claude Code


Note

Medium Risk
Changes scoped-repository read SQL and multi-tenant filter composition (authz scope vs caller input); opt-in preserves existing repos, but adopters must understand mismatch semantics and new Filters fields.

Overview
Scoped repos can now opt in so the scope column (partner_id, etc.) participates in normal query APIs: find_by = true, list_by, or list_for on that column. Without opt-in, behavior is unchanged—scope still defaults find_by off and the scope argument alone drives reads.

When opted in, generated find_by_*, list_for_*, and Filters include the scope column like any other filter. Authz scope and caller filters compose as conjuncts: under Only(a) plus a caller filter Some(b), SQL is partner_id = b AND partner_id = a, so a mismatch returns empty/NotFound instead of silently ignoring the caller value—filters can narrow scope, never widen it.

Macro codegen fixes cursor bind positions in list_for_filters fallback queries when the scope column is also a filter column (param_idx instead of a stale filter count).

Docs add a “Filtering on the scope column” section; integration tests cover find_by, all scope×filter combinations, cursor pagination, and the sargable_filters specialized query matrix.

Reviewed by Cursor Bugbot for commit 904a910. Bugbot is set up for automated code reviews on this repo. Configure here.

bodymindarts and others added 4 commits August 2, 2026 18:46
Relax the scoped-repository validation so the scope column may also be a
query column (find_by = true, list_by, list_for), per the ratified v1
decision that the scope column stays a completely normal persisted column.
Callers filter by the scope column through the normal Filters struct /
query surface instead of mutating the authz-derived scope value.

The caller value composes with the scope and can narrow but never widen:

- All   + value        -> plain filter arm (col = $1)
- Only(a) + value b!=a -> short-circuits in Rust to an empty result /
                          NotFound / None without a database roundtrip
- Only(a) + value a    -> collapses into the scope predicate (single
                          sargable equality, no double predicate SQL)

The scope column keeps its flipped find_by default (no query fns without
explicit opt-in), so generated code for existing scoped and unscoped
repos is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…:find_by

Review feedback: the scope-column default belongs in the accessor itself —
`self.find_by.unwrap_or(!self.scope)` — instead of special-casing in
Columns::all_find_by.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ified conjunct

Review feedback: the Rust-side short circuit (mismatch guard + reused
single-predicate query) added a special case to every read emitter. When
the scope column is also a query column, simply double-specify it — once
as the caller's filter, once as the scope conjunct
(`col = $1 AND col = $2`). A mismatch is a contradictory predicate
returning no rows, which is exactly the intended composition semantics
(and the task doc's original spec).

Removes ScopeInfo::is_scope_column, the fn-entry guard in
list_for_filters, and the per-emitter conditionals; also drops the
implicit PartialEq requirement on the scope column type. Behavior is
unchanged (empty result on mismatch) apart from the degenerate mismatch
case now costing one DB roundtrip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…unct

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bodymindarts
bodymindarts marked this pull request as ready for review August 3, 2026 07:15
@bodymindarts
bodymindarts merged commit a2659f2 into main Aug 3, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant