Pułapki w Django

Podczas ostatniego roku pracy napisałem w Django parę większych i mniejszych aplikacji. Mogę stwierdzić, że znam to środowisko całkiem dobrze, a po uwzględnieniu dotychczasowych doświadczeń z narzędziami do budowania aplikacji webowych, mój werdykt brzmi jednoznacznie: na razie nie szukam niczego innego.

Mimo całej swej świetności, Django ma parę braków, jak np. niemożliwość sensownego dziedziczenia po modelach (już nieaktualne) czy niewygodne (czyt. nieraz wymagające pisania w SQL) wykonywanie rzadkiego ale bardzo pomocnego OUTER JOIN. Do tego dochodzą dwie irytujące dolegliwości, które według mnie są ewidentnymi błędami:

Żeby nie było niczego

Poniższy przykład jest nieaktualny od kwietnia 2008, kiedy to w ORM Django wprowadzono znaczące zmiany.

Weźmy sobie prosty model:

class Foo(models.Model):
    bar = models.IntegerField(null=True)

Wsadźmy coś do bazy:

In : f = Foo(bar=1)

In : f.save()

In : f = Foo()

In : f.save()

In : Foo.objects.all()
Out: [<Foo: Foo object>, <Foo: Foo object>]

Wszystko gra. Teraz poprośmy o obiekty, które w polu bar nie mają niczego. W wynikach zapytania wartości NULL zawsze tłumaczone są na pythonowe None. Naturalnym wydaje się więc pytanie:

In : Foo.objects.filter(bar=None)
Out: []

Niestety, w wynikach też nie ma niczego. Trzeba zastosować specjalną konstrukcję:

In : Foo.objects.filter(bar__isnull=True)
Out: [<Foo: Foo object>]

O ile istnienie drugiego rozwiązania nie przeszkadza wcale w naprawieniu pierwszego (czyt. doprowadzeniu go do działania zgodnego z intuicją i zdrowym rozsądkiem), deweloperzy Django zgodnie opowiedzieli się za zachowaniem status quo ze względu na wsteczną kompatybilność z dziwnie napisanymi aplikacjami. W obliczu ciągłego braku stabilnej wersji 1.0 środowiska, jest to conajmniej zastanawiające.

Przetwarzanie kontekstowe

W aplikacji, którą obecnie piszę, jest byt o nazwie playarena. W skrócie ujmując, są drużyny i zawodnicy. Drużyny mogą zapraszać zawodników a zawodnicy mogą zgłaszać chęć dołączenia do drużyn. Obiekt reprezentujący członkostwo ma zatem dwa pola typu boolowskiego, opisujące czy potwierdziły je obie strony. Połowicznie potwierdzony obiekt ma status zaproszenia. Ten prosty model owocuje w niektórych momentach dość skomplikowanymi zapytaniami, gdy weźmie się pod uwagę, że każda drużyna ma swojego moderatora (zwanego też kapitanem).

Przyszła mi ochota zapytać o drużyny, z którymi dany użytkownik ma coś wspólnego. To znaczy jest jej członkiem lub moderatorem. Zadanie niby łatwe:

q = Playteam.objects.filter(
    Q(moderator=request.user) |
    Q(
        playteammembership__user=request.user,
        playteammembership__user_accepted=True,
        playteammembership__playteam_accepted=True
      )
    )

Wyników jednakże podejrzanie mało. Jakież było moje zdziwienie, gdy usunięcie pierwszego obiektu Q, pytającego o moderatorów, zwiększyło liczbę wyników! Przyjrzeliśmy się więc wspólnie z Patrysem temu dziwolągowi. Opad szczeny wywołał widok wynikowego SQLa:

SELECT *
FROM playteam
INNER JOIN playteammembership
ON playteam.id = playteammembership.playteam_id
WHERE (
    playteam.moderator_id = 1 OR
    playteammembership.user_id = 1 OR
    playteammembership.user_accepted = True OR
    playteammembership.playteam_accepted = True
    )

Tak, dobrze widzicie. Warunki wewnąrz drugiego Q zostały połączone operatorem alternatywy, a nie koniunkcji, jak miałoby to miejsce w najczęściej używanych metodach .filter() i .exclude().

Natychmiast spróbowaliśmy tego:

q = Playteam.objects.filter(
    Q(moderator=request.user) &
    Q(
        playteammembership__user=request.user,
        playteammembership__user_accepted=True,
        playteammembership__playteam_accepted=True
      )
    )

Zauważcie główny operator, który zmienił się w koniunkcję. Efektu pewnie się domyślacie:

SELECT *
FROM playteam
INNER JOIN playteammembership
ON playteam.id = playteammembership.playteam_id
WHERE (
    playteam.moderator_id = 1 AND
    playteammembership.user_id = 1 AND
    playteammembership.user_accepted = True AND
    playteammembership.playteam_accepted = True
    )

Gdzie tu sens i logika? Na usprawiedliwienie dodam, że opcja umieszczenia wielu warunków w jednym obiekcie Q nigdzie w dokumentacji nie została opisana. Pozostało więc dopisanie paru literek:

q = Playteam.objects.filter(
    Q(moderator=request.user) |
    (
        Q(playteammembership__user=request.user) &
        Q(playteammembership__user_accepted=True) &
        Q(playteammembership__playteam_accepted=True)
        )
      )
    )

...oraz zgłoszenie błędu. Koniec końców, nawet i to zapytanie nie zwróciło odpowiednich wyników i końcową formą jest OUTER JOIN wołany na okrętkę:

memberof = Playteam.objects.filter(
    playteammembership__user=request.user,
    playteammembership__user_accepted=True,
    playteammembership__playteam_accepted=True
    )
modof = Playteam.objects.filter(moderator=request.user)
q = ((modof | memberof) if memberof.count() else modof).order_by('name')

Próba zapakowania tego w formę leniwą, czyli zwykłą lambdę, nie poskutkowała. Z niewiadomych przyczyn szablon Django nie ewaluował naszej anonimowej funkcji. To będzie tematem na kolejne dochodzenie :)

Tekst sponsorował serwis kononowi.cz.