Παραμετροποιήσιμα Lookups

Το Django προσφέρει μια μεγάλη ποικιλία από προεγκατεστημένα lookups για το φιλτράρισμα (π.χ exact και icontains) των δεδομένων σας από την βάση δεδομένων. Το παρών εγχειρίδιο εξηγεί πως να γράψετε δικά σας lookups και πως να αλλάξετε τη συμπεριφορά των ήδη υπαρχουσών lookups. Για την σχετική αναφορά στο API των lookups, δείτε στο άρθρο Lookup API reference.

Ένα απλό lookup παράδειγμα

Ας ξεκινήσουμε με ένα απλό παραμετροποιήσιμο lookup. Θα γράψουμε ένα δικό μας lookup και θα του δώσουμε το όνομα ne (από τις λέξεις not equal), το οποίο θα έχει την ανάποδη συμπεριφορά του exact. Για παράδειγμα, αν γράψουμε Author.objects.filter(name__ne='Jack'), τότε αυτό θα μεταφραστεί στην SQL ως:

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

Η παραπάνω εντολή SQL είναι ανεξάρτητη από τη βάση δεδομένων που χρησιμοποιείτε, οπότε δεν θα χρειαστεί να ανησυχούμε γι” αυτό. Όλες οι βάσεις καταλαβαίνουν αυτή τη σύνταξη.

Υπάρχουν δύο βήματα που πρέπει να κάνουμε για να δουλέψει το ανωτέρω. Πρώτα, πρέπει να υλοποιήσουμε το lookup και μετά πρέπει να το αναφέρουμε στο 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

Σε αυτό το σημείο απλώς έχουμε ορίσει μια κλάση. Τίποτε περισσότερο. Επόμενο βήμα είναι κάνουμε register το νέο μας NotEqual lookup με κάποιο πεδίο από τα Django μοντέλα ή με όλα. Θα χρειαστεί να καλέσουμε την register_lookup πάνω στο πεδίο που θέλουμε αυτό το lookup να είναι διαθέσιμο. Αυτό γίνεται, γιατί σε μερικές περιπτώσεις δεν θέλουμε να έχουμε διαθέσιμο (να φιλτράρουμε, αν θέλετε) το lookup μας σε όλα τα πεδία (πχ IntegerField, DateField κλπ). Αν φτιάξουμε ένα άλλο lookup που να ελέγχει, πχ το πρώτο γράμμα ενός string δεν θα ήταν λογικό να το συνδέσουμε με ένα DateTimeField. Στην προκειμένη περίπτωση, το lookup μπορεί να εφαρμοστεί σε όλων των ειδών τα Field subclasses, οπότε θα το κάνουμε register με το Field απ” ευθείας:

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

Το registration του lookup μπορεί να γίνει και με τη χρήση ενός decorator ως εξής:

from django.db.models.fields import Field

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

Τώρα μπορούμε να χρησιμοποιήσουμε το foo__ne για κάθε τύπο πεδίου του foo. Θα χρειαστεί να βεβαιωθείτε ότι το registration του lookup, γίνεται πριν προσπαθήσετε να δημιουργήσετε querysets χρησιμοποιώντας το. Θα μπορούσατε να τοποθετήσετε τον παραπάνω κώδικα μέσα στο αρχείο models.py ή να κάνετε register το lookup μέσα στη μέθοδο ready() στο αντίστοιχο AppConfig της εφαρμογής σας.

Ρίχνοντας μια πιο προσεκτική ματιά στην υλοποίηση αυτού του lookup, το πρώτο απαραίτητο attribute που χρειάζεται είναι το lookup_name. Αυτό επιτρέπει στο Django ORM να καταλάβει πως να μεταφράσει το name__ne και να χρησιμοποιήσει την κλάση NotEqual για να παράξει την SQL. Συμβατικά, αυτά τα ονόματα είναι πάντα πεζοί χαρακτήρες (μόνο γράμματα) και η μοναδική αυστηρή απαίτηση είναι να μην περιέχουν δύο συνεχόμενες κάτω παύλες, __.

Έπειτα χρειάζεται να ορίσουμε την μέθοδο as_sql. Αυτή δέχεται ως όρισμα ένα SQLCompiler object, με το όνομα compiler και την ενεργή σύνδεση της βάσης δεδομένων με το όνομα connection. Τα SQLCompiler objects δεν βρίσκονται κάπου γραμμένα στο εγχειρίδιο του Django αλλά το μόνο πράγμα που χρειάζεται να ξέρετε γι” αυτά είναι ότι έχουν μια μέθοδο compile() η οποία επιστρέφει ένα tuple που περιέχει ένα SQL string και κάποιες παραμέτρους οι οποίες “γεμίζουν” αυτό το SQL string. Στις περισσότερες περιπτώσεις δεν θα χρειαστεί να την χρησιμοποιήσετε και μπορείτε να συνεχίσετε με τις μεθόδους process_lhs() και process_rhs().

Ένα Lookup συνεργάζεται με δύο τιμές, την lhs και την rhs, οι οποίες είναι σύντμηση των λέξεων left-hand side (αριστερό μέρος) και right-hand side (δεξί μέρος). Το αριστερό μέρος αναφέρεται συνήθως σε κάποιο πεδίο αλλά μπορεί να είναι οτιδήποτε αρκεί να έχει υλοποιήσει το API για εκφράσεις ερωτημάτων. Το δεξί μέρος είναι η τιμή η οποία δίνεται από τον χρήστη. Στο παράδειγμα Author.objects.filter(name__ne='Jack'), το αριστερό μέρος αναφέρεται στο πεδίο name του μοντέλου Author και το string 'Jack' αποτελεί το δεξί μέρος.

Καλούμε την process_lhs και την process_rhs για να μετατρέψουμε το Author.objects.filter(name__ne='Jack') στις τιμές που χρειαζόμαστε για την SQL χρησιμοποιώντας το compiler object που περιγράψαμε παραπάνω. Αυτές οι μέθοδοι επιστρέφουν tuples που περιέχουν λίγη SQL και κάποιες παραμέτρους που θα “γεμίσουν” αυτή την SQL. Ότι ακριβώς χρειαζόμαστε για να επιστρέψει η μέθοδος as_sql. Στο παραπάνω παράδειγμα, η process_lhs επιστρέφει ('"author"."name"', []) και η process_rhs επιστρέφει ('"%s"', ['Jack']). Όπως θα είδατε, δεν υπάρχουν παράμετροι για το αριστερό μέρος, αλλά αυτό εξαρτάται από την περίπτωση. Όπως και να έχει πάντως, θα πρέπει πάντα να περιλαμβάνονται στο τελικό tuple προς επιστροφή (η μεταβλητή params στην as_sql).

Στο τέλος ενώνουμε τα μέρη σε μια SQL εντολή με τη χρήση του <> και παρέχουμε όλες τις παραμέτρους για το query (ερώτημα). Αυτό που επιστρέφουμε είναι ένα tuple που περιέχει την παραγόμενη SQL εντολή και τις παραμέτρους, κενές ή όχι. Το tuple που θα επιστραφεί, αναφερόμενοι στην ανωτέρω as_sql, είναι το ('"author"."name" <> "%s"', ['Jack']).

Ένα απλό παράδειγμα transformer

Το παραπάνω lookup που φτιάξαμε είναι ωραίο, αλλά σε αρκετές περιπτώσεις θα θέλετε να έχετε τη δυνατότητα να ενώνετε τα lookups μεταξύ τους. Για παράδειγμα, ας υποθέσουμε ότι χτίζουμε μια εφαρμογή όπου θέλουμε να κάνουμε χρήση της μεθόδου abs(), που επιστρέφει το απόλυτο ενός αριθμού. Έχουμε ένα μοντέλο με το όνομα Experiment το οποίο καταγράφει μια τιμή έναρξης (start), μια τέλους (end) και την αλλαγή (αρχή – τέλος). Θα θέλαμε να βρούμε όλα τα πειράματα (experiments) όπου η αλλαγή ήταν ίση με μια συγκεκριμένη τιμή (Experiment.objects.filter(change__abs=27)) ή η αλλαγή ήταν μικρότερη ή ίση μιας συγκεκριμένης τιμής (Experiment.objects.filter(change__abs__lt=27)). Εδώ βλέπετε τη σύνδεση μεταξύ των lookups που αναφέραμε πριν (abs__lt).

Σημείωση

Αυτό το παράδειγμα είναι λίγο πολύ σκηνοθετημένο αλλά επιδεικνύει όμορφα τις λειτουργίες που είναι δυνατόν να υλοποιηθούν χωρίς την εξάρτηση πάνω σε μια συγκεκριμένη βάση δεδομένων και χωρίς να ξαναγράφουμε κώδικα που υπάρχει ήδη στο Django.

We will start by writing an AbsoluteValue transformer. This will use the SQL function ABS() to transform the value before comparison:

from django.db.models import Transform

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

Επόμενο βήμα είναι κάνουμε register την κλάση μας με το πεδίο IntegerField:

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

Μπορούμε τώρα να τρέξουμε τα queries στα οποία αναφερθήκαμε παραπάνω. Το Experiment.objects.filter(change__abs=27) θα παράξει την ακόλουθη SQL εντολή:

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

Χρησιμοποιώντας την Transform αντί της Lookup μας επιτρέπει να συνδέουμε περαιτέρω lookups στην αλυσίδα. Επομένως το Experiment.objects.filter(change__abs__lt=27) θα παράξει την ακόλουθη SQL εντολή:

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

Σημειώστε ότι στην περίπτωση που δεν υπάρχει άλλο lookup στο τέλος, το Django μεταφράζει το change__abs=27 ως change__abs__exact=27.

This also allows the result to be used in ORDER BY and DISTINCT ON clauses. For example Experiment.objects.order_by('change__abs') generates:

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

And on databases that support distinct on fields (such as PostgreSQL), Experiment.objects.distinct('change__abs') generates:

SELECT ... DISTINCT ON ABS("experiments"."change")
Changed in Django 2.1:

Ordering and distinct support as described in the last two paragraphs was added.

Για να βρει το Django τον τύπο των lookups που επιτρέπονται μετά το δικό μας (AbsoluteValue), κοιτάζει το attribute output_field το οποίο δεν χρειάστηκε να το ορίσουμε στο συγκεκριμένο παράδειγμα γιατί έτυχε ο αριθμός που συγκρίνουμε, στο δεξί μέρος, να είναι ακέραιος (27). Αν μπερδευτήκατε, ας το ξεκαθαρίσουμε: Η Transform προσφέρει τη μετατροπή ενός πεδίου (πχ του change) σε κάποια άλλη τιμή (πχ από -27 που μπορεί να ήταν, σε 27) προτού συγκριθεί με το δεξί μέρος και γίνει το lookup στη βάση δεδομένων. Ο τύπος της νέας αυτής τιμής (πχ 27 αντί -27) δηλώνεται στο output_field του δικού μας AbsoluteValue ``. Δεν το δηλώσαμε γιατί και οι δύο τιμές είναι τύπου int. Αν υποθέσουμε όμως ότι έχουμε πιο περίπλοκα πεδία (πχ καρτεσιανές συντεταγμένες ή μιγαδικούς αριθμούς) τότε ίσως θα θέλαμε να προσδιορίσουμε τον τύπο της νέας τιμής ως ``FloatField για περαιτέρω lookups. Αυτό μπορεί να γίνει θέτοντας το attribute `` output_field`` ως property της δικής μας κλάσης, ως εξής:

from django.db.models import FloatField, Transform

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

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

Με αυτό τον τρόπο είμαστε σίγουροι ότι το δικό μας transformation θα επιστρέψει μια τιμή τύπου float και τα επόμενα lookups (αν υπάρχουν, πχ το lte στο abs__lte) θα “δουν” την τιμή αυτή ως float και όχι ως int όπως ήταν στην αρχή. Ένα άλλο πολύ κοινό παράδειγμα της Transform είναι η μετατροπή ενός DateField στην τιμή του έτους μόνο. Για παράδειγμα, θα θέλατε να δείτε ποια πειράματα έγιναν το έτος 2016. Θα θέλατε να το γράψετε ως εξής: Experiment.objects.filter(date__year=2016). Το date είναι το πεδίο του μοντέλου μας, τύπου DateField και το year το lookup (που υπάρχει ήδη μέσα στο Django). Το year lookup “ξηλώνει” μόνο το έτος από την ημερομηνία και το συγκρίνει με το 2016. Έτσι, μετατρέπεται η ημερομηνία (DateField) σε έτος (IntegerField) και συγκρίνεται με το δεξί μέρος.

Γράφοντας ένα πιο αποτελεσματικό abs__lt lookup

Όταν χρησιμοποιούμε το παραπάνω abs lookup, η παραγόμενη SQL, σε μερικές περιπτώσεις, δεν χρησιμοποιεί αποτελεσματικά τα ευρετήρια (indexes) της βάσης δεδομένων. Πιο συγκεκριμένα, όταν χρησιμοποιούμε το change__abs__lt=27, αυτό είναι ισοδύναμο με το change__gt=-27 AND change__lt=27. (Στη περίπτωση του lte θα μπορούσαμε να χρησιμοποιήσουμε την εντολή SQL BETWEEN).

Θα θέλαμε, επομένως, η παραγόμενη SQL από το Experiment.objects.filter(change__abs__lt=27) να δείχνει κάπως έτσι:

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

Η υλοποίηση αυτού φαίνεται παρακάτω:

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)

Υπάρχουν μερικά αξιοσημείωτα πράγματα εδώ. Πρώτα απ’ όλα το AbsoluteValueLessThan δεν καλεί τη μέθοδο process_lhs(). Αντιθέτως, υπερπηδά τη μετατροπή του lhs που γίνεται από την AbsoluteValue και χρησιμοποιεί την αρχική τιμή του lhs` (το self.lhs είναι η τιμή μετά την μετατροπή ενώ το self.lhs.lhs είναι η αρχική τιμή του change). Αυτό γίνεται γιατί θέλουμε να έχουμε το "experiments"."change" και όχι το ABS("experiments"."change"). Είναι ασφαλής η αναφορά μας απ’ ευθείας στο self.lhs.lhs γιατί η Lookup κλάση AbsoluteValueLessThan μπορεί να προσπελασθεί μόνο από την Transform κλάση AbsoluteValue (λόγω του register στην τελευταία γραμμή), δηλαδή το lhs θα είναι πάντα ένα instance της Transform κλάσης AbsoluteValue.

Επισημαίνεται επίσης το γεγονός ότι όχι μόνο και τα δύο μέρη (αριστερό και δεξί) χρησιμοποιούνται πολλές φορές μέσα στο SQL query αλλά και οι παράμετροι επίσης (lhs_params και rhs_params).

Ο λόγος που γίνεται αυτό είναι γιατί, σε περίπτωση που το self.rhs είναι κάτι διαφορετικό από έναν ακέραιο (πχ μια αναφορά σε ένα F()) δε θα μπορούμε να κάνουμε τις μετατροπές στην Python.

Σημείωση

Στην πραγματικότητα, τα περισσότερα lookups που εμπεριέχουν το __abs θα μπορούσαν να υλοποιηθούν με την παραπάνω μέθοδο και στις περισσότερες βάσεις δεδομένων θα μπορέσετε να εκμεταλλευτείτε τη δυνατότητα των ευρετηρίων (indexes) τους. Ωστόσο, στην PostgreSQL ίσως θα θέλατε να προσθέσετε ένα ευρετήριο για το abs(change) το οποίο θα επιτρέψει σε αυτά τα queries να είναι πολύ αποτελεσματικά.

Ένα παράδειγμα ενός διμερούς transformer

Το παράδειγμα της Transformer κλάσης AbsoluteValue που περιγράψαμε παραπάνω είναι μια μετατροπή η οποία εφαρμόζεται στο αριστερό μέρος του lookup (στο πεδίο change). Ίσως να υπάρξουν, μερικές περιπτώσεις όπου θα θέλατε η μετατροπή να εφαρμοστεί και στο αριστερό και στο δεξί μέρος. Για παράδειγμα, αν θέλετε να φιλτράρετε ένα queryset βασιζόμενο στην ισότητα των δύο μερών (αριστερό και δεξί) με την χρήση κάποιας SQL συνάρτησης.

Ας εξετάσουμε την απλή περίπτωση μιας μετατροπής που δεν κάνει διάκριση μεταξύ πεζών και κεφαλαίων γραμμάτων. Αυτή η μετατροπή δεν είναι πολύ χρήσιμη στην πράξη καθώς το Django έχει προεγκατεστημένα αρκετά lookups που δεν κάνουν διακρίσεις μεταξύ πεζών-κεφαλαίων. Παρόλ’ αυτά αποτελεί ένα χρήσιμο παράδειγμα για την επίδειξη των διμερών μετατροπών (bilateral transformations) ανεξαρτήτως της βάση δεδομένων που χρησιμοποιείται.

Ορίζουμε έναν transformer UpperCase ο οποίος χρησιμοποιεί την SQL συνάρτηση UPPER() για να μετατρέψει τις τιμές πριν την σύγκριση. Θέτουμε το attribute bilateral = True για να υποδείξουμε ότι η μετατροπή θα πρέπει να γίνει όχι μόνο στο αριστερό μέρος, lhs, αλλά και στο δεξί, rhs:

from django.db.models import Transform

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

Έπειτα το κάνουμε register με τους ανάλογους τύπους πεδίων:

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

Τώρα το queryset Author.objects.filter(name__upper="doe") θα παράξει ένα query το οποίο δεν θα κάνει διάκριση μεταξύ πεζών-κεφαλαίων γραμμάτων:

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

Γράφοντας εναλλακτικές υλοποιήσεις για ήδη υπάρχοντα lookups

Κάποιες φορές, μερικές βάσεις δεδομένων απαιτούν έναν διαφορετικό τρόπο γραφής της SQL για την ίδια λειτουργία. Για παράδειγμα, κάποιες βάσεις για να προσδιορίσουν τη λογική «όχι ίσο με», αντί του συμβόλου <> απαιτούν το !=. (Στην πραγματικότητα σχεδόν όλες οι βάσεις δεδομένων υποστηρίζουν και τα δύο αυτά σύμβολα, συμπεριλαμβανομένων και όλων των επίσημων βάσεων δεδομένων που υποστηρίζει το Django). Σε αυτό το παράδειγμα θα ξαναγράψουμε μια δικιά μας υλοποίηση, για την MySQL, της λειτουργίας NotEqual.

We can change the behavior on a specific backend by creating a subclass of NotEqual with an as_mysql method:

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)

Μπορούμε, έπειτα, να κάνουμε register την κλάση αυτή με το Field. Η κλάση MySQLNotEqual αντικαθιστά την κλάση NotEqual, μόνο για τις MySQL βάσεις δεδομένων, αφού μοιράζονται το ίδιο lookup_name.

Κατά τη διάρκεια μετάφρασης του query, το Django ψάχνει για τις μεθόδους``as_%s % connection.vendor`` και αν δεν βρει κάποια τότε χρησιμοποιεί την as_sql. Τα ονόματα των vendor για τις προεγκατεστημένες στο Django database backends είναι τα sqlite, postgresql, oracle και mysql.

Πως προσδιορίζει το Django τα lookups και τα transforms που χρησιμοποιούνται

Σε μερικές περιπτώσεις θα θέλατε να αλλάξετε, δυναμικά, το Transform ή το Lookup που επιστρέφεται ανάλογα το όνομα που δίνεται στο query παρά να μπείτε στη διαδικασία να φτιάξετε ένα για το καθένα. Θα μπορούσατε, για παράδειγμα, να έχετε ένα πεδίο το οποίο αποθηκεύει συντεταγμένες ή κάποια αυθαίρετη διάσταση και θέλετε να μπορείτε να γράφετε το .filter(coords__x7=4) και να σας επιστρέφει τα objects όπου η 7η συντεταγμένη έχει την τιμή 4. Για να το κάνετε αυτό, θα πρέπει να παρακάμψετε (override) την get_lookup με κάτι σαν αυτό:

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)

Μετά θα ορίσετε τη μέθοδο get_coordinate_lookup, καταλλήλως, για να επιστρέφει μια subclass της Lookup η οποία θα χειριστεί την σχετική τιμή της μεταβλητής dimension.

Ομοίως, υπάρχει μια μέθοδος για την Transform με το όνομα get_transform(). Η μέθοδος get_lookup() θα πρέπει πάντα να επιστρέφει μια subclass της Lookup ενώ η get_transform() μια subclass της Transform. Είναι σημαντικό να θυμάστε ότι τα objects της κλάσης Transform μπορούν να φιλτραριστούν περαιτέρω ενώ τα objects της κλάσης Lookup όχι.

Όταν φιλτράρετε και έχει απομείνει μόνο ένα lookup όνομα, το Django ψάξει για κάποιο Lookup. Αν υπάρχουν πολλά τότε θα ψάξει για κάποιο Transform. Στην περίπτωση που υπάρχει μόνο ένα όνομα και δεν βρεθεί κάποιο Lookup, τότε το Django θα ψάξει για ένα Transform και μετά το exact lookup πάνω σε αυτό το Transform. Στο τέλος, το Django, πάντα θα ψάχνει για ένα Lookup. Για να ξεκαθαρίσουμε λίγο το τοπίο:

  • Το .filter(myfield__mylookup) θα καλέσει την μέθοδο myfield.get_lookup('mylookup').
  • Το .filter(myfield__mytransform__mylookup) θα καλέσει τη μέθοδο myfield.get_transform('mytransform') και μετά την mytransform.get_lookup('mylookup').
  • Το .filter(myfield__mytransform) θα καλέσει πρώτα τη μέθοδο myfield.get_lookup('mytransform'), η οποία θα αποτύχει. Έπειτα θα καλέσει την μέθοδο myfield.get_transform('mytransform') και τέλος την mytransform.get_lookup('exact').
Back to Top