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