Blog Queryset Chaining mit Proxy Models

Nein, dieser Artikel ist in deutsch, auch wenn der Titel etwas anderes vermuten lässt. In diesem Artikel geht es darum, Querysets zu verbinden. Wann braucht man sowas? Stichwort Tumblelog: eine chronologische Liste verschiedener Quellen, in unserem Beispiel Twitter-Tweets und natürlich einfacher Weblog-Beiträge. Beginnen wir wieder mit den Models:

# Weblog Beiträge
class BlogEntry(models.Model):
    title = models.CharField(max_length=255)
    content = models.TextField()
    created = models.DateTimeField()

    def __unicode__(self):
        return self.title

# Tweets
class Tweet(models.Model):
    tweet_text = models.CharField(max_length=140)
    created = models.DateTimeField()

    def __unicode__(self):
        return '%s...' % self.tweet_text[:40]

Und füllen das ganze mit ein paar Testdaten:

>>> from myproject.models import BlogEntry, Tweet
>>> from datetime import datetime

>>> BlogEntry.objects.create(title='Blogeintrag #1', content='blabla', created=datetime.now())
>>> Tweet.objects.create(tweet_text='Zwitscher Zwitscher', created=datetime.now())
>>> BlogEntry.objects.create(title='Blogeintrag #2', content='blabla blub', created=datetime.now())

Verbindung hergestellt

Es gibt verschiedene Möglichkeiten, Querysets zu verbinden (engl. chainen), hier machen wir es schnell und einfach auf Python Ebene. Es gibt weitere Möglichkeiten, zum Beispiel mit Generic-Foreignkeys (mehr dazu am Ende) aber hier im Artikel bewegen wir uns nur auf der Python-Ebene.

Die Einfachste Möglichkeit ist der Pipe-Operator |. Er erlaubt das Kombinieren zweier Querysets aus dem selben Model. Bei unserem Beispiel funktioniert er also schonmal nicht:

>>> BlogEntry.objects.all() | Tweet.objects.all()
AssertionError: Cannot combine queries on two different base models.

Um ehrlich zu sein, ich hatte noch nie den Fall, diese Variante zu brauchen. In der Regel löst man so etwas auch besser über multiple filter Argumente.

Also wie geht das nun mit unterschiedlichen Models respektive Querysets? Dabei hilft uns das Pythonmodul itertools und daraus die Funktion chain. Es verbindet die Querysets (eigentlich jedes iterable Objekt) und erzeugt einen Generator:

>>> from itertools import chain

>>> entry_list = chain(BlogEntry.objects.all(), Tweet.objects.all())
>>> for e in entry_list:
...     print e, e.created.strftime("%X")
...
Blogeintrag #1 19:45:51
Blogeintrag #2 19:46:35
Zwitscher Zwitscher... 19:46:24

Woohoo, aus zwei Querysets wurde eins!

Bitte in einer Reihe anstellen

Aber Moment, da ist ja ein Problem. Das Tweet-Queryset wurde einfach hinten dran gehängt. In einem Tumblelog macht das wenig Sinn, wir möchten dort alle Einträge hübsch nach Datum sortiert haben. Die Python-Funktion sorted hilft dabei:

>>> entry_list = chain(BlogEntry.objects.all(), Tweet.objects.all())
>>> entry_list = sorted(entry_list, key=lambda x: x.created)
>>> for e in entry_list:
...     print e, e.created.strftime("%X")
...
Blogeintrag #1 19:45:51
Zwitscher Zwitscher... 19:46:24
Blogeintrag #2 19:46:35

Und da in einem Tumblelog typischerweise die neuesten Beiträge ganz oben stehen, geben wir sorted noch das Argument reverse=True dazu:

>>> entry_list = chain(BlogEntry.objects.all(), Tweet.objects.all())
>>> entry_list = sorted(entry_list, key=lambda x: x.created, reverse=True)
>>> for e in entry_list:
...     print e, e.created.strftime("%X")
...
Blogeintrag #2 19:46:35
Zwitscher Zwitscher... 19:46:24
Blogeintrag #1 19:45:51

Captain, da tanzt jemand aus der Reihe

Schön und gut das Ganze und so wie wir es jetzt haben, wird es in den meisten Fällen auch funktionieren. Ein Problem kann es aber noch geben: Wir sortieren ja nach dem Feld created und leider gibt es keine Konvention, wie man ein Datumsfeld auf Datenbankebene benennt. Angenommen im Tweet-Model heißt unser Datumsfeld nicht created sondern published:

# Weblog Beiträge
class BlogEntry(models.Model):
    title = models.CharField(max_length=255)
    content = models.TextField()
    created = models.DateTimeField()

# Tweets
class Tweet(models.Model):
    tweet_text = models.CharField(max_length=140)
    published = models.DateTimeField()

Ein Aufruf von sorted bringt nun natürlich einen entsprechenden Fehler:

>>> entry_list = chain(BlogEntry.objects.all(), Tweet.objects.all())
>>> entry_list = sorted(entry_list, key=lambda x: x.created, reverse=True)
AttributeError: 'Tweet' object has no attribute 'created'

Was machen wir jetzt? Ganz einfach, wir benennen published wieder in created um, ändern alle Views und Models und es funktioniert wieder. :-) Ok ein schlechter Tipp, es gibt ja genügend Gründe, dass man ein Model nicht einfach so ändern kann. Der einfachste Grund ist, dass Tweet ein Model eines Third-Party Apps ist und man beim nächsten Update nicht wieder alles durchforsten und ändern möchte. Aber für diesen Fall gibt es in Django ab Version 1.1 eine Lösung: Proxy Models.

Das ist jetzt wichtig zu Verstehen: Proxy Models arbeiten nicht auf Datenbankebene, man kann mit Ihnen Python-Funktionen zu bestehenden Models hinzufügen. Unsere Aufgabe ist jetzt, ein created Feld zum Tweet-Model hinzuzufügen, aber es ist kein Feld im Datenbank-Sinne, es ist eine simple Python-Funktion:

from myproject.models import Tweet

class ChainedTweet(Tweet):
    class Meta:
        proxy = True

    @property
    def created(self):
        return self.published

Wir wrappen mit diesem Proxy-Model eigentlich nur das Feld published und geben es als created zurück. Nochmal zum Verständnis, es ist kein Datenbankfeld, eine Sortierung im Queryset funktioniert nicht:

>>> ChainedTweet.objects.order_by('created')
FieldError: Cannot resolve keyword 'created' into field. Choices are: id, published, tweet_text

Aber auf Python-Ebene funktioniert es:

>>> [(e, e.created.strftime("%X")) for e in ChainedTweet.objects.all()]
[(<ChainedTweet: Zwitscher Zwitscher...>, '20:43:55')]

Und da unser itertools.chain auch auf Python-Ebene arbeitet, nehmen wir diesmal statt des Tweet Models unser Proxy Model ChainedTweet (das ja created kennt) und es funktioniert. :-)

>>> entry_list = chain(BlogEntry.objects.all(), ChainedTweet.objects.all())
>>> entry_list = sorted(entry_list, key=lambda x: x.created, reverse=True)
>>> for e in entry_list:
...     print e, e.created.strftime("%X")
...
Blogeintrag #2 20:44:04
Zwitscher Zwitscher... 20:43:55
Blogeintrag #1 20:43:38

Zu guter Letzt sei noch erwähnt, dass itertools.chain unbegrenzt viele Iteratoren entgegen nehmen kann.

Die Python-Ebene

Vielleicht fragst du dich jetzt, warum ich immer und immer wieder erwähnt habe, dass wir auf Python-Ebene arbeiten. Ganz einfach: sei dir bewusst dass wir hier die Arbeit nicht der Datenbank überlassen und jeder Eintrag in den Speicher geschrieben wird. Sicher, Python wird hier und da Speicher sparen, indem es Generatoren statt einfacher Listen nutzt aber letztendlich ist die entry_list komplett im Speicher abgebildet. Es ist daher eine gute Idee nicht alle Einträge deines Models zu chainen, sondern nur die, die du wirklich anzeigen willst:

>>> entry_list = chain(BlogEntry.objects.all()[:10],  ChainedTweet.objects.all()[:10])

Willst du ein Tumblelog mit Pagination, dass auch noch effektiv unlimitiert Einträge verarbeiten kann, schau dir einmal Ryan Bergs Beispiel mit Generic-Foreignkeys an. Willst du aber schnell und einfach kurze Querysets verbinden, sortieren und anzeigen, dann ist das Verfahren in diesem Artikel das Richtige -- naja zumindestens das schmerzlosere. :-)