Pięć z jedenastu nazwisk wypadło za Zyskim. sort() porównuje liczby, nie litery
Ósemka, Ćwikła i Żak lądują za Zyskim, bo sort() porównuje jednostki UTF-16. Pomiar w JS i w bazie, trzy collation i lista kontrolna do przeglądu.
W panelu klienta jest lista jedenastu nazwisk posortowana rosnąco. Adamek i Ambroziak są na górze, zgodnie z oczekiwaniem, ale za Zyskim, czyli za ostatnim nazwiskiem na Z, stoi jeszcze pięć pozycji: Ósemka, Ćwikła, Łukasik, Śliwa i Żak. Nikt nie napisał błędnego warunku. Lista jest posortowana dokładnie tak, jak każe kod – tylko że kod porównuje liczby, nie litery.
Ten defekt generator wstawia niemal zawsze, bo prompt „dodaj sortowanie po nazwie” nie zawiera informacji o języku danych, a domyślne zachowanie JavaScriptu jest zdefiniowane dla kodów znaków, nie dla alfabetu. Ten sam układ ma trzy warstwy: komparator w komponencie, collation kolumny w bazie i porównanie w wyszukiwarce. Każda z nich potrafi dać inną odpowiedź na to samo pytanie.
Pięć nazwisk za Zyskim, bo porównywane są jednostki UTF-16
Dokumentacja MDN opisuje domyślną kolejność jako wynik zamiany elementów na łańcuchy i porównania ich po jednostkach UTF-16, i odsyła przy tekstach nieangielskich do localeCompare. Ten sam zestaw w Node v22.23.4 daje dwa różne wyniki zależnie od tego, czy komparator jest podany:
const nazwiska = ["Zyski","Żak","Śliwa","Łukasik","Cyganek",
"Ćwikła","Oset","Ósemka","Nowak","Adamek","Ambroziak"];
[...nazwiska].sort();
// Adamek, Ambroziak, Cyganek, Nowak, Oset, Zyski,
// Ósemka, Ćwikła, Łukasik, Śliwa, Żak <- pięć pozycji za Zyskim
[...nazwiska].sort(new Intl.Collator('pl').compare);
// Adamek, Ambroziak, Cyganek, Ćwikła, Łukasik, Nowak,
// Oset, Ósemka, Śliwa, Zyski, Żak
Przyczyna jest arytmetyczna i da się ją wypisać w tabeli. Litery bez znaku diakrytycznego mieszczą się w zakresie ASCII, polskie leżą w blokach dalszych, więc każda z nich jest liczbowo większa od z – niezależnie od tego, gdzie stoi w alfabecie.
| Znak | Jednostka UTF-16 | Wartość | Miejsce w alfabecie polskim |
|---|---|---|---|
| Z | U+005A | 90 | przed Ź i Ż |
| z | U+007A | 122 | przed ź i ż |
| Ó | U+00D3 | 211 | zaraz po O |
| Ą | U+0104 | 260 | zaraz po A |
| Ć | U+0106 | 262 | zaraz po C |
| Ł | U+0141 | 321 | zaraz po L |
| Ś | U+015A | 346 | zaraz po S |
| Ż | U+017B | 379 | na końcu, po Z i Ź |
Kolejność liczb w trzeciej kolumnie wyjaśnia cały objaw. Ósemka wypada przed Ćwikłą nie dlatego, że Ó jest przed Ć w jakimkolwiek alfabecie, ale dlatego, że 211 jest mniejsze od 262.
Operator w komparatorze wychodzi poprawnie na części par
Drugi wariant tego samego defektu jest trudniejszy do wychwycenia w przeglądzie, bo wygląda jak świadoma decyzja: rows.sort((a, b) => a.nazwa > b.nazwa ? 1 : -1). Operator > na łańcuchach porównuje te same jednostki UTF-16, więc defekt jest identyczny, ale na części par odpowiedź wychodzi przypadkiem zgodna z alfabetem.
'Żywiec' > 'Zakopane' // true - zgodne z alfabetem
'Łódź' > 'Zakopane' // true - a polski stawia Łódź przed Zakopanem
'Ćmielów' > 'Dąbrowa' // true - a Ć jest przed D
'Świnoujście' > 'Toruń' // true - a Ś jest przed T
Pierwsza para daje poprawny wynik z tego samego powodu, z którego trzy kolejne dają błędny: Ż ma wysoki numer, a w polskim alfabecie też stoi na końcu. To zbieżność, nie reguła. Komparator oparty na operatorze przechodzi więc test napisany na dwóch czy trzech przykładowych rekordach i psuje się na danych klienta. W przeglądzie kodu to wystarczający powód, żeby traktować każde porównanie tekstu operatorem jako defekt, niezależnie od tego, czy akurat widać skutek.
Kolator tworzy się raz, nie w każdym porównaniu
Poprawka ma dwie wersje i nie są równoważne. localeCompare w komparatorze działa, ale MDN zaleca utworzenie obiektu Intl.Collator przy porównywaniu dużej liczby łańcuchów, bo komparator jest wywoływany wielokrotnie na element. Na 50 000 ośmioznakowych łańcuchów w Node v22.23.4 sortowanie z localeCompare('pl') wewnątrz komparatora zajęło 160 ms, z instancją Intl.Collator('pl') utworzoną raz przed sortowaniem 73 ms, a sort() bez komparatora 34 ms. Dwa pierwsze zwróciły identyczną tablicę; trzeci był po prostu nieposortowany alfabetycznie.
Przy okazji kolatora warto ustawić drugą opcję, o której generator nie wie, bo nie wynika z promptu. Numery dokumentów sortowane leksykograficznie układają się nie tak, jak oczekuje księgowość:
const faktury = ["FV-2","FV-10","FV-1","FV-21"];
[...faktury].sort();
// FV-1, FV-10, FV-2, FV-21
[...faktury].sort(new Intl.Collator('pl', {numeric: true}).compare);
// FV-1, FV-2, FV-10, FV-21
Ta opcja ma odpowiednik po stronie bazy, o czym niżej, więc jeśli numery sortuje się w dwóch miejscach, trzeba ustawić ją w obu. Inaczej ta sama lista ma dwie kolejności zależnie od tego, czy przyszła z zapytania, czy została przesortowana w komponencie.
Trzy collation na jednej kolumnie, trzy różne kolejności
Jeśli sortuje baza, kolejność bierze się z collation kolumny, a nie z języka aplikacji. Pomiar na PostgreSQL 16.15, jedna tabela z dziesięcioma nazwiskami, trzy klauzule COLLATE na tej samej kolumnie:
| Collation | Zwrócona kolejność | Co robi z polskimi literami |
|---|---|---|
"C" |
Adamek, Cyganek, Nowak, Oset, Zyski, Ósemka, Ćwikła, Łukasik, Śliwa, Żak | porównuje bajty, diakrytyki na końcu |
"pl-PL-x-icu" |
Adamek, Cyganek, Ćwikła, Łukasik, Nowak, Oset, Ósemka, Śliwa, Zyski, Żak | Ć po C, Ł po L, Ó po O, Ż po Z |
"und-x-icu" |
Adamek, Ćwikła, Cyganek, Łukasik, Nowak, Ósemka, Oset, Śliwa, Żak, Zyski | traktuje Ć jako wariant C, nie osobną literę |
Pierwszy wiersz to pułapka domyślna i warto sprawdzić ją w każdym przejętym projekcie jednym zapytaniem: SELECT datname, datcollate FROM pg_database;. Baza utworzona z lokalizacją C.UTF-8 na ORDER BY nazwa bez klauzuli COLLATE odda kolejność bajtową, czyli ten sam błąd co sort() w przeglądarce. Dokumentacja PostgreSQL opisuje C jako collation, które sortuje po wartościach bajtów, a za litery uznaje tylko ASCII od A do Z.
Trzeci wiersz jest pułapką subtelniejszą, bo und-x-icu jest w tej samej dokumentacji opisane jako rozsądna kolejność niezależna od języka, więc bywa wybierane wtedy, gdy nikt nie chce decydować o lokalizacji. Dla polskiego to nie to samo. Porównania par rozchodzą się na każdym przykładzie z diakrytykiem: 'Ćwikła' < 'Cyganek' jest prawdą w und-x-icu i fałszem w pl-PL-x-icu, podobnie 'Żak' < 'Zyski' i 'Ósemka' < 'Oset'. Root collation widzi Ć jako C ze znakiem i rozstrzyga na drugiej literze; polskie widzi osobną literę stojącą po C.
Po zmianie collation kolumny indeks wraca do planu
Zmiana kolejności na poziomie bazy to zmiana typu kolumny razem z collation, nie dopisanie COLLATE do każdego zapytania:
ALTER TABLE klienci ALTER COLUMN nazwa TYPE text COLLATE "pl-PL-x-icu";
Po tej operacji ORDER BY nazwa bez żadnej klauzuli oddaje kolejność polską. W pomiarze na tej samej tabeli lista przed zmianą wyglądała tak: Cyganek, Zyski, Ćwikła, Łukasik, Śliwa, Żak, a po zmianie tak: Cyganek, Ćwikła, Łukasik, Śliwa, Zyski, Żak. Istniejący indeks na tej kolumnie przetrwał operację i plan nadal używa Index Only Scan, bo zmiana typu kolumny przebudowuje indeksy pod nowe collation.
Zgodność w drugą stronę już nie działa i to jest argument przeciw rozsypywaniu klauzul COLLATE po zapytaniach. Indeks zbudowany pod pl-PL-x-icu nie obsłuży sortowania w innym porządku: zapytanie z ORDER BY nazwa COLLATE "C" dostaje nad skanem dodatkowy węzeł Sort, czyli indeks przestaje pokrywać sortowanie. Na dziesięciu wierszach nie widać tego w czasie odpowiedzi; na liście, która ma mieć indeks pod sortowanie i stronicowanie, widać od razu.
Opcja numeryczna ma w bazie postać osobnego collation z rozszerzeniem BCP 47, więc numery dokumentów da się sortować tak samo jak w kolatorze przeglądarki:
CREATE COLLATION pl_numeric (provider = icu, locale = 'pl-u-kn-true');
SELECT nr FROM faktury ORDER BY nr COLLATE "pl-PL-x-icu"; -- FV-1, FV-10, FV-2, FV-21
SELECT nr FROM faktury ORDER BY nr COLLATE pl_numeric; -- FV-1, FV-2, FV-10, FV-21
W locale pl sensitivity base nie łączy ą z a
Trzecia warstwa to wyszukiwanie odporne na znaki diakrytyczne, gdzie wpisane „lodz” ma znaleźć „Łódź”. Odruch jest taki, żeby ustawić sensitivity: 'base' i uznać sprawę za zamkniętą. Dla polskiego to nie działa, bo reguły CLDR traktują te litery jako osobne jednostki sortowania, a nie warianty liter podstawowych: plik common/collation/pl.xml zawiera wpisy w postaci &A<ą<<<Ą oraz &Z<ź<<<Ź<ż<<<Ż, gdzie pojedynczy znak < oznacza różnicę pierwszego poziomu, czyli taką, jaka dzieli dwie różne litery.
new Intl.Collator('pl', {sensitivity:'base'}).compare('ą','a'); // 1 -> różne
new Intl.Collator('und', {sensitivity:'base'}).compare('ą','a'); // 0 -> równe
new Intl.Collator('en', {sensitivity:'base'}).compare('ą','a'); // 0
new Intl.Collator('de', {sensitivity:'base'}).compare('ą','a'); // 0
To ta sama reguła, która wcześniej dała poprawne sortowanie, więc nie da się mieć obu zachowań z jednego kolatora. Pole wyszukiwania potrzebuje porównania w und albo normalizacji tekstu przed porównaniem, a sortowanie listy nadal potrzebuje pl. Normalizacja ma własną pułapkę: ł nie rozkłada się w NFD na l ze znakiem łączącym, więc samo zdjęcie znaków diakrytycznych go nie ruszy i trzeba dopisać mapowanie jawnie.
const bezDiakrytykow = s => s
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.replace(/ł/g, 'l').replace(/Ł/g, 'L');
bezDiakrytykow('Łódź'); // 'Lodz'
bezDiakrytykow('Zażółć'); // 'Zazolc'
W bazie ten sam problem wygląda inaczej, bo wszystkie standardowe i predefiniowane collation w PostgreSQL są deterministyczne, czyli uznają łańcuchy za równe tylko przy identycznych bajtach. Dlatego 'ą' = 'a' jest fałszem zarówno w pl-PL-x-icu, jak i w und-x-icu. Równość ignorująca diakrytyki wymaga collation nieodeterministycznego, a dokumentacja wylicza jego cenę: kara wydajnościowa, brak deduplikacji w indeksach B-drzewa i niedostępność części operacji dopasowywania wzorców. Ta sama strona podaje tańszą alternatywę, która do wyszukiwarki zwykle wystarcza: wstępne przetworzenie tekstu funkcjami normalize i is normalized zamiast zmiany collation kolumny.
Co sprawdzić w przeglądzie, zanim lista pójdzie do klienta
Wszystkie trzy warstwy sprowadzają się do jednego pytania, na które projekt musi mieć jedną odpowiedź: kto ustala kolejność. Z promptu „dodaj sortowanie po nazwie” ta decyzja nie wynika i model jej nie podejmie. Jeśli projekt jest przejęty po innym wykonawcy i warstwa danych wymaga oceny przed wyceną rozbudowy, zwykle szybciej wychodzi zlecić przegląd kodu i zapytań z zewnątrz niż dochodzić przyczyny po objawach zgłaszanych przez klienta.
Lista kontrolna do przejścia na każdym widoku listowym. Ma sens tylko spisana – jeśli trzeba ją za każdym razem tłumaczyć nowej osobie w zespole, znaczy, że nigdy nie została przekazana, a tylko opowiedziana:
- Czy w kodzie jest
.sort()bez komparatora albo porównanie tekstu operatorem>. - Czy kolator jest tworzony raz i przekazywany, czy powstaje w każdym wywołaniu komparatora.
- Jakie jest
datcollatebazy.C.UTF-8oznacza kolejność bajtową na każdymORDER BYbez jawnegoCOLLATE. - Czy komponent przesortowuje stronę pobraną z bazy. Jeśli tak, oba porządki muszą być tym samym porządkiem.
- Czy numery dokumentów sortują się numerycznie i czy ustawiono to po obu stronach.
- Czy wyszukiwanie znajduje „Łódź” po wpisaniu „lodz”, sprawdzone osobno na literze ł, nie tylko na ó.
- Czy indeks pod sortowanie jest zbudowany w tym samym collation, w którym sortuje zapytanie.
Decyzja do podjęcia raz i zapisania w dokumentacji przekazywanej razem z projektem brzmi: kolejność ustala baza albo aplikacja, nigdy obie. Jeśli baza, w repozytorium stoi migracja z COLLATE na kolumnie i indeks pod to collation, a komponent wyświetla wiersze w kolejności, w jakiej przyszły z zapytania. Jeśli aplikacja, zapytanie oddaje zbiór bez obietnicy kolejności, a w kodzie jest jeden kolator, utworzony raz i wstrzykiwany tam, gdzie się sortuje. Projekt bez tej decyzji wygląda poprawnie dokładnie do dnia, w którym klient doda do bazy pierwszą nazwę z literą Ż.