web-dev-qa-db-fra.com

Django 1.11 Annoter un agrégat de sous-requête

C’est une fonctionnalité très sophistiquée sur laquelle je suis en train de jouer et qui saigne rapidement. Je souhaite annoter un agrégat de sous-requête sur un ensemble de requêtes existant. Faire cela avant la 1.11 impliquait soit de personnaliser SQL, soit de marteler la base de données. Voici la documentation à ce sujet et son exemple:

from Django.db.models import OuterRef, Subquery, Sum
comments = Comment.objects.filter(post=OuterRef('pk')).values('post')
total_comments = comments.annotate(total=Sum('length')).values('total')
Post.objects.filter(length__gt=Subquery(total_comments))

Ils sont annotant sur l'ensemble, ce qui me semble bizarre, mais peu importe.

Je me bats avec cela, alors je le résume à l'exemple le plus simple du monde réel pour lequel j'ai des données. J'ai Carparks qui contient beaucoup de Spaces. Utilisation Book→Author _ si cela vous rend plus heureux mais, pour le moment, je veux simplement commenter le nombre de modèles associés à l'aide de Subquery *.

spaces = Space.objects.filter(carpark=OuterRef('pk')).values('carpark')
count_spaces = spaces.annotate(c=Count('*')).values('c')
Carpark.objects.annotate(space_count=Subquery(count_spaces))

Cela me donne une belle ProgrammingError: more than one row returned by a subquery used as an expression et dans ma tête, cette erreur est parfaitement logique. La sous-requête renvoie une liste d'espaces avec le total annoté.

L'exemple suggérait qu'une sorte de magie se produirait et que je finirais avec un nombre que je pourrais utiliser. Mais ça ne se passe pas ici? Comment annoter des données de sous-requête agrégées?

Hmm, quelque chose a été ajouté au SQL de ma requête ...

J'ai construit un nouveau modèle Carpark/Space et cela a fonctionné. La prochaine étape consiste donc à déterminer ce qui empoisonne mon code SQL. Sur les conseils de Laurent, j'ai jeté un coup d'œil au code SQL et essayé de le faire ressembler davantage à la version qu'ils ont publiée dans leur réponse. Et c'est là que j'ai trouvé le vrai problème:

SELECT "bookings_carpark".*, (SELECT COUNT(U0."id") AS "c"
FROM "bookings_space" U0
WHERE U0."carpark_id" = ("bookings_carpark"."id")
GROUP BY U0."carpark_id", U0."space"
)
AS "space_count" FROM "bookings_carpark";

Je l'ai souligné, mais c'est la sous-requête GROUP BY ... U0."space". C'est réaccorder les deux pour une raison quelconque. Les enquêtes continuent.

Edit 2: Ok, en regardant la sous-requête SQL, je peux voir ce deuxième groupe en passant par

In [12]: print(Space.objects_standard.filter().values('carpark').annotate(c=Count('*')).values('c').query)
SELECT COUNT(*) AS "c" FROM "bookings_space" GROUP BY "bookings_space"."carpark_id", "bookings_space"."space" ORDER BY "bookings_space"."carpark_id" ASC, "bookings_space"."space" ASC

Edit 3 : D'accord! Ces deux modèles ont des ordres de tri. Celles-ci sont transmises à la sous-requête. Ce sont ces commandes qui gonflent ma requête et la cassent.

Je suppose que cela pourrait être un bug dans Django mais à moins de supprimer le Meta-order_by sur ces deux modèles, y at-il un moyen que je peux unsort une requête au moment de la requête?


* Je sais que je pourrais simplement annoter un compte pour cet exemple . Mon but réel pour utiliser ceci est un nombre de filtres beaucoup plus complexe, mais je ne peux même pas le faire fonctionner.

26
Oli

Il est également possible de créer une sous-classe de Subquery, qui modifie le code SQL qu'il génère. Par exemple, vous pouvez utiliser:

class SQCount(Subquery):
    template = "(SELECT count(*) FROM (%(subquery)s) _count)"
    output_field = models.IntegerField()

Vous utilisez ensuite ceci comme vous le feriez avec la classe Subquery originale:

spaces = Space.objects.filter(carpark=OuterRef('pk')).values('pk')
Carpark.objects.annotate(space_count=SQCount(spaces))

Vous pouvez utiliser cette astuce (au moins dans postgres) avec une gamme de fonctions d'agrégation: je l'utilise souvent pour constituer un tableau de valeurs, ou pour les additionner.

30
Matthew Schinckel

Shazaam! Selon mes modifications, une colonne supplémentaire était en sortie de ma sous-requête. C'était pour faciliter la commande (ce qui n'est pas nécessaire dans un COUNT).

Je devais simplement supprimer la méta-commande prescrite du modèle. Vous pouvez le faire en ajoutant simplement un .order_by() vide à la sous-requête. Dans mon code, cela signifiait:

spaces = Space.objects.filter(carpark=OuterRef('pk')).order_by().values('carpark')
count_spaces = spaces.annotate(c=Count('*')).values('c')
Carpark.objects.annotate(space_count=Subquery(count_spaces))

Et ça marche. Superbement. Si ennuyant.

37
Oli

Je viens de tomber dans un cas TRÈS similaire, où je devais obtenir des réservations de sièges pour des événements où le statut de réservation n'était pas annulé. Après avoir essayé de résoudre le problème pendant des heures, voici ce que j'ai considéré comme la cause fondamentale du problème:

Préface: c'est MariaDB, Django 1.11.

Lorsque vous annotez une requête, il obtient une clause GROUP BY Avec les champs que vous sélectionnez (en gros, ce que contient votre sélection de requête values()). Après avoir étudié avec l'outil de ligne de commande MariaDB pourquoi j'obtiens NULLs ou Nones sur les résultats de la requête, je suis arrivé à la conclusion que la clause GROUP BYCOUNT() pour renvoyer NULLs.

Ensuite, j'ai commencé à me plonger dans l'interface QuerySet pour voir comment puis-je supprimer manuellement le forçage de GROUP BY Des requêtes de la base de données, et voici le code suivant:

from Django.db.models.fields import PositiveIntegerField

reserved_seats_qs = SeatReservation.objects.filter(
        performance=OuterRef(name='pk'), status__in=TAKEN_TYPES
    ).values('id').annotate(
        count=Count('id')).values('count')
# Query workaround: remove GROUP BY from subquery. Test this
# vigorously!
reserved_seats_qs.query.group_by = []

performances_qs = Performance.objects.annotate(
    reserved_seats=Subquery(
        queryset=reserved_seats_qs,
        output_field=PositiveIntegerField()))

print(performances_qs[0].reserved_seats)

Donc, fondamentalement, vous devez supprimer/mettre à jour manuellement le champ group_by Sur le sous-requête de la sous-requête afin de ne pas y ajouter un GROUP BY Au moment de l'exécution. De plus, vous devrez spécifier le champ de sortie de la sous-requête, car il semble que Django ne le reconnaît pas automatiquement et lève des exceptions lors de la première évaluation du jeu de requêtes. Fait intéressant, le la deuxième évaluation réussit sans elle.

Je pense que c'est un bogue Django, ou une inefficacité dans les sous-requêtes. Je vais créer un rapport de bogue à ce sujet.

Edit: le rapport de bogue est ici .

11
karolyi

Si je comprends bien, vous essayez de compter Spaces disponible dans un Carpark. La sous-requête semble exagérée pour cela, le bon vieil annotate seul devrait faire l'affaire:

Carpark.objects.annotate(Count('spaces'))

Cela inclura un spaces__count valeur dans vos résultats.


OK, j'ai vu votre note ...

J'ai également pu exécuter votre même requête avec d'autres modèles que j'avais sous la main. Les résultats sont les mêmes, donc la requête de votre exemple semble être OK (testé avec Django 1.11b1):

activities = Activity.objects.filter(event=OuterRef('pk')).values('event')
count_activities = activities.annotate(c=Count('*')).values('c')
Event.objects.annotate(spaces__count=Subquery(count_activities))

Peut-être que votre "exemple le plus simple du monde réel" est trop simple ... pouvez-vous partager les modèles ou d'autres informations?

3
eillarra

Une solution qui fonctionnerait pour n'importe quelle agrégation générale pourrait être implémentée en utilisant Window classes de Django 2.0. J'ai ajouté cela au suivi Django billet aussi.

Cela permet d'agréger des valeurs annotées en calculant l'agrégat sur des partitions en fonction du modèle de requête externe (dans la clause GROUP BY), puis en annotant ces données sur chaque ligne du sous-ensemble de requêtes. La sous-requête peut ensuite utiliser les données agrégées de la première ligne renvoyée et ignorer les autres lignes.

Performance.objects.annotate(
    reserved_seats=Subquery(
        SeatReservation.objects.filter(
            performance=OuterRef(name='pk'),
            status__in=TAKEN_TYPES,
        ).annotate(
            reserved_seat_count=Window(
                expression=Count('pk'),
                partition_by=[F('performance')]
            ),
        ).values('reserved_seat_count')[:1],
        output_field=FloatField()
    )
)
3
Melipone

"travaille pour moi" n'aide pas beaucoup. Mais. J'ai essayé votre exemple sur certains modèles que j'avais à portée de main (type Book -> Author), Cela fonctionne très bien pour moi dans Django 1.11b1.

Êtes-vous sûr de l'exécuter dans la bonne version de Django? Est-ce le code que vous utilisez actuellement? Est-ce que vous testez ceci non pas sur carpark mais sur un modèle plus complexe?

Essayez peut-être de print(thequery.query) pour voir quel code SQL il tente d’exécuter dans la base de données. Voici ce que j'ai obtenu avec mes modèles (édités pour répondre à votre question):

SELECT (SELECT COUNT(U0."id") AS "c"
FROM "carparks_spaces" U0
WHERE U0."carpark_id" = ("carparks_carpark"."id")
GROUP BY U0."carpark_id") AS "space_count" FROM "carparks_carpark"

Pas vraiment une réponse, mais j'espère que ça aidera.

1
Laurent S