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:
@@ -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 `<a href>`. 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()`.
|
||||
|
||||
Reference in New Issue
Block a user