diff --git a/common/components/filters.py b/common/components/filters.py index 2280239..e32d3d8 100644 --- a/common/components/filters.py +++ b/common/components/filters.py @@ -103,6 +103,7 @@ _PRESENCE_MODIFIERS = frozenset({"NOT_NULL", "IS_NULL"}) _MATCH_MODES: list[LabeledOption] = [ ("INCLUDES", "any"), ("INCLUDES_ALL", "all"), + ("INCLUDES_ONLY", "only"), ("EXCLUDES", "none"), ] diff --git a/common/criteria.py b/common/criteria.py index 8c404b7..9df7081 100644 --- a/common/criteria.py +++ b/common/criteria.py @@ -30,6 +30,7 @@ class Modifier(str, Enum): INCLUDES = "INCLUDES" EXCLUDES = "EXCLUDES" INCLUDES_ALL = "INCLUDES_ALL" + INCLUDES_ONLY = "INCLUDES_ONLY" IS_NULL = "IS_NULL" NOT_NULL = "NOT_NULL" MATCHES_REGEX = "MATCHES_REGEX" @@ -71,6 +72,7 @@ class Modifier(str, Enum): cls.INCLUDES, cls.EXCLUDES, cls.INCLUDES_ALL, + cls.INCLUDES_ONLY, cls.IS_NULL, cls.NOT_NULL, ] @@ -317,16 +319,17 @@ class _SetCriterion(_Criterion): 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: - # INCLUDES_ALL ("related to all of these") is only meaningful for - # many-to-many fields. A naive Q(field=a) & Q(field=b) collapses - # to a single join requiring one through-row to equal both values - # (impossible), so the generic criterion layer cannot build a - # correct Q. M2M callers must supply their own Q builder at the - # filter level — see PurchaseFilter._games_to_q for the subquery - # pattern (chained .filter() calls + pk__in). + if modifier in (Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY): + # INCLUDES_ALL ("related to all of these") and INCLUDES_ONLY + # ("related to exactly these, nothing else") are only meaningful + # for many-to-many fields. A naive Q(field=a) & Q(field=b) + # collapses to a single join requiring one through-row to equal + # both values (impossible), so the generic criterion layer cannot + # build a correct Q. M2M callers must supply their own Q builder + # at the filter level — see PurchaseFilter._games_to_q for the + # chained-subquery pattern. 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." ) raise ValueError(f"Unsupported modifier {modifier} for {type(self).__name__}") diff --git a/games/filters.py b/games/filters.py index 47bc539..2b6de47 100644 --- a/games/filters.py +++ b/games/filters.py @@ -389,19 +389,30 @@ class PurchaseFilter(OperatorFilter): def _games_to_q(criterion: ChoiceCriterion) -> Q: """Build the Q for the many-to-many ``games`` field. - ``INCLUDES_ALL`` ("related to every selected game") cannot be a single - ``.filter(Q(games=a) & Q(games=b))`` — that collapses to one join and - would require a single link row to be both games. Instead chain a filter - per game so each gets its own join, then match by ``pk``. The orthogonal - ``excludes`` channel is applied as a negative, consistent with every - other modifier. All other modifiers delegate to the criterion. + ``INCLUDES_ALL`` ("related to every selected game") and + ``INCLUDES_ONLY`` ("related to exactly these, nothing else") cannot be + a single ``.filter(Q(games=a) & Q(games=b))`` — that collapses to one + join and would require a single link row to be both games. Instead + chain a filter per game so each gets its own join, then match by + ``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: - from games.models import Purchase + if criterion.modifier in (Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY) and criterion.value: + from games.models import Game, Purchase subquery = Purchase.objects.all() for game_id in criterion.value: 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")) if criterion.excludes: q &= ~Q(games__in=criterion.excludes) diff --git a/tests/test_filter_bars.py b/tests/test_filter_bars.py index 5dd6f4f..d4adf74 100644 --- a/tests/test_filter_bars.py +++ b/tests/test_filter_bars.py @@ -93,8 +93,8 @@ class FilterBarRenderingTest(TestCase): self._assert_range_slider(html) def test_purchase_filter_bar_games_has_match_modes(self): - """The many-to-many games field surfaces the any/all/none match select; - single-valued fields (platform) do not.""" + """The many-to-many games field surfaces the any/all/only/none match + select; single-valued fields (platform) do not.""" html = str( PurchaseFilterBar( 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('value="INCLUDES_ALL"', html) + self.assertIn('value="INCLUDES_ONLY"', html) # Platform is single-valued: no match select before its widget. games_start = html.find('data-name="games"') platform_start = html.find('data-name="platform"') diff --git a/tests/test_filters.py b/tests/test_filters.py index 33ba93e..d229d34 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -17,6 +17,21 @@ from common.components import FilterBar 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: def test_equals(self): c = StringCriterion(value="zelda", modifier=Modifier.EQUALS) @@ -101,11 +116,15 @@ class TestChoiceCriterion: c = ChoiceCriterion(value=["f"], excludes=["a"], modifier=Modifier.EXCLUDES) assert c.to_q("status") == ~Q(status__in=["f"]) & ~Q(status__in=["a"]) - def test_includes_all_requires_filter_builder(self): - """INCLUDES_ALL cannot be built by the generic criterion layer — it - requires a filter-level Q builder (see PurchaseFilter._games_to_q).""" - c = ChoiceCriterion(value=["f", "p"], modifier=Modifier.INCLUDES_ALL) - with pytest.raises(AssertionError, match="INCLUDES_ALL requires"): + @pytest.mark.parametrize( + "modifier", [Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY] + ) + def test_m2m_modifiers_require_filter_builder(self, modifier): + """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") def test_not_equals(self): @@ -138,11 +157,15 @@ class TestMultiCriterion: 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]) - def test_includes_all_requires_filter_builder(self): - """INCLUDES_ALL cannot be built by the generic criterion layer — it - requires a filter-level Q builder (see PurchaseFilter._games_to_q).""" - c = MultiCriterion(value=[1, 2], modifier=Modifier.INCLUDES_ALL) - with pytest.raises(AssertionError, match="INCLUDES_ALL requires"): + @pytest.mark.parametrize( + "modifier", [Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY] + ) + def test_m2m_modifiers_require_filter_builder(self, modifier): + """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") def test_is_null(self): @@ -337,6 +360,93 @@ class TestPurchaseGamesIncludesAllAgainstDB: 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: def test_status_choice_criterion(self): gf = GameFilter.from_json( diff --git a/tests/test_search_select.py b/tests/test_search_select.py index 9b8dc76..0fc8e49 100644 --- a/tests/test_search_select.py +++ b/tests/test_search_select.py @@ -223,7 +223,12 @@ class FilterSelectComponentTest(unittest.TestCase): self.assertIn(">Obscure Game", 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): 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('value="INCLUDES_ALL"', html) self.assertIn(">all", html) + self.assertIn('value="INCLUDES_ONLY"', html) + self.assertIn(">only", html) # The container exposes the active mode (defaults to the first) for the JS. self.assertIn('data-match="INCLUDES"', html)