docs(spec): add OperatorFilter.where() ergonomic constructor

Adds a Django-.filter()-style where(**lookups) classmethod (Component 1b)
to issue #56 spec. Additive: keeps the typed explicit constructor intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RF5L4HtbcykTfY9YUYGds3
This commit is contained in:
2026-06-21 08:38:36 +02:00
parent 260169b521
commit b9545a780b
@@ -25,6 +25,7 @@ The acceptance criteria are:
**In scope (this work):** **In scope (this work):**
- The core `filter_url()` helper. - The core `filter_url()` helper.
- The `OperatorFilter.where()` ergonomic constructor.
- The date-range filtering capability the navbar links require. - The date-range filtering capability the navbar links require.
- Wiring the navbar "today" / "last 7 days" totals as links. - Wiring the navbar "today" / "last 7 days" totals as links.
@@ -100,6 +101,81 @@ def filter_url(filter_obj: OperatorFilter, **extra_params: str) -> str:
now. now.
- `filter_presets.py` can adopt this helper later; not required for this change. - `filter_presets.py` can adopt this helper later; not required for this change.
### Component 1b — `OperatorFilter.where()` (ergonomic construction)
Building filters via the explicit constructor is verbose, because each criterion
must be wrapped and a `Modifier` imported:
```python
GameFilter(
purchase_count=IntCriterion(value=1, modifier=Modifier.GREATER_THAN),
playtime_hours=IntCriterion(modifier=Modifier.IS_NULL),
)
```
Add a `where(**lookups)` classmethod on `OperatorFilter` (so every filter type
inherits it) accepting Django-`QuerySet.filter()`-style `field__modifier=value`
lookups:
```python
GameFilter.where(purchase_count__gt=1, playtime_hours__isnull=True)
# combined with filter_url():
filter_url(GameFilter.where(purchase_count__gt=1, playtime_hours__isnull=True))
# the navbar filters become:
filter_url(SessionFilter.where(timestamp_start=today_iso))
filter_url(SessionFilter.where(timestamp_start__between=(week_ago_iso, today_iso)))
```
How it works (no new architecture — it builds the same dataclass instances):
1. Split each kwarg into `(field_name, suffix)` on the last `__`.
2. Resolve the field's criterion class from its dataclass annotation, reusing the
logic `from_json` already has (`common/criteria.py:439-473`). Factor that
resolution into a shared helper (e.g. `_criterion_class_for(cls, field)`) so
`from_json` and `where()` cannot drift — a small de-duplication bonus.
3. Map the suffix to a `Modifier` (see table); no suffix → the criterion type's
natural default (`EQUALS` for scalar/string/bool, `INCLUDES` for the set
criteria `MultiCriterion` / `ChoiceCriterion`).
4. Build the concrete criterion: scalar → `value`; a 2-tuple → `value` / `value2`
(for `between` / `not_between`); a list → the set criterion's `value`.
5. Return a normal filter instance.
Suffix → `Modifier` map:
| suffix | Modifier |
|---------------|-----------------|
| *(none)* | `EQUALS` (scalar/string/bool) / `INCLUDES` (set) |
| `gt` | `GREATER_THAN` |
| `lt` | `LESS_THAN` |
| `ne` | `NOT_EQUALS` |
| `between` | `BETWEEN` (value is a 2-tuple) |
| `not_between` | `NOT_BETWEEN` (value is a 2-tuple) |
| `in` | `INCLUDES` (set) |
| `exclude` | `EXCLUDES` (set) |
| `all` | `INCLUDES_ALL` (set) |
| `contains` | `INCLUDES` (string `icontains`) |
| `regex` | `MATCHES_REGEX` |
| `isnull` | `IS_NULL` (value ignored) |
| `notnull` | `NOT_NULL` (value ignored) |
Design decisions:
- **Purely additive.** The explicit constructor, `to_q()`, and serialization are
unchanged. `where()` is chosen over a custom `__init__` precisely to keep the
explicit `GameFilter(name=StringCriterion(...))` form **fully statically typed**
(a `@dataclass(init=False)` + `**kwargs` constructor would have erased that).
- **Real field names, no aliasing.** Lookups use the actual dataclass field names
(e.g. `playtime_hours`, not a prettier `playtime`); no alias layer to maintain.
- **Fail loud.** An unknown field name or an unknown/!type-incompatible suffix
raises a clear `ValueError`/`TypeError` rather than silently producing an empty
filter. The lookup form is dynamic (like Django's `.filter()`), so this runtime
validation replaces static checking for that form only.
- **Scope.** `where()` covers the common flat-AND case (the verbose pain point).
`AND` / `OR` / `NOT` nesting continues to use the explicit constructor, which
reads fine and is rare.
### Component 2 — date-range filtering on session timestamps ### Component 2 — date-range filtering on session timestamps
**Decision: switch `SessionFilter.timestamp_start` / `timestamp_end` to **Decision: switch `SessionFilter.timestamp_start` / `timestamp_end` to
@@ -144,8 +220,8 @@ The two navbar filters expressed with this:
### Component 3 — navbar wiring (and a consistency fix) ### Component 3 — navbar wiring (and a consistency fix)
- `model_counts` (`games/views/general.py`) computes `today_url` and - `model_counts` (`games/views/general.py`) computes `today_url` and
`last_7_url` with `filter_url(SessionFilter(...))` and adds them to its `last_7_url` with `filter_url(SessionFilter.where(...))` (see Component 1b) and
returned dict alongside the existing formatted totals. adds them to its returned dict alongside the existing formatted totals.
- `NavbarPlaytime()` (`common/layout.py`) gains `today_url` / `last_7_url` - `NavbarPlaytime()` (`common/layout.py`) gains `today_url` / `last_7_url`
parameters and wraps each total string in an `<a href>`. The out-of-band parameters and wraps each total string in an `<a href>`. The out-of-band
refresh call in `games/views/session.py:311` passes the new URLs too. refresh call in `games/views/session.py:311` passes the new URLs too.
@@ -165,6 +241,14 @@ so only the 7-day computation changes.
`filter` query param is the URL-encoded `filter_to_json(filter_obj)`; extra `filter` query param is the URL-encoded `filter_to_json(filter_obj)`; extra
params are merged; the produced URL round-trips through `parse_session_filter` params are merged; the produced URL round-trips through `parse_session_filter`
to an equivalent filter. to an equivalent filter.
- **`where()`** (unit): each suffix maps to the right `Modifier`; the resolved
criterion class matches the field annotation across criterion types
(`Int`/`String`/`Bool`/`Date`/`Multi`/`Choice`); `between` consumes a 2-tuple
into `value`/`value2`; no-suffix defaults to `EQUALS` for scalars and
`INCLUDES` for set criteria; `isnull`/`notnull` ignore the value;
`GameFilter.where(purchase_count__gt=1, playtime_hours__isnull=True)` produces a
filter equal to the explicit construction and yields the same `to_q()`; an
unknown field or suffix raises.
- **Date filtering** (unit/db): sessions started today / 3 days ago / 10 days ago - **Date filtering** (unit/db): sessions started today / 3 days ago / 10 days ago
fall into the correct buckets for the "today" and "last 7 days" filters via fall into the correct buckets for the "today" and "last 7 days" filters via
`SessionFilter.to_q()`. `SessionFilter.to_q()`.