feat(sorting): SortSpec/SortTerm types + parse_sort_terms (#68)
This commit is contained in:
@@ -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)
|
||||
@@ -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 == []
|
||||
Reference in New Issue
Block a user