feat(macros): scope column composes with find_by/list_for filters - #170
Merged
bodymindarts merged 4 commits intoAug 3, 2026
Merged
Conversation
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
marked this pull request as ready for review
August 3, 2026 07:15
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_foron the same column, contradicting the ratified v1 decision record ("the scope column stays a completely normal persisted column … can still befind_by/list_for"). This relaxes the validation and makes the predicates compose, so callers filter by the scope column through the normalFiltersstruct / 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 — underOnlythe column is simply double-specified, once as the caller's filter and once as the scope conjunct:AllAllpWHERE partner_id = pOnly(a)WHERE partner_id = aOnly(a)bWHERE partner_id = b AND partner_id = a— empty unlessa == bA mismatching caller value is a contradictory conjunct that honestly returns nothing (
NotFound/Noneforfind_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, noPartialEqrequirement on the scope type).Applies to
find_by_{scope_col}/maybe_find_by_{scope_col},list_for_{scope_col}_by_*, andlist_for_filters*(specialized sargable arms and the COALESCE catch-all).list_byon the scope column (a sort variant — no filter value) works through the existing generic scoped path.Backward compatibility
scopestill flips the column'sfind_bydefault to false (now folded intoColumnOpts::find_by()per review) — without explicit opt-in, generated code for existing scoped repos is unchanged; unscoped repos untouched.list_foradds a field to the generatedFiltersstruct — 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 invalidate_scope; scope-flippedfind_bydefault lives inColumnOpts::find_by()(self.find_by.unwrap_or(!self.scope))book/src/scoped-repositories.md— new "Filtering on the scope column" sectiontests/scoped_repo.rs(find_by + all filter combinations + cursor pagination + mismatch-with-cursor, via the default catch-all path) and newtests/scoped_repo_sargable.rs(same matrix through thesargable_filtersspecialized 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 checkgreen,nix run .#nextest256/256 passed.Related
.scoped()bound view), perf(macros): gate sargable multi-filter matrix behind opt-in #168 (sargable matrix opt-in) — all merged; branch cut from post-perf(macros): gate sargable multi-filter matrix behind opt-in #168 mainDepositAccountProductScope::narrowand moves the admin partner filter intoDepositAccountProductFilters(PR GaloyMoney/lana-bank#7758 follow-up)🤖 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
Filtersfields.Overview
Scoped repos can now opt in so the scope column (
partner_id, etc.) participates in normal query APIs:find_by = true,list_by, orlist_foron that column. Without opt-in, behavior is unchanged—scopestill defaultsfind_byoff and the scope argument alone drives reads.When opted in, generated
find_by_*,list_for_*, andFiltersinclude the scope column like any other filter. Authz scope and caller filters compose as conjuncts: underOnly(a)plus a caller filterSome(b), SQL ispartner_id = b AND partner_id = a, so a mismatch returns empty/NotFoundinstead of silently ignoring the caller value—filters can narrow scope, never widen it.Macro codegen fixes cursor bind positions in
list_for_filtersfallback queries when the scope column is also a filter column (param_idxinstead 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_filtersspecialized query matrix.Reviewed by Cursor Bugbot for commit 904a910. Bugbot is set up for automated code reviews on this repo. Configure here.