web-dev-qa-db-fra.com

Django Jointure externe gauche

J'ai un site Web où les utilisateurs peuvent voir une liste de films et créer des critiques pour eux.

L'utilisateur doit pouvoir voir la liste de tous les films. De plus, S'ils ont revu le film, ils devraient pouvoir voir le score qu'ils lui ont donné. Sinon, le film est simplement affiché sans la partition.

Ils ne se soucient pas du tout des scores fournis par les autres utilisateurs.

Considérer ce qui suit models.py

from Django.contrib.auth.models import User
from Django.db import models


class Topic(models.Model):
    name = models.TextField()

    def __str__(self):
        return self.name


class Record(models.Model):
    user = models.ForeignKey(User)
    topic = models.ForeignKey(Topic)
    value = models.TextField()

    class Meta:
        unique_together = ("user", "topic")

Ce que je veux essentiellement c'est ceci

select * from bar_topic
left join (select topic_id as tid, value from bar_record where user_id = 1)
on tid = bar_topic.id

Considérer ce qui suit test.py pour le contexte:

from Django.test import TestCase

from bar.models import *


from Django.db.models import Q

class TestSuite(TestCase):

    def setUp(self):
        t1 = Topic.objects.create(name="A")
        t2 = Topic.objects.create(name="B")
        t3 = Topic.objects.create(name="C")
        # 2 for Johnny
        johnny = User.objects.create(username="Johnny")
        johnny.record_set.create(topic=t1, value=1)
        johnny.record_set.create(topic=t3, value=3)
        # 3 for Mary
        mary = User.objects.create(username="Mary")
        mary.record_set.create(topic=t1, value=4)
        mary.record_set.create(topic=t2, value=5)
        mary.record_set.create(topic=t3, value=6)

    def test_raw(self):
        print('\nraw\n---')
        with self.assertNumQueries(1):
            topics = Topic.objects.raw('''
                select * from bar_topic
                left join (select topic_id as tid, value from bar_record where user_id = 1)
                on tid = bar_topic.id
                ''')
            for topic in topics:
                print(topic, topic.value)

    def test_orm(self):
        print('\norm\n---')
        with self.assertNumQueries(1):
            topics = Topic.objects.filter(Q(record__user_id=1)).values_list('name', 'record__value')
            for topic in topics:
                print(*topic)

LES DEUX tests devraient imprimer exactement la même sortie, cependant, seule la version brute crache le tableau de résultats correct:

brut 
 --- 
 A 1 
 B Aucun 
 C 3

l'orm renvoie à la place ce

orm 
 --- 
 A 1 
 C 3

Toute tentative de rejoindre le reste des sujets, ceux qui n'ont aucun avis de l'utilisateur "johnny", se traduit par ce qui suit:

orm
---
A 1
A 4
B 5
C 3
C 6

Comment puis-je accomplir le comportement simple de la requête brute avec l'ORM Django?

edit: Ce genre de travaux mais semble très pauvre:

topics = Topic.objects.filter (record__user_id = 1) .values_list ('name', 'record__value') 
 noned = Topic.objects.exclude (record__user_id = 1) .values_list ('name') 
 pour le sujet dans la chaîne (sujets, non): 
 ...

edit: Cela fonctionne un peu mieux, mais toujours mauvais:

    topics = Topic.objects.filter (record__user_id = 1) .annotate (value = F ('record__value')) 
 topics | = Topic.objects.exclude (pk__in = topics)
orm 
 --- 
 A 1 
 B 5 
 C 3
22
RodericDay

Tout d'abord, il n'y a pas moyen (atm Django 1.9.7) d'avoir une représentation avec l'ORM de Django de la requête brute que vous avez publiée, exactement comme vous le souhaitez; cependant, vous pouvez obtenir le même résultat souhaité avec quelque chose comme:

>>> Topic.objects.annotate(
        f=Case(
            When(
                record__user=johnny, 
                then=F('record__value')
            ), 
            output_field=IntegerField()
        )
    ).order_by(
        'id', 'name', 'f'
    ).distinct(
        'id', 'name'
    ).values_list(
        'name', 'f'
    )
>>> [(u'A', 1), (u'B', None), (u'C', 3)]

>>> Topic.objects.annotate(f=Case(When(record__user=may, then=F('record__value')), output_field=IntegerField())).order_by('id', 'name', 'f').distinct('id', 'name').values_list('name', 'f')
>>> [(u'A', 4), (u'B', 5), (u'C', 6)]

Voici le SQL généré pour la première requête:

>>> print Topic.objects.annotate(f=Case(When(record__user=johnny, then=F('record__value')), output_field=IntegerField())).order_by('id', 'name', 'f').distinct('id', 'name').values_list('name', 'f').query

>>> SELECT DISTINCT ON ("payments_topic"."id", "payments_topic"."name") "payments_topic"."name", CASE WHEN "payments_record"."user_id" = 1 THEN "payments_record"."value" ELSE NULL END AS "f" FROM "payments_topic" LEFT OUTER JOIN "payments_record" ON ("payments_topic"."id" = "payments_record"."topic_id") ORDER BY "payments_topic"."id" ASC, "payments_topic"."name" ASC, "f" ASC

Quelques notes

  • N'hésitez pas à utiliser des requêtes brutes, surtout lorsque la performance est la chose la plus importante. De plus, parfois c'est un must car vous ne pouvez pas obtenir le même résultat en utilisant l'ORM de Django; dans d'autres cas, vous pouvez, mais de temps en temps, avoir un code propre et compréhensible est plus important que les performances dans cette pièce de code.
  • distinct avec des arguments positionnels est utilisé dans cette réponse, qui n'est disponible que pour PostgreSQL, atm. Dans la documentation, vous pouvez en savoir plus sur expressions conditionnelles .
22
trinchet

Ce que je veux essentiellement c'est ceci

select * from bar_topic
left join (select topic_id as tid, value from bar_record where user_id = 1)
on tid = bar_topic.id

... ou, peut-être cet équivalent qui évite une sous-requête ...

select * from bar_topic
left join bar_record
on bar_record.topic_id = bar_topic.id and bar_record.user_id = 1

Je veux savoir comment le faire efficacement ou, si c'est impossible, une explication de pourquoi c'est impossible ...

À moins que vous n'utilisiez des requêtes brutes, c'est impossible avec l'ORM de Django, et voici pourquoi.

QuerySet objets (Django.db.models.query.QuerySet) ont un attribut query (Django.db.models.sql.query.Query) qui est une représentation de la requête réelle qui sera exécutée. Ces objets Query ont utilement une méthode __str__, Vous pouvez donc l'imprimer pour voir de quoi il s'agit.

Commençons par un simple QuerySet...

>>> from bar.models import *
>>> qs = Topic.objects.filter(record__user_id=1)
>>> print qs.query
SELECT "bar_topic"."id", "bar_topic"."name" FROM "bar_topic" INNER JOIN "bar_record" ON ("bar_topic"."id" = "bar_record"."topic_id") WHERE "bar_record"."user_id" = 1

... ce qui ne va évidemment pas fonctionner, à cause du INNER JOIN.

En regardant de plus près à l'intérieur de l'objet Query, il y a un attribut alias_map Qui détermine quelles jointures de table seront effectuées ...

>>> from pprint import pprint
>>> pprint(qs.query.alias_map)
{u'bar_record': JoinInfo(table_name=u'bar_record', rhs_alias=u'bar_record', join_type='INNER JOIN', lhs_alias=u'bar_topic', lhs_join_col=u'id', rhs_join_col='topic_id', nullable=True),
 u'bar_topic': JoinInfo(table_name=u'bar_topic', rhs_alias=u'bar_topic', join_type=None, lhs_alias=None, lhs_join_col=None, rhs_join_col=None, nullable=False),
 u'auth_user': JoinInfo(table_name=u'auth_user', rhs_alias=u'auth_user', join_type='INNER JOIN', lhs_alias=u'bar_record', lhs_join_col='user_id', rhs_join_col=u'id', nullable=False)}

Notez que Django ne prend en charge que deux join_type S, INNER JOIN Et LEFT OUTER JOIN.

Maintenant, nous pouvons utiliser les méthodes promote_joins De l'objet Query pour utiliser un LEFT OUTER JOIN Sur la table bar_record ...

>>> qs.query.promote_joins(['bar_record'])
>>> pprint(qs.query.alias_map)
{u'bar_record': JoinInfo(table_name=u'bar_record', rhs_alias=u'bar_record', join_type='LEFT OUTER JOIN', lhs_alias=u'bar_topic', lhs_join_col=u'id', rhs_join_col='topic_id', nullable=True),
 u'bar_topic': JoinInfo(table_name=u'bar_topic', rhs_alias=u'bar_topic', join_type=None, lhs_alias=None, lhs_join_col=None, rhs_join_col=None, nullable=False),
 u'auth_user': JoinInfo(table_name=u'auth_user', rhs_alias=u'auth_user', join_type='LEFT OUTER JOIN', lhs_alias=u'bar_record', lhs_join_col='user_id', rhs_join_col=u'id', nullable=False)}

... qui changera la requête en ...

>>> print qs.query
SELECT "bar_topic"."id", "bar_topic"."name" FROM "bar_topic" LEFT OUTER JOIN "bar_record" ON ("bar_topic"."id" = "bar_record"."topic_id") WHERE "bar_record"."user_id" = 1

... cependant, cela n'est toujours pas utile, car la jointure correspondra toujours à une ligne, même si elle n'appartient pas à l'utilisateur correct, et la clause WHERE la filtrera.

L'utilisation de values_list() influence automatiquement le join_type ...

>>> qs = Topic.objects.filter(record__user_id=1).values_list('name', 'record__value')
>>> print qs.query
SELECT "bar_topic"."name", "bar_record"."value" FROM "bar_topic" LEFT OUTER JOIN "bar_record" ON ("bar_topic"."id" = "bar_record"."topic_id") WHERE "bar_record"."user_id" = 1

... mais souffre finalement du même problème.

Il y a, malheureusement, une limitation fondamentale dans les jointures générées par l'ORM, en ce qu'elles ne peuvent être que de la forme ...

(LEFT OUTER|INNER) JOIN <lhs_alias> ON (<lhs_alias>.<lhs_join_col> = <rhs_alias>.<rhs_join_col>)

... donc il n'y a vraiment aucun moyen d'atteindre le SQL souhaité, à part utiliser une requête brute.

Bien sûr, vous pouvez pirater des choses comme annotate() et extra(), mais elles généreront probablement des requêtes beaucoup moins performantes et sans doute pas plus lisibles que le SQL brut.


... et une alternative suggérée.

Personnellement, je voudrais simplement utiliser la requête brute ...

select * from bar_topic
left join bar_record
on bar_record.topic_id = bar_topic.id and bar_record.user_id = 1

... ce qui est assez simple pour être compatible avec tous les backends supportés par Django.

9
Aya

Voilà comment je le ferais. Deux requêtes, pas une:

class Topic(models.Model):
    #...

    @property
    def user_value(self):
        try:
            return self.user_records[0].value
        except IndexError:
            #This topic does not have 
            #a review by the request.user
            return None
        except AttributeError:
            raise AttributeError('You forgot to prefetch the user_records')
            #or you can just
            return None

#usage
topics = Topic.objects.all().prefetch_related(
    models.Prefetch('record_set',
        queryset=Record.objects.filter(user=request.user),
        to_attr='user_records'
    )
)

for topic in topics:
    print topic.user_value

L'avantage est que vous obtenez tout l'objet Record. Considérez donc une situation où vous voulez non seulement afficher le value, mais le time-stamp aussi.

Pour mémoire, je veux montrer une autre solution en utilisant .extra. Je suis impressionné que personne ne l'ait mentionné, car il devrait produire les meilleures performances possibles.

topics = Topic.objects.all().extra(
    select={
        'user_value': """SELECT value FROM myapp_record 
            WHERE myapp_record.user_id = %s
            AND myapp_record.topic_id = myapp_topic.id 
        """
    },
    select_params=(request.user.id,)
)

for topic in topics
    print topic.user_value

Les deux solutions peuvent être résumées dans une classe TopicQuerySet personnalisée pour être réutilisables.

class TopicQuerySet(models.QuerySet):

    def prefetch_user_records(self, user):
        return self.prefetch_related(
            models.Prefetch('record_set',
                queryset=Record.objects.filter(user=request.user),
                to_attr='user_records'
            )
        )

    def annotate_user_value(self, user):
        return self.extra(
            select={
                'user_value': """SELECT value FROM myapp_record 
                    WHERE myapp_record.user_id = %s
                    AND myapp_record.topic_id = myapp_topic.id 
                """
            },
            select_params=(user.id,)
        )

class Topic(models.Model):
    #...

    objects = TopicQuerySet.as_manager()


#usage
topics = Topic.objects.all().annotate_user_value(request.user)
#or
topics = Topic.objects.all().prefetch_user_records(request.user)

for topic in topics:
    print topic.user_value
7
Todor

Cette solution plus universelle inspirée de réponse de Trinchet fonctionne également avec d'autres bases de données:

>>> qs = Topic.objects.annotate(
...         f=Max(Case(When(record__user=johnny, then=F('record__value'))))
... )

exemples de données

>>> print(qs.values_list('name', 'f'))
[(u'A', 1), (u'B', None), (u'C', 3)]

vérifier la requête

>>> print(qs.query)  # formated and removed excessive double quotes
SELECT bar_topic.id, bar_topic.name,
       MAX(CASE WHEN bar_record.user_id = 1 THEN bar_record.value ELSE NULL END) AS f
FROM bar_topic LEFT OUTER JOIN bar_record ON (bar_topic.id = bar_record.topic_id)
GROUP BY bar_topic.id, bar_topic.name

Avantages (par rapport aux solutions originales)

  • Il fonctionne également avec SQLite.
  • L'ensemble de requêtes peut être facilement filtré ou trié, peu importe comment.
  • Aucune conversion de type output_field N'est nécessaire.
  • Les méthodes values ou values_list(*field_names) sont utiles pour un GROUP BY Plus simple, mais elles ne sont pas nécessaires.

La jointure gauche peut être rendue plus lisible en écrivant une fonction:

from Django.db.models import Max, Case, When, F

def left_join(result_field, **lookups):
    return Max(Case(When(then=F(result_field), **lookups)))

>>> Topic.objects.annotate(
...         record_value=left_join('record__value', record__user=johnny),
... ).values_list('name', 'record_value')

Plus de champs de Record peuvent être ajoutés par la méthode anotate aux résultats de cette façon avec des noms mnémoniques Nice.

Je suis d'accord avec d'autres auteurs qu'il peut être optimisé, mais la lisibilité compte .

[~ # ~] edit [~ # ~] : Le même résultat se produit si la fonction d'agrégation Max est remplacée par Min. Min et Max ignorent les valeurs NULL et peuvent être utilisés sur n'importe quel type, par ex. pour les cordes. L'agrégation est utile si la jointure gauche n'est pas garantie d'être unique. Si le champ est numérique, il peut être utile d'utiliser la valeur moyenne Avg sur la jointure gauche.

7
hynekcer

Requêtes brutes.

topics = Topic.objects.raw('''
            select * from bar_topic
            left join (select topic_id as tid, value from bar_record where user_id = 1) AS subq
            on tid = bar_topic.id
            ''')

Vous semblez connaître la réponse vous-même. Il n'y a rien de mal à utiliser une requête brute lorsque vous ne pouvez pas obtenir que la requête ORM se comporte exactement comme vous le souhaitez.

Un inconvénient principal des requêtes brutes est qu'elles ne sont pas mises en cache comme les requêtes ORM. Cela signifie que si vous parcourez le jeu de requêtes brut deux fois, la requête sera répétée. Un autre est que vous ne pouvez pas appeler .count () dessus.

Clés étrangères nulles

Vous pouvez forcer l'ORM à utiliser LEFT OUTER JOIN EN définissant null=True dans les clés étrangères. Faites-le avec les tableaux tels quels.

print Record.objects.filter(user_id=8).select_related('topic').query

Le résultat est

SELECT "bar_record"."id", "bar_record"."user_id", "bar_record"."topic_id", "bar_record"."value", "bar_topic"."id", "bar_topic"."name" FROM "bar_record"
INNER JOIN "bar_topic" ON ( "bar_record"."topic_id" = "bar_topic"."id" ) WHERE "bar_record"."user_id" = 8

Maintenant défini, null = True et effectuez la même requête ORM que ci-dessus. Le résultat est

SELECT "bar_record"."id", "bar_record"."user_id", "bar_record"."topic_id", "bar_record"."value", "bar_topic"."id", "bar_topic"."name" FROM "bar_record" 
LEFT OUTER JOIN "bar_topic" ON ( "bar_record"."topic_id" = "bar_topic"."id" ) WHERE "bar_record"."user_id" = 8

Notez comment la requête est soudainement devenue LEFT OUTER JOIN. Mais nous ne sommes pas encore sortis du bois car l'ordre des tables doit être inversé! Ainsi, à moins que vous ne puissiez restructurer vos modèles, un ORM LEFT OUTER JOIN peut ne pas être entièrement possible sans chaîner ou UNION que vous avez déjà essayé.

5
e4c5