From 6459bb983ee78ef81f6b6ec231dd7b40fdd1b89f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 21 Jun 2026 13:43:11 +0200 Subject: [PATCH] feat(sorting): SortSpec/SortTerm types + parse_sort_terms (#68) --- games/sorting.py | 55 +++++++++++++++++++++++++++++++++++++++++++ tests/test_sorting.py | 40 +++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 games/sorting.py create mode 100644 tests/test_sorting.py diff --git a/games/sorting.py b/games/sorting.py new file mode 100644 index 0000000..eaf5d44 --- /dev/null +++ b/games/sorting.py @@ -0,0 +1,55 @@ +"""Structured sorting for list views (Stash-inspired, paired with games/filters.py). + +A list view maps a public sort key to a SortSpec; the URL ?sort= param is a +signed comma-list of those keys (e.g. "-playtime,name"). See +docs/superpowers/specs/2026-06-21-list-view-sort-param-design.md. +""" + +from dataclasses import dataclass +from typing import NamedTuple + +from django.db.models import Aggregate + +type SortKey = str # public column key in a *_SORTS map and in a URL term ("playtime", "name") +type SortString = str # comma-list of signed SortKeys: the URL ?sort= value and *_DEFAULT_SORT ("-date,created") +type AnnotationName = str # an alias added via .annotate(), then referenced by SortSpec.expression +type OrderField = str # SortSpec.expression: a real model field path OR an AnnotationName + +# alias name -> the ORM aggregate that computes it, applied via queryset.annotate() +# e.g. {"total_playtime": Sum("sessions__duration_total")} +type Annotations = dict[AnnotationName, Aggregate] + + +@dataclass(frozen=True) +class SortSpec: + expression: OrderField # unsigned; a real column path or an AnnotationName + annotate: Annotations | None = None + + +class SortTerm(NamedTuple): + key: SortKey + descending: bool # True = "-key" (desc), False = bare key (asc) + + +type SortMap = dict[SortKey, SortSpec] + + +class ParsedSort(NamedTuple): + terms: list[SortTerm] + unknown: list[SortKey] # keys not in the map — the view turns these into warnings + + +def parse_sort_terms(raw: SortString, sort_map: SortMap) -> ParsedSort: + terms: list[SortTerm] = [] + unknown: list[SortKey] = [] + for token in raw.split(","): + token = token.strip() + if not token: + continue + descending = token.startswith("-") + key = token.lstrip("-") + if key in sort_map: + terms.append(SortTerm(key, descending)) + else: + unknown.append(key) + return ParsedSort(terms, unknown) diff --git a/tests/test_sorting.py b/tests/test_sorting.py new file mode 100644 index 0000000..c0217a3 --- /dev/null +++ b/tests/test_sorting.py @@ -0,0 +1,40 @@ +"""Tests for the list-view sorting system (games/sorting.py).""" + +from games.sorting import SortSpec, SortTerm, parse_sort_terms + +# A minimal map; parse_sort_terms only checks key membership, not spec internals. +SAMPLE_MAP = {"name": SortSpec("name"), "date": SortSpec("created_at")} + + +class TestParseSortTerms: + def test_bare_key_is_ascending(self): + parsed = parse_sort_terms("name", SAMPLE_MAP) + assert parsed.terms == [SortTerm("name", False)] + assert parsed.unknown == [] + + def test_dash_prefix_is_descending(self): + parsed = parse_sort_terms("-date", SAMPLE_MAP) + assert parsed.terms == [SortTerm("date", True)] + + def test_multi_column_preserves_order(self): + parsed = parse_sort_terms("date,-name", SAMPLE_MAP) + assert parsed.terms == [SortTerm("date", False), SortTerm("name", True)] + + def test_unknown_key_is_reported_not_raised(self): + parsed = parse_sort_terms("bogus", SAMPLE_MAP) + assert parsed.terms == [] + assert parsed.unknown == ["bogus"] + + def test_mixed_valid_and_unknown(self): + parsed = parse_sort_terms("-name,bogus", SAMPLE_MAP) + assert parsed.terms == [SortTerm("name", True)] + assert parsed.unknown == ["bogus"] + + def test_whitespace_and_empty_tokens_ignored(self): + parsed = parse_sort_terms(" name , , -date ", SAMPLE_MAP) + assert parsed.terms == [SortTerm("name", False), SortTerm("date", True)] + + def test_empty_string_yields_nothing(self): + parsed = parse_sort_terms("", SAMPLE_MAP) + assert parsed.terms == [] + assert parsed.unknown == []