W bólach implementacji swoich builderów natknąłem się na ciekawą własność NULLa w MySQL. Nie wiem czy pojawia sie ona również innych SQLach.
Najprościej bedzie na przykladzie. Załózmy, że mamy dwie tabele "process" i "task". Tabela "process" zawiera listę procesów wykonywanych przez pewien abstrakcyjny procesor, na każdy proces składa się pewna ilość zadań, przy czym każde zadanie może należeć tylko do jednego procesu; czyli prosta relacja jeden do wielu. Dodatkowo każde zadanie posiada właściwość "status" określająca stan tego zadania. Dla ustalenia uwagi niech pole "status" może przyjmować trzy wartości: "none", "running" lub "completed". Definicja tych tabel może wyglądac tak:
CREATE TABLE process (
id INT UNSIGNED PRIMARY KEY NOT NULL auto_increment,
name VARCHAR);
CREATE TABLE task (
id INT UNSIGNED PRIMARY KEY NOT NULL auto_increment,
id_process INT UNSIGNED NOT NULL,
status ENUM('none', 'running', 'completed') NOT NULL);
Zapytanie które zwróci nam identyfikator procesu wraz z ilością zadań przyporzadkowanych do tego procesu jest oczywiście trywialne i wyglada tak:
SELECT id_process, COUNT(*) FROM task GROUP BY id_process;
Problemem natomiast jest zapytanie zwracajace identyfikator procesu wraz z ilością zadań przyporzadkowanych do tego procesu, ale takich które nie zostały jeszcze zakończone, czyli mają status różny od 'completed'. Proste:
SELECT id_process, COUNT(status != 'completed') FROM task GROUP BY id_process;
nie wystarcza ponieważ COUNT zlicza zgrupowane wartości bez wzgledu na ich wartość. W tym przypadku zarówno TRUE jak i FALSE jest traktowane jako element to zliczenia. Zatem powyższe zapytanie zwraca to samo co poprzednie, czyli identyfikator procesu wraz z ilością zadań przyporzadkowanych do tego procesu.
Możnaby oczywiście uzyć funkcji SUM i IF w następujący sposób:
SELECT id_process, SUM(IF(status != 'completed), 1, 0) FROM task GROUP BY id_process;
Ale to nie dość, że niezgodne z zasadami sztuki, to jeszcze mało czytelne i pewnie mało wydajne.
Aby w prosty sposób uzyskać oczekiwany wynik wystarczy wykorzystać to że wartości NULL są niepoliczalne dla funkcji COUNT. Jeżeli zgrupujemy rekordy które w zadanym polu maja cztery wartości z czego dwie są równe NULL to funkcja COUNT na tym polu zwróci nam 2, a nie 4.
Wystarczy zatem doprowadzić ostatnie zapytanie wykorzystujące funkcję COUNT do takiej postaci, w której wartość argumentu jest policzalna gdy pole "status" jest inne niż 'completed', a niepoliczalna (czyli równa NULL) w przeciwnym wypadku. Wyrażenie
status != 'completed' OR NULL
spełnia te założenia ponieważ przyjmuje jedną z dwóch wartości: TRUE dla pola "status" różnego od 'completed', albo NULL w przeciwnym wypadku.
Ostatecznie zapytanie zwracajace identyfikator procesu wraz z ilością zadań przyporzadkowanych do tego procesu, takich które nie zostały jeszcze zakończone wygląda tak:
SELECT id_process, COUNT(status != 'completed' OR NULL) FROM task GROUP BY id_process;
Śliczne? Prymitywne, hack taki, że aż żołądek boli. Wygląda na to, że nie doszedłeś jeszcze w nauce SQL do klauzuli WHERE…
SELECT id_process, COUNT(*)
FROM task
WHERE status <> 'completed'
GROUP BY id_process;
Mógłbyś też pomyśleć o przesiadce na bazę danych (np. PostgreSQL), a nie korzystać z żałosnej imitacji. … reply
Fakt, błedem było nie napisanie bezpośrednio do czego mi to posłuży. Więc naprawiam ten błąd, bo widzę czytanie ze zrozumieniem to rzadka sztuka.
Używając tej swojej klauzuli WHERE wylistuj wszystkie takie procesy, dla których wszystkie zadania (z tabeli "task") mają status 'completed'. Bo ja to zrobię tak:
SELECT id_process, COUNT(status != 'completed' OR NULL) AS count FROM task GROUP BY id_process HAVING count = 0;
Tylko żeby Cię żołądek nie rozbolał, jak będziesz się gimnastykował.
… reply
SELECT id_process
FROM task
WHERE status = 'completed'
GROUP BY id_process;
Coś mi się wydaje, że owracasz jednak kota ogonem, zupełnie niepotrzebnie. Przeczytaj o WHERE, zanim zaczniej kombinować po górkę.
Dobrze byłoby, gdybyś też obejrzał plan dla Twojego i mojego zapytania. Może wtedy być zrozumiał, dlaczego Twoje podejście jest co najmniej niewłaściwe. … reply
Przecież SELECT id_process FROM task WHERE status = 'completed' GROUP BY id_process; zwróci liste procesów które mają przynajmniej jedno zadanie zakończone, a nie liste procesów, w których wszystkie zadania zostały zakończone. Nie widzisz tego?
Cały problem leży w tym, żeby uzyskać listę procesów, w których wszystkie zadania się zakończyły, użycie WHERE w tym przypadku nic nie da, bo przed wykonaniem funkcji COUNT ograniczy zbiór poczatkowy do zadań które się nie zakończyły (w przypadku WHERE status <> 'completed') albo do zadań które się zakończyły w przypadku WHERE status = 'completed'). Czyli używając WHERE status = 'completed' nie wiemy czy są w tak ograniczonej tabeli rekordy z status <> 'completed'
Na przykładzie będzie prościej. Przyjmijmy, że w tabela "task" wygląda tak (pominąłem pierwsze pole bo jest nieistotne w tym wypadki):
No tak - mają być wszystkie. Wobec tego biję się w piersi, rzeczywiście moje zapytanie nie daje poprawnego wyniku.
Mimo wszystko napisałbym to inaczej:
SELECT id_process
FROM task
WHERE status = 'completed'
GROUP BY id_process
EXCEPT
SELECT id_process
FROM task
WHERE status <> 'completed'
GROUP BY id_process
To zapytanie wciąż będzie szybsze w planie wykonania od Twojego.
… reply
Nie jestem do końca przekonany, czy będzie szybsze. Nawet jeżeli tak, to najprawdopodobniej
SELECT DISTINCT id_process AS p1 FROM task
WHERE status = 'completed' AND
NOT EXISTS (SELECT * FROM task WHERE status <> 'completed' AND id_process = p1);
będzie jeszcze szybsze. Ale to nie o to chodzi.
… reply
Tomasz Trojanowski mieszka w Łańcucie, wraz z żoną Iwoną i synami Jakubem i Arkadiuszem. Pracuje jako administrator serwerów internetowych. W wolnych chwilach zajmuje się projektem PLD/FreeBSD.