Skip to content

Introduce visibility filters - #165

Open
nilmerg wants to merge 9 commits into
mainfrom
feature/visibility-filters-76
Open

Introduce visibility filters#165
nilmerg wants to merge 9 commits into
mainfrom
feature/visibility-filters-76

Conversation

@nilmerg

@nilmerg nilmerg commented Jul 23, 2026

Copy link
Copy Markdown
Member

Overview

Adds a way to always constrain query results, independent of the filters a caller supplies — useful for row-level access rules (soft-deletes, tenant scoping, …). Two kinds of constraint are introduced:

  • Model visibility filterModel::createVisibilityFilter(Filter\Chain $filter). References the model's own columns only. Applied to the base model as a WHERE clause and, whenever the model is joined, added to the JOIN condition.
  • Relation filterRelation::setFilter(Filter\Rule $filter), plus BelongsToMany::setThroughFilter() for the junction. Applied as extra JOIN conditions. A relation filter may reference either the source or the target table by its alias (target by default), and the through filter the source or junction table.

Filter columns must be actual columns of the referenced table and comparison values are passed as-is to the query builder (behaviors are not applied); ExpressionInterface values are supported under the same rules.

How it works

Qualification happens in two stages so a relation filter can be applied unchanged regardless of join direction:

  1. Resolver::resolveRelationFilter() (at relation-resolution time) rewrites each column to a logical alias — the source's or target's table alias.
  2. Resolver::qualifyFilter(Filter\Chain, Model ...$subjects) (at assembly time) maps those logical aliases to the runtime aliases, with both participating models in scope.

Because the filter addresses tables by alias rather than by role, it resolves correctly when the FilterProcessor reverses a join into a correlated sub-query (filtering on a to-many relation) and when derive() lazy-loads a relation — the source and target simply swap places and qualifyFilter still finds them.

Changes

  • ModelcreateVisibilityFilter() hook (no-op by default).
  • RelationsetFilter()/getFilter(); resolve() now yields the relation to join as the generator key (a numeric key still works, with a deprecation notice).
  • BelongsToManysetThroughFilter()/getThroughFilter(); propagates the through/relation filters and the join type to the junction and target joins.
  • ResolvergetVisibilityFilter() (resolve + cache a model's filter), resolveRelationFilter() and qualifyFilter(); resolves a relation's filters when resolving the relation.
  • Query — applies the visibility filter to the base WHERE and to joined models, and relation/through filters to their JOIN conditions; carries relation filters into reversed sub-queries and derive().

Tests

  • Unit coverage for the new Relation/BelongsToMany accessors and for Resolver::resolveRelationFilter() / qualifyFilter() / getVisibilityFilter() (target- and source-referencing columns, alias/column validation, junction handling, deep-clone).
  • SQL coverage in VisibilityFilterTest for base WHERE, joined model visibility filters, relation and through filters, a relation filter referencing the source table, and — via the FilterProcessor — the same filters propagating unchanged through the reversed joins of a sub-query.
  • FilterProcessorTest runs against MySQL and PostgreSQL.

🤖 Generated with Claude Code

nilmerg and others added 9 commits July 28, 2026 13:05
Visibility filters are supposed to constrain a model's query result at
all times. Be it by selecting from it or joining it. Usecases such
as soft-deletions are thus made possible to guarantee that ORM queries
never fetch related rows.
Relation filters are supposed to constrain joined or eager
loaded results, but only given the direction they were
declared in. For example, if the opposite relation does
not declare the same or any filter at all, joining from
this side does not constrain the result the same way as
the other way round.
…tic`

This is the equivalent of `Relation::setFilter()` but for the junction
table.
…ubjects): Filter\Chain`

Utility method to qualify a simple filter using only base table
columns (e.g. `'base.column'`)
…el $source, Model $target): void`

Supposed to validate and resolve relation filters so that they
reference a model which `qualifyFilter` can work with.
Provides access to a model's visibility filter and resolves it
as well, similar to what `resolveRelationFilter` does but for
relation filters.
* `getSelectBase` applies the base model's visibility filters.
* `assembleSelect` applies visibility and relation filters to joins.
  This introduces a compatibility related change to `Relation::resolve()`
  which now yields a key and extending classes need to re-yield it.
  `BelongsToMany` is such a case and did that already for a long time.
  A deprecation notice will appear if a relation still yields an int.
* `derive` applies filters of the relation that is eagerly loaded
  as otherwise only visibility filters would apply.
* `createSubQuery` overrides the filters of the reversed relations
  with those of the original path, preserving the semantics as if
  the results were joined normally.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nilmerg
nilmerg force-pushed the feature/visibility-filters-76 branch from c5bde13 to f7a4ce3 Compare July 28, 2026 11:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant