Expressions de recherche personnalisées

Django offre une large variété d”expressions de recherche intégrées pour filtrer les requêtes (par exemple, exact et icontains). Cette documentation explique comment écrire des expressions personnalisées et comment modifier le comportement d’expressions existantes. Pour la référence des API de recherche, consultez API de référence des expressions de recherche.

Un exemple d’expression de recherche

Commençons par une petite expression de recherche. Nous allons écrire une expression personnalisée ne fonctionnant à l’inverse de exact. Author.objects.filter(name__ne='Jack') s’exprime comme ceci en SQL :

"author"."name" <> 'Jack'

Ce code SQL est indépendant de la base de données, nous n’avons donc pas à nous soucier de la gestion de bases de données différentes.

Deux étapes sont nécessaires pour que cela fonctionne. Nous devons d’abord implémenter la recherche, puis nous devons informer Django de sa disponibilité

from django.db.models import Lookup

class NotEqual(Lookup):
    lookup_name = 'ne'

    def as_sql(self, compiler, connection):
        lhs, lhs_params = self.process_lhs(compiler, connection)
        rhs, rhs_params = self.process_rhs(compiler, connection)
        params = lhs_params + rhs_params
        return '%s <> %s' % (lhs, rhs), params

Pour inscrire la recherche NotEqual, il faut appeler register_lookup sur la classe de champ pour lequel nous souhaitons rendre disponible cette recherche. Dans ce cas, l’expression peut s’appliquer à toutes les sous-classes de Field, c’est pourquoi nous l’inscrivons directement dans Field:

from django.db.models.fields import Field
Field.register_lookup(NotEqual)

L’enregistrement d’expression peut aussi se faire par la technique de décoration :

from django.db.models.fields import Field

@Field.register_lookup
class NotEqualLookup(Lookup):
    # ...

Nous pouvons dorénavant écrire champ__ne pour n’importe quel champ. Il faut s’assurer que l’inscription a lieu avant la création de tout jeu de requête utilisant l’expression. Il est possible de placer cette implémentation dans un fichier models.py ou d’enregistrer l’expression dans la méthode ready() d’une classe AppConfig.

En examinant plus précisément l’implémentation, le premier attribut obligatoire est lookup_name. Cela permet à l’ORM de savoir comment interpréter name__ne et d’utiliser NotEqual pour produire le code SQL. Par convention, ces noms sont toujours en minuscules et ne contiennent que des lettres, mais la seule exigence absolue est l’interdiction d’utiliser la chaîne __.

Nous devons ensuite définir la méthode as_sql. Elle accepte un objet SQLCompiler appelé compiler ainsi que la connexion de base de données active. Les objets SQLCompiler ne sont pas documentés, mais la seule chose à savoir à leur sujet est qu’ils comportent une méthode compile() qui renvoie un tuple contenant une chaîne SQL ainsi que les paramètres à insérer dans cette chaîne. Dans la plupart des cas, il n’est pas nécessaire de l’utiliser directement et il suffit alors de la transmettre à process_lhs() et process_rhs().

Un objet Lookup fonctionne avec deux valeurs, lhs et rhs, qui signifient côté gauche (« left-hand side ») et côté droite (« right-hand side »). Le côté gauche est normalement une référence de champ, mais cela peut aussi être n’importe quel objet implémentant l”API d’expression de recherche. Le côté droit correspond à la valeur donnée par l’utilisateur. Dans l’exemple Author.objects.filter(name__ne='Jack'), le côté gauche est une référence au champ name du modèle Author, alors que 'Jack' correspond au côté droit.

Nous appelons process_lhs et process_rhs pour les convertir en valeurs nécessaires pour le code SQL en utilisant l’objet compiler décrit précédemment. Ces méthodes renvoient des tuples contenant le code SQL ainsi que les paramètres à insérer dans ce code, exactement comme nous en avons besoin pour la valeur de retour de notre méthode as_sql. Dans l’exemple ci-dessus, process_lhs renvoie ('"author"."name"', []) et process_rhs renvoie ('"%s"', ['Jack']). Dans cet exemple, le côté gauche ne contient aucun paramètre, mais cela dépend de l’objet concerné, il faut donc tout de même les inclure dans les paramètres que nous renvoyons.

Pour terminer, nous combinons les différentes parties dans une expression SQL avec <>, et nous fournissons tous les paramètres pour la requête. Nous renvoyons ensuite un tuple contenant la chaîne SQL produite et ses paramètres.

Un exemple de transformateur

La recherche personnalisée ci-dessus va très bien, mais dans certains cas, il est souhaitable d’enchaîner des recherches les unes après les autres. Par exemple, supposons que nous construisons une application où nous souhaitons exploiter l’opérateur abs(). Nous avons un modèle Experiment qui enregistre une valeur de départ, une valeur de fin et la modification (début - fin). Nous aimerions trouver toutes les expériences où la modification est égale à une certaine valeur (Experiment.objects.filter(change__abs=27)), ou que la valeur ne dépasse pas un seuil limite (Experiment.objects.filter(change__abs__lt=27)).

Note

Cet exemple est un peu tiré par les cheveux, mais il démontre bien l’étendue des fonctionnalités possibles de manière indépendante de la base de données, et sans dupliquer une fonctionnalité déjà présente dans Django.

Nous allons commencer par écrire un transformateur AbsoluteValue. Il va faire appel à la fonction SQL ABS() pour transformer la valeur avant la comparaison :

from django.db.models import Transform

class AbsoluteValue(Transform):
    lookup_name = 'abs'
    function = 'ABS'

Ensuite, nous allons l’inscrire pour le champ IntegerField:

from django.db.models import IntegerField
IntegerField.register_lookup(AbsoluteValue)

Nous pouvons maintenant exécuter les requêtes que nous avions en tête. Experiment.objects.filter(change__abs=27) générera le code SQL suivant :

SELECT ... WHERE ABS("experiments"."change") = 27

En utilisant Transform au lieu de Lookup, cela signifie que nous avons la possibilité d’enchaîner d’autres recherches à la suite. Ainsi, Experiment.objects.filter(change__abs__lt=27) générera le code SQL suivant :

SELECT ... WHERE ABS("experiments"."change") < 27

Notez que dans le cas où aucune autre recherche n’a été ajoutée, Django interprète change__abs=27 comme change__abs__exact=27.

Cela permet aussi d’utiliser le résultat dans les clauses ORDER BY et DISTINCT ON. Par exemple, Experiment.objects.order_by('change__abs') produit :

SELECT ... ORDER BY ABS("experiments"."change") ASC

Et pour les bases de données qui prennent en charge l’instruction DISTINCT pour des champs spécifiques (comme PostgreSQL), Experiment.objects.distinct('change__abs') produit :

SELECT ... DISTINCT ON ABS("experiments"."change")

Lorsqu’il recherche quelles sont les expressions de recherche autorisées après l’application de Transform, Django se base sur l’attribut output_field. Nous n’avons pas eu besoin de le préciser ici car il n’a pas changé, mais si nous avions appliqué AbsoluteValue à un champ représentant un type plus complexe (par exemple un point relatif à une origine ou un nombre complexe), nous aurions pu vouloir indiquer que la transformation renvoie un type FloatField dans l’optique de recherches subséquentes. Cela peut se faire en ajoutant un attribut output_field à la transformation :

from django.db.models import FloatField, Transform

class AbsoluteValue(Transform):
    lookup_name = 'abs'
    function = 'ABS'

    @property
    def output_field(self):
        return FloatField()

Cela garantit que les recherches suivantes comme dans abs__lte se comportent comme elles le feraient avec un champ

Écriture d’une recherche abs__lt efficace

Lors de l’utilisation de l’expression abs écrite ci-dessus, le code SQL produit n’utilise pas les index de manière efficace dans certains cas. En particulier, quand on écrit change__abs__lt=27, c’est équivalent à change__gt=-27 AND change__lt=27 (dans le cas de lte, nous pourrions utiliser l’expression SQL BETWEEN).

Nous aimerions donc que Experiment.objects.filter(change__abs__lt=27) produise le code SQL suivant :

SELECT .. WHERE "experiments"."change" < 27 AND "experiments"."change" > -27

L’implémentation est :

from django.db.models import Lookup

class AbsoluteValueLessThan(Lookup):
    lookup_name = 'lt'

    def as_sql(self, compiler, connection):
        lhs, lhs_params = compiler.compile(self.lhs.lhs)
        rhs, rhs_params = self.process_rhs(compiler, connection)
        params = lhs_params + rhs_params + lhs_params + rhs_params
        return '%s < %s AND %s > -%s' % (lhs, rhs, lhs, rhs), params

AbsoluteValue.register_lookup(AbsoluteValueLessThan)

Plusieurs éléments méritent d’être signalés. Tout d’abord, AbsoluteValueLessThan n’appelle pas process_lhs(). Au lieu de cela, il omet la transformation de lhs effectuée par AbsoluteValue et utilise la variable lhs originale. C’est-à-dire que nous voulons obtenir "experiments"."change" et non ABS("experiments"."change"). La référence directe à self.lhs.lhs est sûre car AbsoluteValueLessThan ne peut être accédée qu’au travers de l’expression AbsoluteValue, ce qui veut dire que lhs est toujours une instance de AbsoluteValue.

Remarquez aussi que comme les deux côtés sont utilisés plusieurs fois dans la requête, les paramètres doivent contenir aussi plusieurs fois lhs_params et rhs_params.

La requête finale procède à l’inversion (27 to -27) directement dans la base de données. La raison de faire cela est que si self.rhs représente autre chose qu’une valeur entière simple (par exemple une référence F()), nous ne pouvons pas faire la transformation dans le code Python.

Note

En fait, la plupart des recherches avec __abs pourraient être implémentées comme des requêtes d’intervalle comme celle-ci, et pour la plupart des moteurs de base de données, il serait probablement plus judicieux de le faire afin de tirer parti au maximum des index. Cependant, avec PostgreSQL, il est possible d’ajouter un index sur abs(change) ce qui permettrait à ces requêtes d’être très efficaces.

Un exemple de transformateur bilatéral

L’exemple AbsoluteValue que nous avons abordé précédemment est une transformation qui s’applique au côté gauche de l’expression. Il peut arriver que l’on veuille appliquer la transformation à la fois aux côtés gauche et droit de l’expression. Par exemple, si vous souhaitez filtrer une requête sur la base de l’égalité des côtés gauche et droit après leur avoir appliqué une fonction SQL quelconque.

Examinons ici les transformations non sensibles à la casse. Cette transformation n’est pas très utile en réalité car Django contient déjà un certain nombre d’expressions de recherche non sensibles à la casse, mais cela fera une excellente démonstration des transformations bilatérales et neutres en terme de moteur de base de données.

Nous définissons une transformation UpperCase qui utilise la fonction SQL UPPER() pour transformer les valeurs avant leur comparaison. Nous définissons bilateral = True pour indiquer que cette transformation doit s’appliquer aux deux côtés lhs et rhs:

from django.db.models import Transform

class UpperCase(Transform):
    lookup_name = 'upper'
    function = 'UPPER'
    bilateral = True

Puis, occupons-nous de l’enregistrement :

from django.db.models import CharField, TextField
CharField.register_lookup(UpperCase)
TextField.register_lookup(UpperCase)

Dorénavant, la requête Author.objects.filter(name__upper="doe") va produire une expression non sensible à la casse comme ceci :

SELECT ... WHERE UPPER("author"."name") = UPPER('doe')

Écriture d’implémentations alternatives pour des recherches existantes

Il peut arriver que le code SQL doive être différent d’une base de données à l’autre pour la même opération. Pour cet exemple, nous allons réécrire une implémentation personnalisée pour MySQL de l’opérateur NotEqual. Au lieu de <>, nous utiliserons l’opérateur != (en réalité, presque toutes les bases de données acceptent les deux syntaxes, ce qui est le cas pour toutes les bases de données prises en charge officiellement par Django).

Nous pouvons modifier le comportement pour un moteur spécifique en créant une sous-classe de NotEqual avec une méthode as_mysql:

class MySQLNotEqual(NotEqual):
    def as_mysql(self, compiler, connection, **extra_context):
        lhs, lhs_params = self.process_lhs(compiler, connection)
        rhs, rhs_params = self.process_rhs(compiler, connection)
        params = lhs_params + rhs_params
        return '%s != %s' % (lhs, rhs), params

Field.register_lookup(MySQLNotEqual)

Nous pouvons ensuite l’inscrire dans Field. Elle prend la place de la classe NotEqual d’origine car elle possède le même attribut lookup_name.

Lors de la compilation d’une requête, Django cherche d’abord les méthodes as_%s % connection.vendor et se rabat ensuite sur as_sql. Les noms « vendor » pour les moteurs intégrés sont sqlite, postgresql, oracle et mysql.

Comment Django détermine les recherches et les transformations qui sont utilisées

Dans certains cas, il serait souhaitable de changer dynamiquement l’objet Transform ou Lookup renvoyé en fonction du nom passé, plutôt que de le corriger. Par exemple, un champ pourrait stocker les coordonnées ou une dimension arbitraire, et vous souhaiteriez autoriser une syntaxe telle que .filter(coords__x7=4) pour renvoyer les objets où la 7ème coordonnée a la valeur 4. Pour cela, vous pourriez étendre get_lookup avec quelque chose comme :

class CoordinatesField(Field):
    def get_lookup(self, lookup_name):
        if lookup_name.startswith('x'):
            try:
                dimension = int(lookup_name[1:])
            except ValueError:
                pass
            else:
                return get_coordinate_lookup(dimension)
        return super().get_lookup(lookup_name)

Vous pourriez ensuite définir get_coordinate_lookup de manière à ce qu’elle renvoie une sous-classe de Lookup gérant la valeur applicable de dimension.

Il existe une méthode similaire appelée get_transform(). get_lookup() doit toujours renvoyer une sous-classe de Lookup, et get_transform() une sous-classe de Transform. Il est important de se rappeler que les objets Transform peuvent ensuite être filtrés, alors que les objets Lookup ne le peuvent pas.

Lors du filtrage, s’il n’y a qu’un seul nom de recherche restant à résoudre, c’est un Lookup qui est recherché. S’il y a plusieurs noms, la recherche visera un Transform. Dans la situation où il n’y a qu’un seul nom et qu’un `` Lookup`` n’est pas trouvé, un Transform est recherché, puis l’expression de recherche exact est appliquée sur ce Transform. Toutes les séquences d’appels se terminent toujours par un Lookup. Pour clarifier :

  • .filter(myfield__mylookup) appelle myfield.get_lookup('mylookup').
  • .filter(myfield__mytransform__mylookup) appelle myfield.get_transform('mytransform'), puis mytransform.get_lookup('mylookup').
  • .filter(myfield__mytransform) appelle d’abord myfield.get_lookup('mytransform') qui échouera, il appelle donc en dernier recours myfield.get_transform('mytransform') puis mytransform.get_lookup('exact').
Back to Top