8 votes

Django Inline pour ManyToMany génère des requêtes en double

Je rencontre un problème majeur de performance avec mon administration django. Beaucoup de requêtes dupliquées en fonction du nombre de lignes que j'ai.

models.py

class Setting(models.Model):
    name = models.CharField(max_length=50, unique=True)

    class Meta:
        ordering = ('name',)

    def __str__(self):
        return self.name

class DisplayedGroup(models.Model):
    name = models.CharField(max_length=30, unique=True)
    position = models.PositiveSmallIntegerField(default=100)

    class Meta:
        ordering = ('priority',)

    def __str__(self):
        return self.name

class Machine(models.Model):
    name = models.CharField(max_length=20, unique=True)
    settings = models.ManyToManyField(
        Setting, through='Arrangement', blank=True
    )

    class Meta:
        ordering = ('name',)

    def __str__(self):
        return self.name

class Arrangement(models.Model):
    machine = models.ForeignKey(Machine, on_delete=models.CASCADE)
    setting = models.ForeignKey(Setting, on_delete=models.CASCADE)
    displayed_group = models.ForeignKey(
        DisplayedGroup, on_delete=models.PROTECT,
        default=1)
    priority = models.PositiveSmallIntegerField(
        default=100,
        help_text='Smallest number will be displayed first'
    )

    class Meta:
        ordering = ('priority',)
        unique_together = (("machine", "setting"),)

admin.py

class ArrangementInline(admin.TabularInline):
    model = Arrangement
    extra = 1

class MachineAdmin(admin.ModelAdmin):
    inlines = (ArrangementInline,)

Si j'ai 3 paramètres ajoutés au formulaire en ligne et 1 supplémentaire, j'ai environ 10 requêtes en double.

SELECT "corps_setting"."id", "corps_setting"."name", "corps_setting"."user_id", "corps_setting"."tagged", "corps_setting"."created", "corps_setting"."modified" FROM "corps_setting" ORDER BY "corps_setting"."name" ASC
- Duplicated 5 times

SELECT "corps_displayedgroup"."id", "corps_displayedgroup"."name", "corps_displayedgroup"."color", "corps_displayedgroup"."priority", "corps_displayedgroup"."created", "corps_displayedgroup"."modified" FROM "corps_displayedgroup" ORDER BY "corps_displayedgroup"."priority" ASC
- Duplicated 5 times.

Quelqu'un pourrait-il me dire ce que je fais de mal ici ? J'ai passé 3 jours à essayer de résoudre le problème moi-même, sans succès.

Le problème s'aggrave lorsque j'ai environ 50 paramètres en ligne d'une Machine, j'aurai ~100 requêtes.

Voici la capture d'écran

15voto

isobolev Points 825

J'ai assemblé une solution générique basée sur la réponse de @makaveli qui ne semble pas avoir de problème mentionné dans les commentaires :

class CachingModelChoicesFormSet(forms.BaseInlineFormSet):
    """
    Used to avoid duplicate DB queries by caching choices and passing them all the forms.
    To be used in conjunction with `CachingModelChoicesForm`.
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        sample_form = self._construct_form(0)
        self.cached_choices = {}
        try:
            model_choice_fields = sample_form.model_choice_fields
        except AttributeError:
            pass
        else:
            for field_name in model_choice_fields:
                if field_name in sample_form.fields and not isinstance(
                    sample_form.fields[field_name].widget, forms.HiddenInput):
                    self.cached_choices[field_name] = [c for c in sample_form.fields[field_name].choices]

    def get_form_kwargs(self, index):
        kwargs = super().get_form_kwargs(index)
        kwargs['cached_choices'] = self.cached_choices
        return kwargs

class CachingModelChoicesForm(forms.ModelForm):
    """
    Gets cached choices from `CachingModelChoicesFormSet` and uses them in model choice fields in order to reduce
    number of DB queries when used in admin inlines.
    """

    @property
    def model_choice_fields(self):
        return [fn for fn, f in self.fields.items()
            if isinstance(f, (forms.ModelChoiceField, forms.ModelMultipleChoiceField,))]

    def __init__(self, *args, **kwargs):
        cached_choices = kwargs.pop('cached_choices', {})
        super().__init__(*args, **kwargs)
        for field_name, choices in cached_choices.items():
            if choices is not None and field_name in self.fields:
                self.fields[field_name].choices = choices

Il vous suffit de sous-classer votre modèle à partir de CachingModelChoicesForm et d'utiliser CachingModelChoicesFormSet dans votre classe en ligne :

class ArrangementInlineForm(CachingModelChoicesForm):
    class Meta:
        model = Arrangement
        exclude = ()

class ArrangementInline(admin.TabularInline):
    model = Arrangement
    extra = 50
    form = ArrangementInlineForm
    formset = CachingModelChoicesFormSet

6voto

makaveli Points 1779

C'est le comportement normal de Django - il ne fait pas l'optimisation pour vous, mais il vous donne des outils décents pour le faire vous-même. Et ne vous inquiétez pas, 100 requêtes n'est pas vraiment un gros problème (j'ai vu 16 000 requêtes sur une page) qui doit être résolu immédiatement. Mais si vos quantités de données augmentent rapidement, il est sage de s'en occuper, bien sûr.

Les principales armes dont vous disposerez sont les méthodes queryset. select_related() y prefetch_related() . Il n'y a pas vraiment lieu de s'y attarder, car ils sont très bien documentés. aquí mais juste une indication générale :

  • utiliser select_related() lorsque l'objet que vous interrogez n'a qu'un seul objet connexe (FK ou one2one)

  • utiliser prefetch_related() lorsque l'objet que vous interrogez a plusieurs objets connexes (l'autre extrémité de FK ou M2M).

Et comment les utiliser dans l'administration de Django, vous demandez-vous ? Elémentaire, mon cher Watson. Remplacez la méthode de la page d'administration get_queryset(self, request) donc ça ressemblerait à quelque chose comme ça :

from django.contrib import admin

class SomeRandomAdmin(admin.ModelAdmin):
    def get_queryset(self, request):
        return super().get_queryset(request).select_related('field1', 'field2').prefetch_related('field3')    

EDIT : Après avoir lu votre commentaire, je me rends compte que mon interprétation initiale de votre question était absolument fausse. J'ai également plusieurs solutions à votre problème et voici ce que j'en pense :

  1. La plus simple que j'utilise la plupart du temps et que je recommande : remplacez simplement les widgets de sélection par défaut de Django par raw_id_field et aucune requête n'est effectuée. Il suffit de définir raw_id_fields = ('setting', 'displayed_group') dans l'administration en ligne et être fait pour.

  2. Mais, si vous ne voulez pas vous débarrasser des boîtes de sélection, je peux vous donner un code un peu compliqué qui fait l'affaire, mais qui est plutôt long et pas très joli. L'idée est de remplacer le jeu de formulaires qui crée les formulaires et de spécifier des choix pour ces champs dans le jeu de formulaires afin qu'ils ne soient interrogés qu'une seule fois dans la base de données.

C'est parti :

from django import forms
from django.contrib import admin
from app.models import Arrangement, Machine, Setting, DisplayedGroup

class ChoicesFormSet(forms.BaseInlineFormSet):
    setting_choices = list(Setting.objects.values_list('id', 'name'))
    displayed_group_choices = list(DisplayedGroup.objects.values_list('id', 'name'))

    def _construct_form(self, i, **kwargs):
        kwargs['setting_choices'] = self.setting_choices
        kwargs['displayed_group_choices'] = self.displayed_group_choices
        return super()._construct_form(i, **kwargs)

class ArrangementInlineForm(forms.ModelForm):
    class Meta:
        model = Arrangement
        exclude = ()

    def __init__(self, *args, **kwargs):
        setting_choices = kwargs.pop('setting_choices', [((), ())])
        displayed_group_choices = kwargs.pop('displayed_group_choices', [((), ())])

        super().__init__(*args, **kwargs)

        # This ensures that you can still save the form without setting all 50 (see extra value) inline values.
        # When you save, the field value is checked against the "initial" value
        # of a field and you only get a validation error if you've changed any of the initial values.
        self.fields['setting'].choices = [('-', '---')] + setting_choices
        self.fields['setting'].initial = self.fields['setting'].choices[0][0]
        self.fields['setting'].empty_values = (self.fields['setting'].choices[0][0],)

        self.fields['displayed_group'].choices = displayed_group_choices
        self.fields['displayed_group'].initial = self.fields['displayed_group'].choices[0][0]

class ArrangementInline(admin.TabularInline):
    model = Arrangement
    extra = 50
    form = ArrangementInlineForm
    formset = ChoicesFormSet

    def get_queryset(self, request):
        return super().get_queryset(request).select_related('setting')

class MachineAdmin(admin.ModelAdmin):
    inlines = (ArrangementInline,)

admin.site.register(Machine, MachineAdmin)

Si vous trouvez quelque chose qui pourrait être amélioré ou si vous avez des questions, faites-le moi savoir.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X