momente şi schiţe de informatică şi matematică
To attain knowledge, write. To attain wisdom, rewrite.

Prevenirea duplicării câmpurilor de tip TEXT

Django | MD5 | MySQL | Python
2014 sep

Următoarea imagine avansează aplicația slightChess demarată în [1-3]:

Pentru fiecare Coach am înregistrat partide jucate de el cu diverși parteneri; utilizatorul alege un Coach, un Partner și o partidă, putând urmări desfășurarea acesteia; dacă este și autorizat (prin link-ul 'Login'), atunci poate să elimine partida respectivă, sau să-i marcheze o anumită poziție și eventual, să adauge un comentariu distinct asupra partidei.

Precizăm încă o dată că este vorba aici de partide de șah "rapid" practicate pe Internet de către amatori; dar în prealabil înregistrării în contextul aplicației slightChess, colecția respectivă a fost supusă analizei automate a unui program de șah "profesionist" (deocamdată am folosit Crafty) - încât derularea partidelor astfel adnotate capătă valențe instructive.

Tot experimentând pe parcursul dezvoltării aplicației, am ajuns într-o situație neprevăzută: am înregistrat în baza de date - în tabelul MySQL games_game - o aceeași partidă, de mai multe ori. Problema constă nu în eliminarea duplicatelor, ci în prevenirea apariției acestora.

Pentru games_partner am putut evita foarte simplu, apariția duplicatelor (v. [1]): am folosit unique pentru câmpul "nume" (care este de tip varchar(80)) și apoi am inserat noii parteneri prin INSERT IGNORE (dacă partenerul respectiv deja există în tabel, atunci MySQL va ignora inserarea acestuia). Dar imediat, nu putem proceda la fel pentru cazul tabelului games_game: câmpul pgn instituit pentru înregistrarea textului partidei este de tip MySQL text și nu mai poate fi o "cheie unică".

Cauți și eventual ajungi să întrevezi o idee de soluționare plecând de la cunoștințele proprii…
Știind câte ceva despre construcția programelor de șah, primul lucru evocat în ajutor în cazul de față este… codificarea pozițiilor de șah - analoge acum, câmpurilor de tip "text" (dar alte analogii avea la îndemână un cunoscător de criptografie); anume, poziția este transformată într-o valoare numerică unică (diferită de la poziție la poziție), printr-un algoritm creat de Zobrist în 1970 (… cu 20 de ani înainte de formularea metodei generale "message-digest", MD5).

Folosind MD5, putem transforma conținutul câmpului pgn de tip text într-un index de 32 octeți, pe care îl vom păstra pe un nou câmp, pgn_hash de tip varchar(32), declarat ca unique. În acest scop, redefinim parțial modelul Game din [1] astfel:

from hashlib import md5    # message digest algorithms
class Game(models.Model):
    pgn = models.TextField()
    partner = models.ForeignKey(Partner, on_delete=models.CASCADE)
    coach = models.ForeignKey(settings.AUTH_USER_MODEL, 
                              on_delete=models.CASCADE)
    comment = models.CharField(max_length=3000, default="", blank=True)
    mark_pos = models.PositiveSmallIntegerField(default=0)
    pgn_hash = models.CharField(max_length=32, unique=True) 

    def save(self, *args, **kwargs):
        self.pgn_hash = md5(self.pgn.encode('utf-8')).hexdigest()
        super(Game, self).save(*args, **kwargs)

Față de [1], am adăugat trei câmpuri și am redefinit metoda save() - calculând și constituind câmpul pgn_hash înainte de a permite înregistrarea efectivă a obiectului curent Game.

Această modificare nu poate fi operată în modul obișnuit (v. [1]): opțiunea syncdb a utilitarului Django manage.py creează tabele noi, în baza de date - ori games-game deja există (și nu ne convine să-l ștergem și să-l re-constituim, având deja aproape o mie de înregistrări).

Rămâne să operăm direct în MySQL, folosind comanda ALTER TABLE; dar pentru aceasta trebuie să știm cum anume se face asocierea între obiectele Python Field și tipurile de câmpuri MySQL - de exemplu, consideram intuitiv că obiectele TextField() sunt mapate pe câmpuri MySQL text… Pachetul Django prevede această asociere în fișierul django/db/backends/mysql/creation.py și putem să o vizualizăm independent prin următorul script (v. [1], pentru scripturi "helper"):

import os    # helper3.py
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'slightchess.settings')

from django.db.backends.mysql.creation import DatabaseCreation as dbc
for key, value in dbc.data_types.items():
    print key, ': ', value
vb@vb:~/slightchess$ python helper3.py 
DateField :  date
SmallIntegerField :  smallint
TextField :  longtext
CharField :  varchar(%(max_length)s)
IntegerField :  integer
...

În general, am intuit destul de corect: IntegerField() este transformat în integer, etc.; dar TextField() nu corespunde cu text, ci cu tipul MySQL longtext - iar diferența este importantă: pentru câmpuri longtext MySQL-5.5 prevede câte o "zonă de memorie" de 4GB (ceea ce în cazul nostru este ridicol de mult), necesitând pentru adresare pointeri "lungi". Însă în Django avem voie să adaptăm aceste asocieri generice la propriile necesități (nu trebuie să ne bazăm pe utilitarul manage.py syncdb):

vb@vb:~/slightchess$ mysql slightchess
mysql> alter table games_game modify column pgn text not null;

Prin comanda redată mai sus, am redefinit câmpul pgn drept câmp de tip text - pentru care MySQL rezervă maximum 64KB (câștigând și ca viteză de adresare, prin "scurtarea" pointerilor).

Următoarele patru comenzi vizează câmpul pgn_hash: îl adăugăm în tabel, prevăzându-l inițial cu valori NULL (adică, "fără valoare") - altfel, comanda ar eșua (datorită clauzei "unique": dacă am fi prevăzut obișnuitul "NOT NULL", atunci înregistrarea a doua ar fi avut aceeași valoare "pgn_hash" ca și prima - anume, valoarea "null"):

mysql> alter table games_game add column pgn_hash varchar(32) null unique;

Apoi, setăm valorile câmpurilor pgn_hash, pentru înregistrările existente - folosind funcția md5() din MySQL (aceasta returnează aceeași valoare ca și implementarea hashlib.md5() din Python, pentru un același argument); folosim desigur, UPDATE IGNORE - încât pentru o înregistrare care ar avea aceeași valoare pgn ca și una anterior prelucrată, câmpul pgn_hash va rămâne "NULL":

mysql> update ignore games_game set pgn_hash = md5(pgn);

Se subânțelege că putem folosi SELECT pgn, pgn_hash FROM games_game LIMIT 2 de exemplu, pentru a și vizualiza rezultatele comenzii… Acum, înregistrările rămase fără valoare pe câmpul pgn_hash reprezintă duplicate ale unor partide existente; să le ștergem:

mysql> delete from games_game where pgn_hash is null;

În sfârșit, putem redefini câmpul pgn_hash pentru a forța MySQL să verifice ca fiecare nouă înregistrare să aibă setat și acest câmp (este "not null"):

mysql> alter table games_game modify column pgn_hash varchar(32) not null unique;

În [3] am prevăzut scriptul st_games.py pentru a prelua fișierele *.can produse de Crafty și a înscrie partidele respective în baza de date; mai trebuie să modificăm acolo operația de inserare, pentru a ține seama de faptul că acum avem "cheia unică" pgn_hash, care trebuie și calculată:

cursor.execute(
'INSERT IGNORE INTO games_game SET coach_id="%s", pgn="%s", partner_id="%s", pgn_hash="%s"' 
     %(selector_id, re.escape(can), part_id, md5(can.encode('utf-8')).hexdigest()))

Scăpăm astfel de grija de a nu duplica partidele, în baza de date. Teoretic se poate întâmpla și să "pierdem" partide, în sensul că deși pgn-urile sunt distincte, MD5 ne dă un același pgn_hash (încât numai primul dintre aceste pgn-uri distincte va fi înregistrat, dat fiind că pgn_hash este "cheie unică" și am inserat cu IGNORE) - dar știm că probabilitatea acestei situații de "coliziune" este foarte mică.

vezi Cărţile mele (de programare)

docerpro | Prev | Next