Skip to content

Tabela krzyżowa: stronicowanie i sortowanie wierszy - #797

Open
mpasternak wants to merge 2 commits into
devfrom
feat-pivot-stronicowanie-sortowanie
Open

Tabela krzyżowa: stronicowanie i sortowanie wierszy#797
mpasternak wants to merge 2 commits into
devfrom
feat-pivot-stronicowanie-sortowanie

Conversation

@mpasternak

Copy link
Copy Markdown
Member

Problem

Tabela krzyżowa renderowała wszystkie wiersze macierzy naraz — przy
pivot_row=autor to ~2000 wierszy na jednej stronie. Nie było też żadnego
sterowania kolejnością: jedyną dostępną była ta zaszyta w _labels()
(alfabetycznie, a dla rok/koszyk_pk malejąco liczbowo). Pytanie „kto ma
najwięcej prac" wymagało eksportu do XLSX i posortowania w Excelu.

Dotyczy obu wejść — multiseek „precyzyjne" i „Szukaj zapytaniem"
(DjangoQL) — bo body pivota to jeden wspólny partial.

Rozwiązanie

Sortowanie i stronicowanie działają na już zbudowanej macierzy, nie na
SQL-u: row_totals powstają zanim _labels() posortuje wiersze, więc
sortowanie po sumie nie kosztuje ani jednego dodatkowego zapytania. Wariant
SQL-owy (LIMIT/OFFSET na kluczach wierszy) wymagałby drugiego przebiegu po
sumy brzegowe — te muszą obejmować cały zbiór, nie widoczną stronę — i płacił
drogi JOIN do autorzy__ dwa razy.

Nowy PivotWidok (sort / kierunek / strona / na_stronie) plus
parse_widok() żyją w bpp/pivot/core.py, a nie w rejestrach — dzięki temu
sygnatura parse_params() i jej pięć miejsc wywołania zostają nietknięte.
as_table() wołane bez argumentu zachowuje się dokładnie jak dotąd.

UI

  • Nagłówki „wymiar wierszy" i „RAZEM" sortują (klik odwraca kierunek), ze
    strzałką ▲/▼ i aria-sort.
  • Pager pod i nad tabelą; domyślnie 50 wierszy, do wyboru 25/50/100/250.
  • Selektor rozmiaru strony w istniejącym pasku kontrolek.

Decyzje warte obejrzenia w review

  • Kolejność wierszy musi być porządkiem TOTALNYM. Ostatecznym
    dyskryminatorem jest sam klucz wymiaru, nie etykieta — etykiety nie są
    unikatowe (dwóch autorów „Kowalski Jan" ma różne PK i ten sam str()),
    a kolejność wejścia pochodzi z GROUP BY bez ORDER BY, więc Postgres
    nie gwarantuje jej powtarzalności. Bez tego wiersz na granicy strony
    pokazuje się na dwóch stronach albo na żadnej.
  • col_totals / grand_total NIE są przeliczane per strona (semantyka
    Excela). Pod tabelą staje o tym adnotacja — inaczej redaktor zsumuje
    widoczną kolumnę i uzna, że liczby się nie zgadzają.
  • print=None w linkach sortujących i pagerze. {% querystring %}
    przenosi cały bieżący GET, a common-results.html odpala
    window.print() dla ?print=1 — bez wykasowania tego klucza klik
    w sortowanie otwierałby okno drukowania.
  • Ukryte pivot_sort/pivot_dir w formularzu kontrolek. Selecty
    auto-submitują, więc bez nich zmiana wymiaru cicho wracałaby do kolejności
    domyślnej. pivot_page celowo NIE jest przenoszone — zmiana wymiaru ma
    wracać na stronę 1.
  • Rozmiar strony jest walidowany względem whitelisty. Bez tego
    ?pivot_per_page=999999 unieważnia całą zmianę.
  • Strzałka etykiety jest odwracana dla rok/koszyk_pk, bo kolejność
    naturalna jest tam malejąca — ▲ nad listą 2025, 2024, 2023 byłoby kłamstwem.
  • Eksport respektuje sortowanie, ignoruje stronę (pełna macierz w pliku).
    To także powód, dla którego UI nie ma opcji „pokaż wszystkie wiersze".
  • PIVOT_MAX_CELLS 10 000 → 50 000. Limit chronił głównie przed
    wypluciem gigantycznego HTML-a; po stronicowaniu render przestał być wąskim
    gardłem. PIVOT_MAX_PAIRS (200 000) bez zmian — to prawdziwy bezpiecznik
    pamięci dla anonimów.

Testy

Nowy src/bpp/tests/test_pivot_widok.py (40 testów, w większości bez bazy)
plus dopiski w test_multiseek_pivot_view.py i test_zapytanie_pivot.py.
Pokryte m.in.: sortowanie po sumie w obu kierunkach, determinizm remisów,
widok=None == dotychczasowe zachowanie, podział na strony, strona poza
zakresem (→ ostatnia, nie 404), odrzucenie pivot_per_page=999999,
globalność sum brzegowych na stronie ≠ 1, render wielokropka w pagerze,
gaszenie print=1 w linkach, eksport ignorujący stronę — dla obu widoków.

Spec

docs/superpowers/specs/2026-08-31-pivot-stronicowanie-sortowanie-design.md

Self-review

Przed wystawieniem PR gałąź przeszła self-review agentem fable. Zgłosił
dwa realne defekty — oba naprawione drugim commitem (d94bda7b0):

  1. sortowanie nie dawało porządku totalnego przy powtórzonych etykietach,
  2. testy sortowania po sumie w obu widokach przechodziły z niewłaściwego
    powodu (oczekiwana kolejność pokrywała się z naturalną).

Poprawki zweryfikowane mutacjami: wyzerowanie klucza sortu, zignorowanie
SORT_SUMA i usunięcie dyskryminatora wywalają odpowiednio 6, 6 i 3 testy;
wcześniejsze wersje testów przechodziły każdą z tych mutacji.

Do wiadomości review: przy podniesieniu PIVOT_MAX_CELLS do 50 000
_label_mapping może zrobić in_bulk na 5× większym zbiorze obiektów FK
(jednorazowo per żądanie, bez cache). Świadomy trade-off — wysokokardynalne
wymiary (zrodlo, autor, jednostka) mają allow_column=False, więc
szerokość macierzy nie eksploduje, a PIVOT_MAX_PAIRS pilnuje pamięci.

Testy lokalne

krok wynik
make tests-without-playwright 9490 passed, 4 skipped, 1 xfailed
make tests-only-playwright 157 passed, 1 skipped
make js-tests (vitest) 112 passed (9 plików)
pre-commit (staged) wszystko Passed

🤖 Generated with Claude Code

https://claude.ai/code/session_0132gLD38SwHVWMY1kemmkQt

mpasternak and others added 2 commits August 31, 2026 10:49
Tabela krzyżowa renderowała wszystkie wiersze macierzy naraz — przy
pivot_row=autor to ~2000 wierszy na jednej stronie. Nie było też żadnego
sterowania kolejnością: jedyną dostępną było alfabetyczne _labels().

Sortowanie i stronicowanie działają na JUŻ ZBUDOWANEJ macierzy, nie na
SQL-u: row_totals powstają zanim _labels() posortuje wiersze, więc
sortowanie po sumie nie kosztuje dodatkowego zapytania. Wariant SQL-owy
(LIMIT/OFFSET na kluczach wierszy) wymagałby drugiego przebiegu po sumy
brzegowe i płacił drogi JOIN do autorzy__ dwa razy.

Nowy PivotWidok (sort/kierunek/strona/na_stronie) + parse_widok() żyją
w bpp.pivot.core, a nie w rejestrach — dzięki temu sygnatura
parse_params() i jej pięć miejsc wywołania zostają nietknięte.
as_table() wołane bez argumentu zachowuje się dokładnie jak dotąd.

Szczegóły:

- rozmiar strony walidowany względem whitelisty (25/50/100/250); bez
  tego ?pivot_per_page=999999 unieważnia całą zmianę,
- sortowanie po sumie rozstrzyga remisy etykietą — bez porządku
  totalnego wiersz o równej sumie mógłby trafić na dwie strony naraz
  albo zniknąć z obu,
- col_totals/grand_total NIE są przeliczane per strona (semantyka
  Excela); pod tabelą staje o tym adnotacja,
- linki sortujące i pager kasują print=None, bo {% querystring %}
  przenosi cały GET, a common-results.html odpala window.print() dla
  ?print=1,
- ukryte pola pivot_sort/pivot_dir w formularzu kontrolek: selecty
  auto-submitują, więc bez nich zmiana wymiaru gubiłaby sortowanie.
  pivot_page celowo NIE jest przenoszone — zmiana wymiaru wraca na 1,
- eksport XLSX/CSV respektuje sortowanie, ignoruje stronę (pełna
  macierz w pliku) — dlatego UI nie ma opcji „pokaż wszystkie",
- PIVOT_MAX_CELLS 10 000 -> 50 000: po stronicowaniu render przestał
  być wąskim gardłem. PIVOT_MAX_PAIRS bez zmian (bezpiecznik pamięci).

Działa identycznie w obu wejściach — multiseek „precyzyjne" i
/zapytanie/ (DjangoQL) dzielą partial multiseek/report-body-pivot.html.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132gLD38SwHVWMY1kemmkQt
Self-review (agent fable) znalazł dwa realne defekty w poprzednim commicie.

1. Sortowanie NIE dawało porządku totalnego, gdy dwa RÓŻNE klucze mają
   tę samą etykietę. Etykiety nie są unikatowe: dwóch autorów „Kowalski
   Jan" ma różne PK i ten sam str(). Przy remisie decydowała wtedy
   kolejność wejścia, a ta pochodzi z set(row_keys) wypełnianego wynikiem
   GROUP BY bez ORDER BY — Postgres nie gwarantuje jej powtarzalności
   między wykonaniami (HashAggregate, parallel workers). Wiersz na
   granicy strony mógł się przez to pokazać na dwóch stronach albo na
   żadnej — dokładnie defekt, przed którym tie-break miał chronić.

   Ostatecznym dyskryminatorem jest teraz sam klucz wymiaru
   (_klucz_rozstrzygajacy; str(), bo klucze bywają mieszanych typów).
   Poprawka dotyczy OBU warstw: posortowane_wiersze() i _labels() —
   dla sort=etykieta cała totalność siedzi w tej drugiej. Gałąź
   rok/koszyk sortuje po samym kluczu, więc już była totalna; przy
   okazji korzysta z WYMIARY_MALEJACE zamiast powtórzonej krotki.

2. Testy sortowania po sumie w obu widokach przechodziły z niewłaściwego
   powodu — dane były ułożone tak, że oczekiwana kolejność pokrywała się
   z naturalną (multiseek) albo istniał tylko jeden wiersz (/zapytanie/).
   Przy DWÓCH wierszach „suma malejąco" i „odwróć naturalną" dają zresztą
   ten sam wynik, więc pierwsza poprawka też by nie wystarczyła. Testy
   używają teraz TRZECH lat o różnych licznościach — naturalna, odwrócona
   naturalna, suma rosnąco i suma malejąco to cztery różne kolejności.

   Zweryfikowane mutacjami: wyzerowanie klucza sortu, zignorowanie
   SORT_SUMA i usunięcie dyskryminatora wywalają odpowiednio 6, 6 i 3
   testy. Wcześniejsze wersje przechodziły każdą z tych mutacji.

Dodatkowo: shim bpp.multiseek_registry.pivot przestaje re-eksportować
DOZWOLONE_NA_STRONIE. To ta sama pułapka „martwej kopii", przez którą
świadomie nie re-eksportuje PIVOT_MAX_* — podmiana na kopii rozjechałaby
listę opcji w UI z whitelistą sprawdzaną w parse_widok(). Funkcje
zostają, bo re-eksport funkcji to ten SAM obiekt, nie kopia wartości.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132gLD38SwHVWMY1kemmkQt
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant