Skip to content

Implement filters - #156

Merged
azmeuk merged 14 commits into
mainfrom
17-filters
Sep 10, 2026
Merged

azmeuk merged 14 commits into
mainfrom
17-filters

Conversation

@azmeuk

@azmeuk azmeuk commented Sep 1, 2026

Copy link
Copy Markdown
Member

Fixes #17

@azmeuk
azmeuk force-pushed the 17-filters branch 9 times, most recently from 7edb41c to cc891d5 Compare September 8, 2026 12:37
@azmeuk
azmeuk force-pushed the 17-filters branch 2 times, most recently from 05adddc to c6eabb1 Compare September 10, 2026 08:25
ScimFilter parses the filter grammar of RFC7644 §3.4.2.2, on a new lark
dependency, and evaluates it against Python objects. Parsing, resolution
against a model and evaluation are separate stages, so FilterVisitor can
transpile a tree into a backend query rather than evaluate it. Binding a filter
checks it as it is created, against one resource type or against a union of
them: §3.4.2.1 has an attribute only some of them declare evaluate to false on
the others, so only an attribute none declares is refused. The grammar and the
resolution sit above the package, attrPath being one ABNF rule that RFC7644
uses in both FILTER and PATH (§3.5.2), and neither the classes a subscription
answers nor the resolutions it caches outlive a resource type built at runtime.
Path validates against the PATH rule of RFC7644 §3.5.2, as corrected by errata
7122, instead of a permissive character pattern, so a.b.c is now rejected. get,
set and delete honour the selection, letting PatchOp.patch address individual
entries of a multi-valued attribute: a selection matching nothing raises
noTarget for replace, as §3.5.2.3 mandates, and is a no-op for remove and add,
which §3.5.2.2 and errata 8097 leave that way. The filter between the brackets
is checked against the model before the resource is read, so an undeclared
attribute is reported as invalidFilter whether or not the selected attribute
holds values, and the three spellings the errata offers for one selection all
answer that filter expressed against an entry.
Path carried its own resolution next to the one the filter engine brought in,
and the two disagreed: attr rendered the whole parsed node, so a value
selection left every model-aware property None. Path.resolve is now the single
resolution, reading the attribute off the normalised path so that the three
spellings of one selection designate the same one, and over the 153 paths
iter_paths yields for User, User[EnterpriseUser] and Group, only
x509Certificates.value changes, now reported unwrapped as bytes.
Both are strings whose attribute names only mean something against a model, and
both carried their own subscription, class cache and pydantic plumbing. They
now derive from _BoundToModels, which keeps the generated class name and the
error messages each of them used to build by hand, and turns whatever either
gets wrong into a pydantic error, where only a path used to name the field it
was read from and carry its scimType.
SearchRequest.filter is a ScimFilter instead of a str, so a malformed filter is
rejected at validation time, as a pydantic error naming the field and carrying
its scimType; it is a str subclass, so anything reading one still reads a
string. Naming the resource types the endpoint serves, as in
SearchRequest[User] or SearchRequest[User | Group], is what also refuses an
attribute none of them declares, where an unparameterised request only has its
syntax checked.
Adds a filters page covering the grammar, the operators, the deviations from
the published ABNF and a worked transpiler built on FilterVisitor, and a
tutorial section on filters and PATCH value selections. The framework guides
now filter the collections they serve: a filter applies to the SCIM
representation, so the store is mapped first, filtered, paginated last, and
the root query binds every resource type it gathers.
Replaces the in-memory storage layer of the integration guides with a database,
where the filter becomes a WHERE clause and the order an ORDER BY next to the
pagination, so a page costs a query over the matching rows rather than a walk
over all of them. The clause follows the three rules of RFC7644 §3.4.2.3, case
folding included, and closes on the primary key so a page stays reproducible
without a sortBy. Twenty-three filters and twelve orderings run both as a query
and through match() and sort_resources, an oracle that caught SQLite's naive
datetime, its case-folding LIKE, and a case test on str that skipped
emails.value.
Reading, writing and removing through a path resolved it against the type of
the resource by splitting the string again: a schema URN matched by prefix, the
segments looked up one by one. Resolving against the bound model had moved to
the attribute resolver, and so had value selections, so the same question was
answered three ways. The walk over an instance now takes the AttrPath the path
designates to the resolver, and lands on the objects a ResolvedAttribute names;
the bare schema URN is recognised by one function the bound and the instance
side share. The depth the string walk allowed is one the grammar refuses at
construction, so nothing reachable is lost.
The constraint checks of a PATCH went through a private helper of Path that
resolved the path and kept only the model and the head attribute, next to a
local copy of the function answering the object an attribute lives on. Both
are what Path.resolve and attribute_host already answer, so PatchOp calls them
and Path carries nothing that only PatchOp reads.
Path carried two halves that shared nothing but the parsed form: what a path
designates on a model, and how it reads and writes an instance. The second
now lives in its own module, as functions taking the path they act for, and
Path keeps its declarative surface next to the three methods that delegate
to them.
Filters and paths share one grammar with two start rules, one syntax tree, one
resolver and one evaluator, yet the modules answering for them were split
between a filters package and five modules at the root of the distribution,
with the public namespace re-exporting mostly what lived outside it. Every
filter is built on attribute paths where a path may carry no filter, so the
attribute path is the notion the whole toolkit rests on, and the package is
named for it. scim2_models.filters is gone; from scim2_models.path import Path
keeps working.
A one-line docstring opens each module of the package, so that a reader tells
apart what answers for the grammar, the syntax tree, the resolution against a
model, the evaluation, and the access to an instance.
Next to Attribute, the description of an attribute a schema publishes, the name
read as a variant of it. The object is the binding of a syntactic attribute
path to the Python class and field it designates on a model, which is what the
evaluator and transpilers consume, and the name now says so.
…gnates

Path carried six properties reading the attribute it designates, written before
AttributeBinding existed and with its words turned around: path.model named the
type holding the sub-attribute where the binding names the one holding the
head. Five of them go, the binding carrying each under a target_ name, and
get_annotation joins it. Path.model keeps the one answer no binding gives, the
model a resource root or a bare schema URN designates, and answers None for a
path that names an attribute.
@azmeuk
azmeuk marked this pull request as ready for review September 10, 2026 08:58
@azmeuk
azmeuk merged commit 6d15095 into main Sep 10, 2026
21 of 25 checks passed
@azmeuk
azmeuk deleted the 17-filters branch September 10, 2026 08:59
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.

Support filters in SearchQuery and PatchOp

1 participant