Come scrivere lookup personalizzati

Django offre una ampia varietà di built-in lookups per la selezione (per esempio, exact e icontains). Questo documento spiega come scrivere criteri di ricerca personalizzati e come modificare il comportamento di quelli esistenti. Per i riferimenti alle API dei criteri di ricerca, vedi Lookup API reference.

Un esempio di criterio di ricerca

Iniziamo con un piccolo lookup custom. Scriveremo un lookup custom ne che lavora in modo opposto ad exact. Author.objects.filter(name__ne='Jack') si tradurrà nell’SQL:

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

Questo comando SQL è indipendente dal backend, non ci dobbiamo quindi preoccupare di differenti basi di dati.

Sono richiesti due passaggi per farlo funzionare. Primo devi implementare il criterio di ricerca, poi occorre dichiaralo in Django.

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

Per registrare il lookup NotEqual dovremo chiamare register_lookup sul campo della classe in cui vogliamo che il lookup sia disponibile. In questo caso, il lookup ha senso su tutte le sottoclassi Field, così possiamo registrarlo direttamente con Field:

from django.db.models import Field

Field.register_lookup(NotEqual)

La registrazione di un criterio di ricerca si puo” fare anche usando un modello decoratore:

from django.db.models import Field


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

Adesso possiamo usare foo__ne per ogni campo foo. Ti devi assicurare che questa registrazione avvenga prima che cerchi di creare un qualunque queryset che la usi. Puoi mettere l’implementazione in un file models.py , o registrare il criterio di ricerca nel metodo ready() di un AppConfig.

Dando un’occhiata più attenta all’implementazione, il primo attributo richiesto é lookup_name. Questo permette alla ORM di capire come interpretare name__ne e usare NotEqual per generare il codice SQL. Per convenzione, questi nomi sono sempre stringhe in minuscolo contenenti solo lettere, ma l’unico requisito mandatorio è che non contengano la stringa __.

Poi dobbiamo definire il metodo as_sql . Questo usa un oggetto SQLCompiler , chiamato compiler, e la connessione al database attivo. Gli oggetti SQLCompiler non sono documentati, ma la sola cosa che dobbiamo sapere a loro riguardo è che hanno un metodo compile() che restituisce una tupla contenente una stringa SQL, e i parametri da interpolare in quella stringa. Nella maggior parte dei casi, non hai necessità di usarli direttamente e puoi passarli a process_lhs() e process_rhs().

Un Lookup lavora verso due valori, lhs e rhs, che rappresentano la parte sinistra e la parte destra. La parte sinistra di solito è un riferimento a un campo, ma può essere qualunque cosa implementi una query expression API. La parte destra è il valore dato dall’utente. Nell’esempio Author.objects.filter(name__ne='Jack'), la parte sinistra è un riferimento al campo name del modello Author , e 'Jack' è la parte destra.

Chiamiamo process_lhs e process_rhs per convertirli nei valori che ci servono per il codice SQL usando l’oggetto compiler descritto in precedenza. Questi metodi ritornano tuple contenenti il codice SQL e i parametri che devono essere interpolati in quel codice SQL, esattamente come si devono essere restituiti dal nostro metodo as_sql . Nell’esempio precedente, process_lhs restituisce ('"author"."name"', []) e process_rhs restituisce ('"%s"', ['Jack']). In questo esempio non c’erano parametri per la parte sinistra, ma può dipendere dall’oggetto che abbiamo, per questo dobbiamo includerli nei parametri che restituiamo.

Finalmente combiniamo le parti in una espressione SQL con <>, e riforniamo? con tutti i parametri per l’interrogazione. Dopodiché ritorniamo una tupla contenente la stringa SQL e i parametri generati.

Un esempio di trasformatore

La ricerca personalizzata sopra indicata è fantastica, ma in alcuni casi potresti aver bisogno di concatenare le ricerche insieme. Per esempio, supponiamo che stiamo costruendo la nostra applicazione laddove vogliamo fare uso del operatore abs(). Abbiamo un modello Experiment che registra un valore inizio, un valore fine, e che un valore cambio ( inizio - fine ). Ci piacerebe cercare tutti gli esperimenti dove valore cambio è uguale ad alcuni valori (Experiment.objects.filter(change__abs=27)),o qualsiasi vaolore che non supera un certo quantitativo (Experiment.objects.filter(change__abs__lt=27)).

Nota

Questo esempio è in qualche modo ?forzato?, ma ci mostra il range di funzionalita possibili nel database backend in maniera indipendete, e senza duplicare funzionalita gia esistenti in Django

Cominceremo con la scrittura del convertitore``AbsoluteValue``. Utilizzeremo le funzioni SQL ABS() per convertire il valore prima del confronto:

from django.db.models import Transform


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

Successivamente registriamolo per il campo IntegerField:

from django.db.models import IntegerField

IntegerField.register_lookup(AbsoluteValue)

Adesso possiamo lanciare le query che avevamo prima. Experiment.objects.filter(change__abs=27) genererà il seguente SQL:

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

Usare Transform invece di Lookup significa che saremo in grado di concatenare ulteriori lookup in seguito. Così Experiment.objects.filter(change__abs__lt=27) genererà il seguente SQL:

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

Nel caso non ci siano altre ricerche specificate, Django interpreta change__abs=27  come change__abs__exact=27.

Questo consente anche al risultato di essere usato in espressioni ORDER BY e DISTINCT ON. Per esempio Experiment.objects.order_by('change__abs') produce:

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

E nei database che supportano distinct sui campi (come PostgreSQL), Experiment.objects.distinct('change__abs')  produce:

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

Quando cerca quali lookup sono disponibili dopo avere applicato la Transform, Django usa l’attributo output_field. Se l’attributo non è cambiato, non abbiamo necessità di specificarlo, ma supponiamo che stavamo applicando la AbsoluteValue ad un campo che rappresenta un tipo più complesso (per esempio un punto relativo ad una origine, o un numero complesso), allora per ulteriori lookup avremmo voluto specificare il fatto che la trasformazione ritorna un tipo FloatField. Ciò può essere fatto aggiungendo un attributo output_field alla trasformazione:

from django.db.models import FloatField, Transform


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

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

Questo assicura che future ricerce come abs__lte si comportino come per FloatField.

Scrivere una abs__lt ricerca efficiente

Usando il lookup abs di cui sopra, l’SQL che viene prodotto in qualche caso non utilizzerà gli indici prodotti efficientemente. In particolare, l’utilizzo di change__abs__lt=27 è equivalente all’utilizzo di change__gt=-27 AND change__lt=27. (Per il caso lte avremmo potuto usare il BETWEEN di SQL).

Ci piacerebbe che Experiment.objects.filter(change__abs__lt=27) generi la seguente SQL:

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

L’implemetazione è:

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)

Ci sono un paio di cose che accadono e che vale la pena di notare. La prima è che AbsoluteValueLessThan non sta chiamando process_lhs(). Piuttosto, salta la trasformazione di lhs fatta da AbsoluteValue e utilizza l” lhs originale. Cioè, vogliamo ottenere "experiments"."change" e non ABS("experiments"."change"). Fare riferimento direttamente a self.lhs.lhs è sicuro perchè AbsoluteValueLessThan può essere acceduto solo dal lookup AbsoluteValue, quindi lhs è sempre una istanza di AbsoluteValue.

Nota anche che poichè entrambe le parti sono utilizzate più volte nella query, i parametri devono contenere lhs_params e rhs_params più volte.

La query finale fà l’inversione (27 a -27) direttamente nel database. La ragione per cui fà così è che se self.rhs` non è un semplice valore intero (per esempio una ``F() reference) non possiamo fare la trasformazione in Python.

Nota

In effetti, molti lookup con __abs potrebbero essere implementati con delle query range come questa e su molti database di backend sarebbe anche più ragionevole, perchè potrebbero usare gli indici. In ogni caso con PostgreSQL potresti voler aggiungere un indice su abs(change) che permetterebbe a queste query di essere molto efficienti.

Un esempio di trasformatore bilaterale

L’esempio AbsoluteValue che abbiamo discusso in precedenza è una trasformazione che si applica al lato sinistro della ricerca. Potrebbero esserci alcuni casi in cui si desidera applicare la trasformazione sia al lato sinistro che al lato destro. Ad esempio, se si desidera filtrare un set di query in base all’uguaglianza del lato sinistro e destro in modo insensibile ad alcune funzioni SQL.

Esaminiamo qui le trasformazioni senza distinzione tra maiuscole e minuscole. Questa trasformazione non è molto utile in pratica poiché Django viene già fornito con una serie di ricerche integrate senza distinzione tra maiuscole e minuscole, ma sarà una bella dimostrazione di trasformazioni bilaterali in modo indipendente dal database.

Definiamo un trasformatore Maiuscolo che utilizza la funzione SQL UPPER() per trasformare i valori prima del confronto. Definiamo bilateral = True per indicare che questa trasformazione dovrebbe applicarsi sia a lhs che a rhs:

from django.db.models import Transform


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

Successivamente registriamolo:

from django.db.models import CharField, TextField

CharField.register_lookup(UpperCase)
TextField.register_lookup(UpperCase)

Ora, il queryset Author.objects.filter(name__upper="doe") genererà una query senza distinzione tra maiuscole e minuscole come questa:

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

Scrivendo implementazioni alternative per le ricerche esistenti

A volte database vendor differenti richiedono SQL diverso per le stesse operazioni. Per questo esempio, riscriveremo una implementazione custom dell’operatore NotEqual per MySQL. Invece di <> useremo l’operatore !=. (Nota che in realtà praticamente tutti i database li supportano entrambi, inclusi quelli supportati da Django).

Puoi cambiare il comportamento di uno specifico backend creando una sottoclasse di «NotEqual» con un metodo «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)

Poi possiamo registrarlo con Field. Prende il posto della classe NotEqual originale dal momento che ha lo stesso lookup_name.

Quando compila una query, Django prima cerca i metodi as_%s % connection.vendor e poi ricade su as_sql. I nomi dei vendor per i backends built-in sono sqlite, postgresql, oracle e mysql.

Come Django determina le ricerche e trasforma quelle in uso

In alcuni casi potresti voler cambiare dinamicamente quale Transform o Lookup viene restituito a seconda del nome che gli passi, piuttosto che metterlo a posto. Per esempio, potresti avere un campo che contiene coordinate di una dimensione arbitraria e vorresti poter consentire una sintassi di tipo .filter(coords__x7=4) per restituire gli oggetti in cui la settima coordinata vale 4. Per fare questo, faresti override di get_lookup con qualcosa come:

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

Poi definiresti in modo appropriato get_coordinate_lookup per restituire una sottoclasse di Lookup che gestisce la dimensione rilevante di dimension.

Esiste un metodo che è chiamato get_transform(), in modo simile. get_lookup() dovrebbe sempre restituire una sottoclasse Lookup e get_transform() una sottoclasse Transform. E” importante ricordare che gli oggetti Transform possono essere ulteriormente filtrati, cosa che non possibile fare per gli oggetti Lookup.

In fase di filtering, se rimane un solo lookup name da risolvere, cercheremo un Lookup. Se ci sono più nomi, si cercherà per un Transform. Nella situazione in cui ci sia solo un nome e non si trovi un Lookup, cercheremo un Transform e poi il lookup exact su quel Transform. Tutte le sequenze di chiamate terminano sempre con un Lookup. Per fare chiarezza:

  • .filter(myfield__mylookup) chiamera myfield.get_lookup('mylookup').
  • .filter(myfield__mytransform__mylookup) chimaerà myfield.get_transform('mytransform'), e poi mytransform.get_lookup('mylookup').
  • .filter(myfield__mytransform) chiamerà prima myfield.get_lookup('mytransform'), che fallirà, così farà fallback su myfield.get_transform('mytransform') e poi mytransform.get_lookup('exact').
Back to Top