Prosta zagadka
Model do dalszych rozważań
Filter wybiera wszystkie obiekty spełniające podane kryterium
Łączenie metod
Exclude wybiera obiekty, które nie spełniają warunku
Łączenie metod exclude
Wyjaśnienie i prawa De Morgana
Rozwiązanie zagadki
Moja wpadka z ORM
Pytaj, pytaj i jeszcze razy pytaj
Spis treści
Django ma wbudowany ORM, który znacznie ułatwia pracę. Każdy, kto z niego korzystał przyzna, że pozwala na bardzo szybkie tworzenie prostych aplikacji. To nie tak, że nie da się stworzyć bardziej zaawansowanych projektów. Sam wielokrotnie pracowałem przy projektach, których struktura bazy danych składała się śmiało z setki tabel. Logika była tak zagnieżdżona, że poprawka pobierania elementu wymagała przejścia przez dziesiątki z nich podczas samego debuggowania. Paradoksalnie problemem nie była wtedy ta spora struktura, a wymagania redakcyjne i komunikacja (redakcja pełniła rolę klienta). Jako programiści często jesteśmy sfrustrowani, gdy osoby nietechniczne nas nie rozumieją. Powinniśmy czasem się zatrzymać i postawić po drugiej stronie, bo często jest tak, że to nas nie rozumieją. Dlatego gorąco zachęcam wszystkich do pytania, jeżeli coś nie jest do końca jasne.
Prosta zagadka
Dlaczego o tym piszę? Przekonacie się za chwilę :). Zanim przejdziemy do wyjaśniania różnic między filter i exclude przeprowadźmy mały eksperyment. Spróbujmy opisać maile dwoma atrybutami. Mail może być odebrany i oznaczony jako ważny. Zbierając wszystkie możliwości możemy wyróżnić mail:
- Odebrany i Ważny
- Odebrany i Nieważny
- Nieodebrany i Ważny
- Nieodebrany i Nieważny
Spróbujcie wypisać wszystkie maile z wyjątkiem nieodebranych i nieważnych. Ile ich będzie? Naprawdę, zanim przejdziecie dalej, zatrzymajcie się i spróbujcie wskazać te przypadki.
Model do dalszych rozważań
Mam nadzieję, że wykonaliście te proste zadanie i możemy kontynuować. Do dalszych rozważań posłużymy się następującym modelem:
class Mail(models.Model):
received = models.BooleanField()
important = models.BooleanField()
Filter wybiera wszystkie obiekty spełniające podane kryterium
To w zasadzie jest wystarczający opis. Przejdźmy zatem do przykładu. Wypiszmy wszystkie maile odebrane.
Mail.objects.filter(received=True)
SELECT "equipment_mail"."id", "equipment_mail"."received", "equipment_mail"."important" FROM "equipment_mail" WHERE "equipment_mail"."received" = 1 LIMIT 21
<QuerySet [<Mail: Mail object (1)>, <Mail: Mail object (2)>]>
Łączenie metod
Spróbujmy wypisać wszystkie maile odebrane i ważne.
Mail.objects.filter(received=True, important=True)
SELECT "equipment_mail"."id", "equipment_mail"."received", "equipment_mail"."important" FROM "equipment_mail" WHERE ("equipment_mail"."important" = 1 AND "equipment_mail"."received" = 1) LIMIT 21
<QuerySet [<Mail: Mail object (1)>]>
Można to zrobić inaczej. Przykład poniżej:
Mail.objects.filter(received=True).filter(important=True)
SELECT "equipment_mail"."id", "equipment_mail"."received", "equipment_mail"."important" FROM "equipment_mail" WHERE ("equipment_mail"."received" = 1 AND "equipment_mail"."important" = 1) LIMIT 21
<QuerySet [<Mail: Mail object (1)>]>
Jak widać, w obu przypadkach wynik jest ten sam. Porównując zapytania w SQL, które są generowane widzimy, że jedyną różnicą jest kolejność atrybutów. W naszym wypadku nie wpływa to na rezultat. Gdybyśmy chcieli wyświetlić wszystkie maile odebrane lub ważne przy użyciu metody filter, to musielibyśmy użyć Q z Django, który pozwala na budowanie bardziej zaawansowanych logicznych warunków.
Exclude wybiera obiekty, które nie spełniają warunku
Możemy to interpretować jako przeciwieństwo poprzedniej omawianej metody. Spróbujmy zatem pobrać wszystkie maile z wyjątkiem nieodebranych.
Mail.objects.exclude(received=False)
SELECT "equipment_mail"."id", "equipment_mail"."received", "equipment_mail"."important" FROM "equipment_mail" WHERE NOT ("equipment_mail"."received" = 0) LIMIT 21
<QuerySet [<Mail: Mail object (1)>, <Mail: Mail object (2)>]>
Mamy ty podwójne zaprzeczenie. Wynik jest ten sam co w przypadku pobierania aktywnych. Różnica jest jedynie w zbudowanym zapytaniu.
Łączenie metod exclude
Skoro już rozumiemy różnicę w tych metodach i potrafimy ich używać, spróbujmy odpowiedzieć na wcześniejszą zagadkę. Dla przypomnienia musimy pobrać wszystkie maile z wyjątkiem odebranych i ważnych. Zwróćcie uwagę na to w jaki sposób napisałem zadanie. Nie prosiłem o wszystkie wysłane i ważne maile, bo w takich sytuacjach automatycznie próbujemy zbudować właśnie zapytanie z filter. Ale to mogą być moje przyzwyczajenia. Jak zatem zbudować zapytanie?
Mail.objects.exclude(received=True, important=True)
SELECT "equipment_mail"."id", "equipment_mail"."received", "equipment_mail"."important" FROM "equipment_mail" WHERE NOT ("equipment_mail"."important" = 1 AND "equipment_mail"."received" = 1) LIMIT 21
<QuerySet [<Mail: Mail object (2)>, <Mail: Mail object (3)>, <Mail: Mail object (4)>]>
Dzięki takiemu zapytaniu otrzymaliśmy 3 obiekty, czyli dokładnie przeciwieństwo tego co wcześniej. A więc wszystko zgodnie z oczekiwaniami. Spróbujmy zatem rozbić to na 2 metody analogicznie jak wcześniej.
Mail.objects.exclude(received=True).exclude(important=True)
SELECT "equipment_mail"."id", "equipment_mail"."received", "equipment_mail"."important" FROM "equipment_mail" WHERE (NOT ("equipment_mail"."received" = 1) AND NOT ("equipment_mail"."important" = 1)) LIMIT 21
<QuerySet [<Mail: Mail object (4)>]>
W tym przypadku z kolei widzimy różnice. Część z Was może być zdziwiona, bo przy filter to było bez znaczenia. Przyznam, że ja sam również byłem zaskoczony, kiedy pierwszy raz się z tym zetknąłem. Zrozumiałem to dopiero po kilku telefonach od redakcji, która pilnie potrzebowała poprawki. Przysiadłem więc do analizy generowanego zapytania. I to był klucz do zrozumienia.
Wyjaśnienie i prawa De Morgana
W pierwszym przypadku zwracane są obiekty, które nie spełniają jednocześnie obu przypadków, co widzimy w warunku:
WHERE NOT ("equipment_mail"."important" = 1 AND "equipment_mail"."received" = 1)
W drugim zwracane są obiekty, które nie spełniają warunku pierwszego lub drugiego. Widoczne to jest poniżej:
WHERE (NOT ("equipment_mail"."received" = 1) AND NOT ("equipment_mail"."important" = 1))
Taka subtelna zmiana, a potrafi całkowicie zmienić logikę działania aplikacji. Jeśli ktoś miał styczność z logiką i prawami De Morgana, to nie powinien mieć problemów ze zrozumieniem tych przypadków. Ze studenckiego doświadczenia pamiętam, że logika nie była za bardzo lubiana. Dlatego najlepiej jeśli zapamiętamy, że zapytania składane z kilku exclude są tożsame z logicznym "lub". Możemy ewentualnie rozbijać te zapytania i wykonywać je krok po kroku. Czyli najpierw wybieramy maile, które nie są nieodebrane, a następnie z tych, które zostaną maile ważne.
Rozwiązanie zagadki
Skoro już rozumiemy różnicę w tych metodach i potrafimy ich używać, spróbujmy odpowiedzieć na wcześniejszą zagadkę. Dla przypomnienia musimy pobrać wszystkie maile z wyjątkiem nieodebranych i nieważnych. Jak zatem zbudować zapytanie? Tak:
Mail.objects.exclude(received=False).exclude(important=False)
Czy może tak:
Mail.objects.exclude(received=False, important=False)
Odpowiedź brzmi: To zależy ;)
Ponownie wszystko zależy od interpretacji. Część z Was zapewne w domyśle miała warunek, że maile nie są jednocześnie nieodebrane i nieważne, czyli drugi przypadek. A z drugiej strony są też tacy, którzy wybrali maile, które nie są nieodebrane oraz maile nie spełniające warunku ważnych, co jest tożsame z sumą logiczną, a więc przypadkiem pierwszym. Celowo w zadaniu na początku podałem podwójne zaprzeczenie, żeby było tu trudniej rozczytać. Gdybym poprosił o aktywne i ważne maile, to nikt nie miałby problemu z interpretacją wyników.
Co zrobić w takim przypadku? Tak jak napisałem we wstępie: PYTAĆ. Nawet jeżeli dla drugiej osoby takie stwierdzenie może być oczywiste, zapytaj czy chodziło jej o wszystkie maile odebrane i ważne czy odebrane lub ważne. Dodając zaprzeczenia lub słowa wskazujące na wyjątki znacząco utrudniamy ich zrozumienie.
Moja wpadka z ORM
Wspomniałem wcześniej, że sam miałem problem. Gdy poznacie szerszy kontekst, lepiej zrozumiecie jaki błąd popełniłem. Opisana wcześniej redakcja miała przygotowane raporty ze statystykami obiektów. Do raportu wybrane były wszystkie opublikowane obiekty. Opublikowane to takie, które są aktywne, a aktualna data jest pomiędzy datą początku publikacji, a datą końca publikacji. Zapytanie w dużym uproszczeniu wyglądało to tak:
Article.objects.filter(active=True).filter(pub_date__lte=NOW, end_date__gte=NOW)
Dla porównania zostałem poproszony o raport dla wszystkich nieopublikowanych artykułów. Jako, że miałem dostęp do kodu, to bez sprawdzenia zamieniłem filter na exclude i wygenerowałem nowy raport.
Article.objects.exclude(active=True).exclude(pub_date__lte=NOW, end_date__gte=NOW)
Już widzicie błąd w tym rozumowaniu? Ja wtedy nie widziałem. Jeżeli Wy również nie widzicie, to zadajcie sobie dwa proste pytania:
- Czy artykuł oznaczony jako aktywny, ale z datą publikacji w przyszłości będzie w pierwszym raporcie?
- Czy ten sam artykuł będzie oznaczony w drugim raporcie?
Pytaj, pytaj i jeszcze razy pytaj
Podsumowanie tego wpisu jest w zasadzie proste. Zawsze, jeżeli coś dla Ciebie jest niejasne: PYTAJ . Lepiej stracić kilka minut na dopytaniu się o jeden szczegół niż później spędzić kilka godzin na poprawianiu kodu i testów, bo coś źle zrozumieliśmy. Na swoim przykładzie wiem, że często ego bierze górę. Ale to nie jest dobre. Od kiedy zacząłem bardziej dopytywać i być bardziej dociekliwym moja efektywność znacznie się poprawiła. Współpraca z redakcjami również wskoczyła na inny poziom, bo zamiast rzemieślniczego klepania kodu, starałem się stworzyć produkt, który rzeczywiście był użyteczny.
Mam nadzieję, że takie wplecione przemyślenia do kodu pozwolą Wam poszerzyć perspektywy, bo w końcu to jest najważniejsze :)
Wykorzystany kod napisany został w Python2.7 oraz Django1.11.