Tabela krzyżowa: stronicowanie i sortowanie wierszy - #797
Open
mpasternak wants to merge 2 commits into
Open
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Tabela krzyżowa renderowała wszystkie wiersze macierzy naraz — przy
pivot_row=autorto ~2000 wierszy na jednej stronie. Nie było też żadnegosterowania kolejnością: jedyną dostępną była ta zaszyta w
_labels()(alfabetycznie, a dla
rok/koszyk_pkmalejąco liczbowo). Pytanie „kto manajwię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_totalspowstają zanim_labels()posortuje wiersze, więcsortowanie po sumie nie kosztuje ani jednego dodatkowego zapytania. Wariant
SQL-owy (
LIMIT/OFFSETna kluczach wierszy) wymagałby drugiego przebiegu posumy 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) plusparse_widok()żyją wbpp/pivot/core.py, a nie w rejestrach — dzięki temusygnatura
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
strzałką ▲/▼ i
aria-sort.Decyzje warte obejrzenia w review
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 BYbezORDER BY, więc Postgresnie gwarantuje jej powtarzalności. Bez tego wiersz na granicy strony
pokazuje się na dwóch stronach albo na żadnej.
col_totals/grand_totalNIE są przeliczane per strona (semantykaExcela). Pod tabelą staje o tym adnotacja — inaczej redaktor zsumuje
widoczną kolumnę i uzna, że liczby się nie zgadzają.
print=Nonew linkach sortujących i pagerze.{% querystring %}przenosi cały bieżący GET, a
common-results.htmlodpalawindow.print()dla?print=1— bez wykasowania tego klucza klikw sortowanie otwierałby okno drukowania.
pivot_sort/pivot_dirw formularzu kontrolek. Selectyauto-submitują, więc bez nich zmiana wymiaru cicho wracałaby do kolejności
domyślnej.
pivot_pagecelowo NIE jest przenoszone — zmiana wymiaru mawracać na stronę 1.
?pivot_per_page=999999unieważnia całą zmianę.rok/koszyk_pk, bo kolejnośćnaturalna jest tam malejąca — ▲ nad listą 2025, 2024, 2023 byłoby kłamstwem.
To także powód, dla którego UI nie ma opcji „pokaż wszystkie wiersze".
PIVOT_MAX_CELLS10 000 → 50 000. Limit chronił głównie przedwypluciem gigantycznego HTML-a; po stronicowaniu render przestał być wąskim
gardłem.
PIVOT_MAX_PAIRS(200 000) bez zmian — to prawdziwy bezpiecznikpamię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.pyitest_zapytanie_pivot.py.Pokryte m.in.: sortowanie po sumie w obu kierunkach, determinizm remisów,
widok=None== dotychczasowe zachowanie, podział na strony, strona pozazakresem (→ ostatnia, nie 404), odrzucenie
pivot_per_page=999999,globalność sum brzegowych na stronie ≠ 1, render wielokropka w pagerze,
gaszenie
print=1w linkach, eksport ignorujący stronę — dla obu widoków.Spec
docs/superpowers/specs/2026-08-31-pivot-stronicowanie-sortowanie-design.mdSelf-review
Przed wystawieniem PR gałąź przeszła self-review agentem
fable. Zgłosiłdwa realne defekty — oba naprawione drugim commitem (
d94bda7b0):powodu (oczekiwana kolejność pokrywała się z naturalną).
Poprawki zweryfikowane mutacjami: wyzerowanie klucza sortu, zignorowanie
SORT_SUMAi 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_CELLSdo 50 000_label_mappingmoże zrobićin_bulkna 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ęcszerokość macierzy nie eksploduje, a
PIVOT_MAX_PAIRSpilnuje pamięci.Testy lokalne
make tests-without-playwrightmake tests-only-playwrightmake js-tests(vitest)pre-commit(staged)🤖 Generated with Claude Code
https://claude.ai/code/session_0132gLD38SwHVWMY1kemmkQt