From b9545a780bdaf7f0cb6a40670b54c7e5ad25097b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 21 Jun 2026 08:38:36 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01RF5L4HtbcykTfY9YUYGds3 --- ...sue-56-programmatic-filter-links-design.md | 88 ++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-06-21-issue-56-programmatic-filter-links-design.md b/docs/superpowers/specs/2026-06-21-issue-56-programmatic-filter-links-design.md index c095831..152610e 100644 --- a/docs/superpowers/specs/2026-06-21-issue-56-programmatic-filter-links-design.md +++ b/docs/superpowers/specs/2026-06-21-issue-56-programmatic-filter-links-design.md @@ -25,6 +25,7 @@ The acceptance criteria are: **In scope (this work):** - The core `filter_url()` helper. +- The `OperatorFilter.where()` ergonomic constructor. - The date-range filtering capability the navbar links require. - 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. - `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 **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) - `model_counts` (`games/views/general.py`) computes `today_url` and - `last_7_url` with `filter_url(SessionFilter(...))` and adds them to its - returned dict alongside the existing formatted totals. + `last_7_url` with `filter_url(SessionFilter.where(...))` (see Component 1b) and + adds them to its returned dict alongside the existing formatted totals. - `NavbarPlaytime()` (`common/layout.py`) gains `today_url` / `last_7_url` parameters and wraps each total string in an ``. The out-of-band 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 params are merged; the produced URL round-trips through `parse_session_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 fall into the correct buckets for the "today" and "last 7 days" filters via `SessionFilter.to_q()`.