Align set-criterion modifiers with Stash (any/all/none) and harmonize EXCLUDES

Closes #10.

Backend (common/criteria.py):
- Treat `excludes` as an always-orthogonal AND'd negative across both
  MultiCriterion and ChoiceCriterion; the modifier now governs only the
  `value` (include) set. This removes the prior divergence where
  MultiCriterion.EXCLUDES dropped the excludes list and ChoiceCriterion.EXCLUDES
  swapped include/exclude into a positive.
- Fold INCLUDES / INCLUDES_ALL / EXCLUDES (+ EQUALS/NOT_EQUALS aliases) into the
  shared _SetCriterion base so the two subclasses cannot drift; remove _extra_q.

M2M "has all" (games/filters.py):
- PurchaseFilter._games_to_q builds a pk__in subquery with one join per value so
  INCLUDES_ALL on the many-to-many games field works in a single .filter()
  (a naive Q(games=a) & Q(games=b) collapses to one join and matches nothing).

UI (FilterSelect + filter_bar.js):
- Add an optional any/all/none match-mode <select> (INCLUDES/INCLUDES_ALL/
  EXCLUDES) rendered before the pills via a new `leading` slot on the shared
  combobox shell. A native control so its value is its state. readSearchSelect
  serialises it to data-match; filter_bar folds it into the criterion modifier.
  Orthogonal to the (Any)/(None) presence pseudo-options and the exclude channel.
- Enable it for the M2M Purchase.games field (INCLUDES_ALL is only meaningful
  for multi-valued relations). Styled with already-compiled utilities.

Tests: harmonized EXCLUDES + INCLUDES_ALL for both criterion types, a DB-backed
INCLUDES_ALL vs INCLUDES contrast on Purchase.games, and FilterSelect /
PurchaseFilterBar rendering + round-trip of the match mode.

https://claude.ai/code/session_01KwVrGFbq13mZdhDL9G6zhg
This commit is contained in:
Claude
2026-06-08 20:08:50 +00:00
committed by Lukáš Kucharczyk
parent 05534875d6
commit ba9b92d419
9 changed files with 419 additions and 72 deletions
+57 -49
View File
@@ -271,17 +271,26 @@ class _SetCriterion(_Criterion):
"""Shared base for set-membership criteria (``MultiCriterion`` /
``ChoiceCriterion``).
``value`` is the include set and ``excludes`` the exclude set. The common
modifiers are implemented once here so the two subclasses cannot drift:
Two orthogonal channels, mirroring Stash's modifier model:
- ``INCLUDES`` — in ``value`` (when non-empty) AND not in ``excludes`` (when
non-empty). Empty lists contribute no constraint, so an exclude-only
criterion means "everything except ``excludes``".
- ``EQUALS`` — alias of ``INCLUDES``.
- ``IS_NULL`` / ``NOT_NULL`` — presence; the lists are ignored.
- ``value`` is the *include* set. The ``modifier`` governs how it matches:
Subclasses contribute their own modifiers (e.g. ``INCLUDES_ALL``) by
overriding ``_extra_q``.
- ``INCLUDES`` — in ``value`` (match *any*); ``EQUALS`` is an alias.
- ``INCLUDES_ALL`` — related to *all* of ``value`` (meaningful for
many-to-many fields, e.g. a purchase's games).
- ``EXCLUDES`` — in none of ``value`` (match *none*); ``NOT_EQUALS`` is an
alias.
- ``excludes`` is an *always-orthogonal* negative: it contributes
``AND NOT IN (excludes)`` for every (non-presence) modifier, never
swapped into the include set. An exclude-only criterion therefore means
"everything except ``excludes``".
Empty lists contribute no constraint. ``IS_NULL`` / ``NOT_NULL`` test
presence and ignore both lists.
The logic lives entirely here so the two subclasses (which differ only in
their value type) cannot drift.
"""
value: list = field(default_factory=list)
@@ -290,25 +299,37 @@ class _SetCriterion(_Criterion):
def to_q(self, field_name: str) -> Q:
modifier = self.modifier
if modifier in (Modifier.INCLUDES, Modifier.EQUALS):
q = Q()
if self.value:
q &= Q(**{f"{field_name}__in": self.value})
if self.excludes:
q &= ~Q(**{f"{field_name}__in": self.excludes})
return q
if modifier == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if modifier == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
extra = self._extra_q(field_name)
if extra is not None:
return extra
raise ValueError(f"Unsupported modifier {modifier} for {type(self).__name__}")
# The modifier governs only the include set; ``excludes`` is an orthogonal
# AND'd negative applied for every (non-presence) modifier.
q = self._value_q(field_name)
if self.excludes:
q &= ~Q(**{f"{field_name}__in": self.excludes})
return q
def _extra_q(self, field_name: str) -> Q | None:
"""Hook for subclass-specific modifiers; ``None`` means unsupported."""
return None
def _value_q(self, field_name: str) -> Q:
"""Build the Q for the include (``value``) set, per the modifier."""
modifier = self.modifier
if modifier in (Modifier.INCLUDES, Modifier.EQUALS):
return Q(**{f"{field_name}__in": self.value}) if self.value else Q()
if modifier in (Modifier.EXCLUDES, Modifier.NOT_EQUALS):
return ~Q(**{f"{field_name}__in": self.value}) if self.value else Q()
if modifier == Modifier.INCLUDES_ALL:
# Logical AND of equalities ("related to every value"). NOTE: for a
# *multi-valued* relation this only behaves as "has all" when each
# equality lands on its own join — i.e. applied via chained
# ``.filter()`` calls or a ``pk__in`` subquery, not a single
# ``.filter(Q(rel=a) & Q(rel=b))`` (which would require one related
# row to equal both). M2M callers (e.g. PurchaseFilter.games) build
# that subquery; see PurchaseFilter._games_to_q.
q = Q()
for value in self.value:
q &= Q(**{field_name: value})
return q
raise ValueError(f"Unsupported modifier {modifier} for {type(self).__name__}")
@classmethod
def from_json(cls, data: dict | None) -> Self | None:
@@ -317,51 +338,38 @@ class _SetCriterion(_Criterion):
return None
# Labels embedded as {id, label} dicts are display-only; strip to bare ids
# so the querying layer stays clean and typed.
result.value = [item["id"] if isinstance(item, dict) else item for item in result.value]
result.excludes = [item["id"] if isinstance(item, dict) else item for item in result.excludes]
result.value = [
item["id"] if isinstance(item, dict) else item for item in result.value
]
result.excludes = [
item["id"] if isinstance(item, dict) else item for item in result.excludes
]
return result
@dataclass
class MultiCriterion(_SetCriterion):
"""Filter on a many-to-many or ForeignKey relationship by ID list."""
"""Filter on a many-to-many or ForeignKey relationship by ID list.
All modifier logic (including ``INCLUDES_ALL`` and ``EXCLUDES``) lives in
``_SetCriterion``; this subclass only refines the value type.
"""
value: list[int] = field(default_factory=list)
excludes: list[int] = field(default_factory=list)
def _extra_q(self, field_name: str) -> Q | None:
if self.modifier == Modifier.EXCLUDES:
return ~Q(**{f"{field_name}__in": self.value})
if self.modifier == Modifier.INCLUDES_ALL:
q = Q()
for value in self.value:
q &= Q(**{field_name: value})
return q
return None
@dataclass
class ChoiceCriterion(_SetCriterion):
"""Filter on a choice/enum field with multi-select include/exclude.
Used by FilterSelect widgets for status, ownership_type, etc.
Used by FilterSelect widgets for status, ownership_type, etc. Shares all
modifier logic with ``MultiCriterion`` via ``_SetCriterion``.
"""
value: list[str] = field(default_factory=list)
excludes: list[str] = field(default_factory=list)
def _extra_q(self, field_name: str) -> Q | None:
if self.modifier == Modifier.EXCLUDES:
q = Q()
if self.value:
q &= ~Q(**{f"{field_name}__in": self.value})
if self.excludes:
q &= Q(**{f"{field_name}__in": self.excludes})
return q
if self.modifier == Modifier.NOT_EQUALS:
return ~Q(**{f"{field_name}__in": self.value})
return None
# ── OperatorFilter base ────────────────────────────────────────────────────