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 simple d’expression de recherche¶
Commençons par une expression de recherche simple. 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. Nuos devons d’abord implémenter la recherche, puis nous devons informer Django de sa disponibilité. L’implémentation est très simple :
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 suffit d’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 simple 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")
La prise en charge pour les tris et la méthode distinct
telle qu’exposée dans les deux derniers paragraphes a été ajoutée.
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 l’exemple simple d’une transformation non sensible à 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):
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)
appellemyfield.get_lookup('mylookup')
..filter(myfield__mytransform__mylookup)
appellemyfield.get_transform('mytransform')
, puismytransform.get_lookup('mylookup')
..filter(myfield__mytransform)
appelle d’abordmyfield.get_lookup('mytransform')
qui échouera, il appelle donc en dernier recoursmyfield.get_transform('mytransform')
puismytransform.get_lookup('exact')
.