Add includes only matcher mode
This commit is contained in:
@@ -103,6 +103,7 @@ _PRESENCE_MODIFIERS = frozenset({"NOT_NULL", "IS_NULL"})
|
|||||||
_MATCH_MODES: list[LabeledOption] = [
|
_MATCH_MODES: list[LabeledOption] = [
|
||||||
("INCLUDES", "any"),
|
("INCLUDES", "any"),
|
||||||
("INCLUDES_ALL", "all"),
|
("INCLUDES_ALL", "all"),
|
||||||
|
("INCLUDES_ONLY", "only"),
|
||||||
("EXCLUDES", "none"),
|
("EXCLUDES", "none"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+12
-9
@@ -30,6 +30,7 @@ class Modifier(str, Enum):
|
|||||||
INCLUDES = "INCLUDES"
|
INCLUDES = "INCLUDES"
|
||||||
EXCLUDES = "EXCLUDES"
|
EXCLUDES = "EXCLUDES"
|
||||||
INCLUDES_ALL = "INCLUDES_ALL"
|
INCLUDES_ALL = "INCLUDES_ALL"
|
||||||
|
INCLUDES_ONLY = "INCLUDES_ONLY"
|
||||||
IS_NULL = "IS_NULL"
|
IS_NULL = "IS_NULL"
|
||||||
NOT_NULL = "NOT_NULL"
|
NOT_NULL = "NOT_NULL"
|
||||||
MATCHES_REGEX = "MATCHES_REGEX"
|
MATCHES_REGEX = "MATCHES_REGEX"
|
||||||
@@ -71,6 +72,7 @@ class Modifier(str, Enum):
|
|||||||
cls.INCLUDES,
|
cls.INCLUDES,
|
||||||
cls.EXCLUDES,
|
cls.EXCLUDES,
|
||||||
cls.INCLUDES_ALL,
|
cls.INCLUDES_ALL,
|
||||||
|
cls.INCLUDES_ONLY,
|
||||||
cls.IS_NULL,
|
cls.IS_NULL,
|
||||||
cls.NOT_NULL,
|
cls.NOT_NULL,
|
||||||
]
|
]
|
||||||
@@ -317,16 +319,17 @@ class _SetCriterion(_Criterion):
|
|||||||
return Q(**{f"{field_name}__in": self.value}) if self.value else Q()
|
return Q(**{f"{field_name}__in": self.value}) if self.value else Q()
|
||||||
if modifier in (Modifier.EXCLUDES, Modifier.NOT_EQUALS):
|
if modifier in (Modifier.EXCLUDES, Modifier.NOT_EQUALS):
|
||||||
return ~Q(**{f"{field_name}__in": self.value}) if self.value else Q()
|
return ~Q(**{f"{field_name}__in": self.value}) if self.value else Q()
|
||||||
if modifier == Modifier.INCLUDES_ALL:
|
if modifier in (Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY):
|
||||||
# INCLUDES_ALL ("related to all of these") is only meaningful for
|
# INCLUDES_ALL ("related to all of these") and INCLUDES_ONLY
|
||||||
# many-to-many fields. A naive Q(field=a) & Q(field=b) collapses
|
# ("related to exactly these, nothing else") are only meaningful
|
||||||
# to a single join requiring one through-row to equal both values
|
# for many-to-many fields. A naive Q(field=a) & Q(field=b)
|
||||||
# (impossible), so the generic criterion layer cannot build a
|
# collapses to a single join requiring one through-row to equal
|
||||||
# correct Q. M2M callers must supply their own Q builder at the
|
# both values (impossible), so the generic criterion layer cannot
|
||||||
# filter level — see PurchaseFilter._games_to_q for the subquery
|
# build a correct Q. M2M callers must supply their own Q builder
|
||||||
# pattern (chained .filter() calls + pk__in).
|
# at the filter level — see PurchaseFilter._games_to_q for the
|
||||||
|
# chained-subquery pattern.
|
||||||
assert False, (
|
assert False, (
|
||||||
"INCLUDES_ALL requires a filter-level Q builder for M2M fields. "
|
f"{modifier} requires a filter-level Q builder for M2M fields. "
|
||||||
"See PurchaseFilter._games_to_q for the chained-subquery pattern."
|
"See PurchaseFilter._games_to_q for the chained-subquery pattern."
|
||||||
)
|
)
|
||||||
raise ValueError(f"Unsupported modifier {modifier} for {type(self).__name__}")
|
raise ValueError(f"Unsupported modifier {modifier} for {type(self).__name__}")
|
||||||
|
|||||||
+19
-8
@@ -389,19 +389,30 @@ class PurchaseFilter(OperatorFilter):
|
|||||||
def _games_to_q(criterion: ChoiceCriterion) -> Q:
|
def _games_to_q(criterion: ChoiceCriterion) -> Q:
|
||||||
"""Build the Q for the many-to-many ``games`` field.
|
"""Build the Q for the many-to-many ``games`` field.
|
||||||
|
|
||||||
``INCLUDES_ALL`` ("related to every selected game") cannot be a single
|
``INCLUDES_ALL`` ("related to every selected game") and
|
||||||
``.filter(Q(games=a) & Q(games=b))`` — that collapses to one join and
|
``INCLUDES_ONLY`` ("related to exactly these, nothing else") cannot be
|
||||||
would require a single link row to be both games. Instead chain a filter
|
a single ``.filter(Q(games=a) & Q(games=b))`` — that collapses to one
|
||||||
per game so each gets its own join, then match by ``pk``. The orthogonal
|
join and would require a single link row to be both games. Instead
|
||||||
``excludes`` channel is applied as a negative, consistent with every
|
chain a filter per game so each gets its own join, then match by
|
||||||
other modifier. All other modifiers delegate to the criterion.
|
``pk``. ``INCLUDES_ONLY`` additionally excludes purchases that have
|
||||||
|
any game outside the specified set. The orthogonal ``excludes``
|
||||||
|
channel is applied as a negative, consistent with every other
|
||||||
|
modifier. All other modifiers delegate to the criterion.
|
||||||
"""
|
"""
|
||||||
if criterion.modifier == Modifier.INCLUDES_ALL and criterion.value:
|
if criterion.modifier in (Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY) and criterion.value:
|
||||||
from games.models import Purchase
|
from games.models import Game, Purchase
|
||||||
|
|
||||||
subquery = Purchase.objects.all()
|
subquery = Purchase.objects.all()
|
||||||
for game_id in criterion.value:
|
for game_id in criterion.value:
|
||||||
subquery = subquery.filter(games=game_id)
|
subquery = subquery.filter(games=game_id)
|
||||||
|
|
||||||
|
if criterion.modifier == Modifier.INCLUDES_ONLY:
|
||||||
|
extra_ids = Game.objects.exclude(
|
||||||
|
id__in=criterion.value
|
||||||
|
).values_list("id", flat=True)
|
||||||
|
if extra_ids:
|
||||||
|
subquery = subquery.exclude(games__in=extra_ids)
|
||||||
|
|
||||||
q = Q(pk__in=subquery.values("pk"))
|
q = Q(pk__in=subquery.values("pk"))
|
||||||
if criterion.excludes:
|
if criterion.excludes:
|
||||||
q &= ~Q(games__in=criterion.excludes)
|
q &= ~Q(games__in=criterion.excludes)
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
self._assert_range_slider(html)
|
self._assert_range_slider(html)
|
||||||
|
|
||||||
def test_purchase_filter_bar_games_has_match_modes(self):
|
def test_purchase_filter_bar_games_has_match_modes(self):
|
||||||
"""The many-to-many games field surfaces the any/all/none match select;
|
"""The many-to-many games field surfaces the any/all/only/none match
|
||||||
single-valued fields (platform) do not."""
|
select; single-valued fields (platform) do not."""
|
||||||
html = str(
|
html = str(
|
||||||
PurchaseFilterBar(
|
PurchaseFilterBar(
|
||||||
filter_json="", preset_list_url="/l", preset_save_url="/s"
|
filter_json="", preset_list_url="/l", preset_save_url="/s"
|
||||||
@@ -102,6 +102,7 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertIn("data-search-select-match", html)
|
self.assertIn("data-search-select-match", html)
|
||||||
self.assertIn('value="INCLUDES_ALL"', html)
|
self.assertIn('value="INCLUDES_ALL"', html)
|
||||||
|
self.assertIn('value="INCLUDES_ONLY"', html)
|
||||||
# Platform is single-valued: no match select before its widget.
|
# Platform is single-valued: no match select before its widget.
|
||||||
games_start = html.find('data-name="games"')
|
games_start = html.find('data-name="games"')
|
||||||
platform_start = html.find('data-name="platform"')
|
platform_start = html.find('data-name="platform"')
|
||||||
|
|||||||
+120
-10
@@ -17,6 +17,21 @@ from common.components import FilterBar
|
|||||||
from games.filters import GameFilter
|
from games.filters import GameFilter
|
||||||
|
|
||||||
|
|
||||||
|
class TestModifier:
|
||||||
|
def test_includes_only_in_enum(self):
|
||||||
|
assert Modifier.INCLUDES_ONLY == "INCLUDES_ONLY"
|
||||||
|
|
||||||
|
def test_includes_only_in_for_multi(self):
|
||||||
|
assert Modifier.INCLUDES_ONLY in Modifier.for_multi()
|
||||||
|
|
||||||
|
def test_for_multi_includes_all_four_match_modes(self):
|
||||||
|
modes = Modifier.for_multi()
|
||||||
|
assert Modifier.INCLUDES in modes
|
||||||
|
assert Modifier.INCLUDES_ALL in modes
|
||||||
|
assert Modifier.INCLUDES_ONLY in modes
|
||||||
|
assert Modifier.EXCLUDES in modes
|
||||||
|
|
||||||
|
|
||||||
class TestStringCriterion:
|
class TestStringCriterion:
|
||||||
def test_equals(self):
|
def test_equals(self):
|
||||||
c = StringCriterion(value="zelda", modifier=Modifier.EQUALS)
|
c = StringCriterion(value="zelda", modifier=Modifier.EQUALS)
|
||||||
@@ -101,11 +116,15 @@ class TestChoiceCriterion:
|
|||||||
c = ChoiceCriterion(value=["f"], excludes=["a"], modifier=Modifier.EXCLUDES)
|
c = ChoiceCriterion(value=["f"], excludes=["a"], modifier=Modifier.EXCLUDES)
|
||||||
assert c.to_q("status") == ~Q(status__in=["f"]) & ~Q(status__in=["a"])
|
assert c.to_q("status") == ~Q(status__in=["f"]) & ~Q(status__in=["a"])
|
||||||
|
|
||||||
def test_includes_all_requires_filter_builder(self):
|
@pytest.mark.parametrize(
|
||||||
"""INCLUDES_ALL cannot be built by the generic criterion layer — it
|
"modifier", [Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY]
|
||||||
requires a filter-level Q builder (see PurchaseFilter._games_to_q)."""
|
)
|
||||||
c = ChoiceCriterion(value=["f", "p"], modifier=Modifier.INCLUDES_ALL)
|
def test_m2m_modifiers_require_filter_builder(self, modifier):
|
||||||
with pytest.raises(AssertionError, match="INCLUDES_ALL requires"):
|
"""INCLUDES_ALL / INCLUDES_ONLY cannot be built by the generic criterion
|
||||||
|
layer — they require a filter-level Q builder (see
|
||||||
|
PurchaseFilter._games_to_q)."""
|
||||||
|
c = ChoiceCriterion(value=["f", "p"], modifier=modifier)
|
||||||
|
with pytest.raises(AssertionError, match="requires a filter-level"):
|
||||||
c.to_q("status")
|
c.to_q("status")
|
||||||
|
|
||||||
def test_not_equals(self):
|
def test_not_equals(self):
|
||||||
@@ -138,11 +157,15 @@ class TestMultiCriterion:
|
|||||||
c = MultiCriterion(value=[1], excludes=[2], modifier=Modifier.EXCLUDES)
|
c = MultiCriterion(value=[1], excludes=[2], modifier=Modifier.EXCLUDES)
|
||||||
assert c.to_q("game_id") == ~Q(game_id__in=[1]) & ~Q(game_id__in=[2])
|
assert c.to_q("game_id") == ~Q(game_id__in=[1]) & ~Q(game_id__in=[2])
|
||||||
|
|
||||||
def test_includes_all_requires_filter_builder(self):
|
@pytest.mark.parametrize(
|
||||||
"""INCLUDES_ALL cannot be built by the generic criterion layer — it
|
"modifier", [Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY]
|
||||||
requires a filter-level Q builder (see PurchaseFilter._games_to_q)."""
|
)
|
||||||
c = MultiCriterion(value=[1, 2], modifier=Modifier.INCLUDES_ALL)
|
def test_m2m_modifiers_require_filter_builder(self, modifier):
|
||||||
with pytest.raises(AssertionError, match="INCLUDES_ALL requires"):
|
"""INCLUDES_ALL / INCLUDES_ONLY cannot be built by the generic criterion
|
||||||
|
layer — they require a filter-level Q builder (see
|
||||||
|
PurchaseFilter._games_to_q)."""
|
||||||
|
c = MultiCriterion(value=[1, 2], modifier=modifier)
|
||||||
|
with pytest.raises(AssertionError, match="requires a filter-level"):
|
||||||
c.to_q("games")
|
c.to_q("games")
|
||||||
|
|
||||||
def test_is_null(self):
|
def test_is_null(self):
|
||||||
@@ -337,6 +360,93 @@ class TestPurchaseGamesIncludesAllAgainstDB:
|
|||||||
assert result == {seeded["both"], seeded["all_three"]}
|
assert result == {seeded["both"], seeded["all_three"]}
|
||||||
|
|
||||||
|
|
||||||
|
class TestPurchaseGamesIncludesOnlyAgainstDB:
|
||||||
|
"""INCLUDES_ONLY on the many-to-many ``Purchase.games`` should match only
|
||||||
|
purchases linked to *exactly* the given games — Stash's ``only`` mode,
|
||||||
|
which INCLUDES_ALL does not provide (it includes supersets)."""
|
||||||
|
|
||||||
|
def _seed(self):
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from games.models import Game, Platform, Purchase
|
||||||
|
|
||||||
|
platform, _ = Platform.objects.get_or_create(name="Test", icon="test")
|
||||||
|
a, _ = Game.objects.get_or_create(name="A", defaults={"platform": platform})
|
||||||
|
b, _ = Game.objects.get_or_create(name="B", defaults={"platform": platform})
|
||||||
|
c, _ = Game.objects.get_or_create(name="C", defaults={"platform": platform})
|
||||||
|
|
||||||
|
def make(linked):
|
||||||
|
purchase = Purchase.objects.create(
|
||||||
|
platform=platform, date_purchased=datetime.date(2024, 1, 1)
|
||||||
|
)
|
||||||
|
purchase.games.set(linked)
|
||||||
|
return purchase
|
||||||
|
|
||||||
|
return {
|
||||||
|
"a": a,
|
||||||
|
"b": b,
|
||||||
|
"both": make([a, b]),
|
||||||
|
"only_a": make([a]),
|
||||||
|
"all_three": make([a, b, c]),
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_includes_only_matches_exact_set(self):
|
||||||
|
"""INCLUDES_ONLY [A, B] returns only purchases with exactly A and B."""
|
||||||
|
from games.filters import PurchaseFilter
|
||||||
|
from games.models import Purchase
|
||||||
|
|
||||||
|
seeded = self._seed()
|
||||||
|
pf = PurchaseFilter.from_json(
|
||||||
|
{
|
||||||
|
"games": {
|
||||||
|
"value": [seeded["a"].id, seeded["b"].id],
|
||||||
|
"modifier": "INCLUDES_ONLY",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = set(Purchase.objects.filter(pf.to_q()))
|
||||||
|
assert result == {seeded["both"]}
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_includes_only_single_game(self):
|
||||||
|
"""INCLUDES_ONLY [A] = exactly game A, no others."""
|
||||||
|
from games.filters import PurchaseFilter
|
||||||
|
from games.models import Purchase
|
||||||
|
|
||||||
|
seeded = self._seed()
|
||||||
|
pf = PurchaseFilter.from_json(
|
||||||
|
{
|
||||||
|
"games": {
|
||||||
|
"value": [seeded["a"].id],
|
||||||
|
"modifier": "INCLUDES_ONLY",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = set(Purchase.objects.filter(pf.to_q()))
|
||||||
|
assert result == {seeded["only_a"]}
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_includes_only_contrast_with_includes_all(self):
|
||||||
|
"""INCLUDES_ONLY excludes the superset that INCLUDES_ALL would match."""
|
||||||
|
from games.filters import PurchaseFilter
|
||||||
|
from games.models import Purchase
|
||||||
|
|
||||||
|
seeded = self._seed()
|
||||||
|
pf = PurchaseFilter.from_json(
|
||||||
|
{
|
||||||
|
"games": {
|
||||||
|
"value": [seeded["a"].id, seeded["b"].id],
|
||||||
|
"modifier": "INCLUDES_ONLY",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = set(Purchase.objects.filter(pf.to_q()))
|
||||||
|
# all_three has A, B, C — INCLUDES_ALL would match it, ONLY does not.
|
||||||
|
assert seeded["all_three"] not in result
|
||||||
|
assert seeded["both"] in result
|
||||||
|
|
||||||
|
|
||||||
class TestGameFilterFromJson:
|
class TestGameFilterFromJson:
|
||||||
def test_status_choice_criterion(self):
|
def test_status_choice_criterion(self):
|
||||||
gf = GameFilter.from_json(
|
gf = GameFilter.from_json(
|
||||||
|
|||||||
@@ -223,7 +223,12 @@ class FilterSelectComponentTest(unittest.TestCase):
|
|||||||
self.assertIn(">Obscure Game</span>", html)
|
self.assertIn(">Obscure Game</span>", html)
|
||||||
self.assertIn('data-value="4172"', html)
|
self.assertIn('data-value="4172"', html)
|
||||||
|
|
||||||
MATCH_MODES = [("INCLUDES", "any"), ("INCLUDES_ALL", "all"), ("EXCLUDES", "none")]
|
MATCH_MODES = [
|
||||||
|
("INCLUDES", "any"),
|
||||||
|
("INCLUDES_ALL", "all"),
|
||||||
|
("INCLUDES_ONLY", "only"),
|
||||||
|
("EXCLUDES", "none"),
|
||||||
|
]
|
||||||
|
|
||||||
def test_match_modes_render_native_select(self):
|
def test_match_modes_render_native_select(self):
|
||||||
html = FilterSelect(field_name="games", match_modes=self.MATCH_MODES)
|
html = FilterSelect(field_name="games", match_modes=self.MATCH_MODES)
|
||||||
@@ -231,6 +236,8 @@ class FilterSelectComponentTest(unittest.TestCase):
|
|||||||
self.assertIn("data-search-select-match", html)
|
self.assertIn("data-search-select-match", html)
|
||||||
self.assertIn('value="INCLUDES_ALL"', html)
|
self.assertIn('value="INCLUDES_ALL"', html)
|
||||||
self.assertIn(">all</option>", html)
|
self.assertIn(">all</option>", html)
|
||||||
|
self.assertIn('value="INCLUDES_ONLY"', html)
|
||||||
|
self.assertIn(">only</option>", html)
|
||||||
# The container exposes the active mode (defaults to the first) for the JS.
|
# The container exposes the active mode (defaults to the first) for the JS.
|
||||||
self.assertIn('data-match="INCLUDES"', html)
|
self.assertIn('data-match="INCLUDES"', html)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user