Etiquetar és d'aquelles funcionalitats que semblen trivials fins que cal posar-les al quart model. Llavors et trobes amb taules gairebé idèntiques, el mateix codi repetit en diversos llocs i una petició de producte que no pots resoldre sense migrar mitja base de dades. El que segueix és com muntar-ho una sola vegada per a qualsevol model, amb les decisions que eviten que la neteja i els permisos se't compliquin després. Els exemples van en Laravel, una de les nostres especialitats, i comparem obertament amb spatie/laravel-tags, que és el que fa servir gairebé tothom, però el model de dades és portable a qualsevol stack.
Una taula d'etiquetes per model és el camí que no escala
Comença sempre igual. Necessites etiquetar documents, així que crees document_tags i document_tag_document. Funciona.
Un mes després cal etiquetar clients. I després expedients, i després clàusules. Cadascun amb la seva taula d'etiquetes i la seva taula pivot, amb la mateixa lògica de crear si no existeix, el mateix generador d'identificadors, el mateix selector a la interfície duplicat quatre vegades.
I arriba el dia que algú demana filtrar per una etiqueta que existeix en dos llocs, i descobreixes que "urgent" en documents i "urgent" en expedients són dues files de dues taules diferents que no es poden creuar.
Dues taules, i la taxonomia és una columna de text
L'estructura que resol això són exactament dues taules, i cap no creix quan hi afegeixes un model nou.
terms guarda els termes: el seu nom, el seu identificador estable, a quina taxonomia pertanyen, el seu pare si hi ha jerarquia i el seu ordre.
termables és la pivot polimòrfica: quin terme està adjuntat a quin model, sigui del tipus que sigui.
I la decisió que més simplifica el dia a dia: la taxonomia no té taula. És una cadena de text en una columna, i la seva definició viu a la configuració de l'aplicació:
'taxonomies' => [
'case_tags' => ['models' => [CaseModel::class], 'max_terms_per_model' => 8],
'clause_tags'=> ['models' => [Clause::class]],
'thread_categories' => ['models' => [Thread::class], 'hierarchical' => true],
],
Afegir una classificació nova és una entrada en un array. Zero migracions, zero desplegaments d'esquema, i la llista de taxonomies del projecte es llegeix d'un cop d'ull en un fitxer.
NULL no serveix com a valor de "sense propietari" en una restricció d'unicitat
En SQL, NULL no és un valor: és l'absència de valor, i dues absències no són iguals entre elles. Aquesta regla, que sona acadèmica, és la que fa que una restricció d'unicitat deixi passar duplicats exactament on menys et convé.
Si la teva aplicació té diversos clients o diverses organitzacions, cadascun necessita el seu propi catàleg d'etiquetes. L'etiqueta "VIP" de l'organització 5 i la "VIP" de l'organització 6 són dues files diferents, i han de poder coexistir amb el mateix identificador.
Això es resol amb un segon eix polimòrfic a la taula de termes, que diu a qui pertany el catàleg, i amb una restricció d'unicitat sobre les quatre columnes juntes:
UNIQUE (scope_type, scope_id, taxonomy, handle)
I aquí hi ha el parany. Les etiquetes globals, les que no pertanyen a ningú, sembla que demanin NULL en aquestes dues columnes. No ho facis. A MySQL, NULL no és igual a NULL als efectes d'una restricció d'unicitat, així que la restricció deixa de protegir justament en el cas global: pots inserir "urgent" global cinquanta vegades i la base de dades no dirà res.
La solució és fer servir valors sentinella: cadena buida i zero.
protected $attributes = [
'scope_type' => '',
'scope_id' => 0,
];
Lleig de llegir i correcte de comportament. És una d'aquelles coses que només es descobreixen en producció, quan apareixen els duplicats que la restricció havia d'haver impedit.
El pivot polimòrfic només pot tenir cascada per un costat
Una relació polimòrfica renuncia a alguna cosa a canvi de la seva flexibilitat, i convé saber a què: una clau forana necessita apuntar a una taula concreta, i una columna que avui apunta a documents i demà a expedients no en pot tenir.
A la taula pivot, la columna que apunta al terme sí que pot tenir clau forana amb esborrat en cascada: esborres un terme i les seves associacions se'n van soles, ho fa la base de dades.
La columna que apunta al model etiquetat no pot tenir-la, perquè és polimòrfica: apunta a documents, a clients o a cases segons la fila, i una clau forana només pot apuntar a una taula.
Així que aquest costat cal netejar-lo des del codi, a l'esdeveniment d'esborrat del model:
static::deleting(function ($model) {
if (method_exists($model, 'isForceDeleting') && ! $model->isForceDeleting()) {
return; // esborrat suau: conservem per poder restaurar
}
$model->terms()->detach();
});
I hi ha un cas que aquest enganxall no cobreix i convé tenir present: si el model s'esborra per una cascada de la mateixa base de dades, perquè la seva taula pare tenia una clau forana en cascada, l'esdeveniment d'Eloquent no es dispara i les files del pivot queden apuntant a un registre que ja no existeix.
No és una errada del disseny polimòrfic, és el seu preu. I la manera de pagar-lo és recordar-ho: cada taula que es relaciona de forma polimòrfica amb alguna cosa que s'esborra en cascada necessita la seva pròpia neteja.
Si el teu model fa servir esborrat suau, no treguis les etiquetes en esborrar. Deixa les associacions i treu-les només en l'esborrat definitiu. Així, quan algú restaura un registre, en recupera també la classificació. És un detall de dues línies que evita la conversa incòmoda de "he recuperat l'expedient però ha perdut totes les seves etiquetes".
El nom en text pla i les traduccions a part
Desar el nom dues vegades, en una columna i a més traduït, s'assembla força a duplicar dades, que és justament el que un intenta evitar en dissenyar un esquema. La justificació no és al model, és en el que la base de dades sap fer amb cada format.
Un terme multiidioma es pot guardar sencer en una columna JSON, amb una clau per idioma. És net conceptualment i té un cost concret: ordenar, indexar i filtrar per nom passa a requerir extreure del JSON a cada consulta.
L'alternativa és tenir les dues coses: una columna de text pla amb el nom canònic, que és la que fan servir l'índex, l'ORDER BY i la generació de l'identificador, i una columna JSON a part amb les traduccions, que un accessor retorna quan existeix la de l'idioma actiu.
I per cercar en tots els idiomes alhora, una tercera columna desnormalitzada que concatena el nom, totes les seves traduccions i les descripcions, mantinguda automàticament en guardar. Amb això, una sola cerca troba el terme sigui en l'idioma que sigui, sense condicionals per idioma a la consulta.
L'arbre es construeix en memòria amb una sola consulta
Si la taxonomia és jeràrquica, el parany clàssic és recórrer els fills de cada node, que és una consulta per nivell i per branca.
No cal. Es porten tots els termes de la taxonomia en una consulta, s'agrupen per identificador de pare en memòria i s'enganxen:
$byParent = $terms->groupBy('parent_id');
$terms->each(fn ($term) => $term->setRelation(
'children',
$byParent->get($term->id, collect())
));
Una consulta, l'arbre muntat, i a partir d'aquí la molla de pa i els descendents es resolen sense tornar a la base de dades. Per a una taxonomia d'uns centenars de termes, que és el normal, això és sempre la resposta correcta.
Desactivar no és esborrar, i confondre-ho trenca l'històric
Quan algú demana poder "treure" una etiqueta, gairebé mai no vol dir esborrar-la. Vol que desaparegui d'on es tria, sense que s'evapori dels centenars de registres que ja la porten, i aquestes dues coses són operacions diferents encara que el botó es digui igual.
Quan una etiqueta deixa de fer-se servir, el que vols normalment no és esborrar-la: vols que deixi d'aparèixer al selector per a classificacions noves, però que els registres que ja la tenen la continuïn mostrant.
Això és una bandera d'actiu, no un esborrat suau, i són coses diferents. L'esborrat suau diu "això ja no existeix"; la bandera diu "això existeix i no s'ofereix". Ficar totes dues semàntiques al mateix mecanisme fa que ningú sàpiga què significa una fila amb data d'esborrat.
Què hi ha al mercat i en què es diferencia
Etiquetar és dels problemes més ben resolts de l'ecosistema de Laravel, així que la pregunta no és si existeix res fet, sinó en què es diferencia del que necessites. Les diferències no són a les funcionalitats de la portada, són a l'esquema.
spatie/laravel-tags és, de bon tros, el més utilitzat: més de dotze milions d'instal·lacions. Comparteix l'arquitectura de fons, dues taules i una pivot polimòrfica, i suporta tipus d'etiqueta i traduccions. La diferència d'enfocament que val la pena avaluar és que la seva internacionalització va tota per JSON, recolzada en una dependència de traducció obligatòria, la qual cosa és una sola font de veritat per camp a canvi d'haver d'extreure del JSON per ordenar o indexar. I l'aïllament per client no és al seu esquema, així que si el necessites es resol fora, amb un àmbit global o una base de dades per client.
rtconner/laravel-tagging i cviebrock/eloquent-taggable cobreixen el cas clàssic d'etiquetes lliures, amb normalització d'identificadors i comptadors d'ús, i ho fan bé. El seu abast no inclou de sèrie ni múltiples taxonomies ni jerarquia.
El que va decidir el nostre cas va ser el segon eix polimòrfic, el del propietari del catàleg, perquè era a l'esquema i a la restricció d'unicitat en lloc de resoldre's a mà a cada consulta. Quan tens clients que no s'han de veure les etiquetes entre ells, aquesta garantia la prefereixes tenir a la base de dades.
Com es veu en producció
A Crowd Legal hi ha nou taxonomies declarades i sis models classificables: expedients, clients, usuaris, fils, clàusules i plantilles de document.
I n'hi ha un setè que il·lustra bé per què el disseny polimòrfic paga. El model de fitxers ve d'un altre paquet i no es pot modificar, així que la relació se li afegeix en temps d'arrencada des d'un proveïdor de serveis. No va caldre cap taula nova ni tocar codi aliè: la pivot ja sap apuntar a qualsevol cosa.
I si no treballes amb Laravel
Els criteris són de modelatge de dades i valen a qualsevol lloc:
- Una pivot polimòrfica en lloc d'una taula per model. Si afegir una entitat classificable implica una migració, el disseny envellirà malament.
- Res de NULL a les columnes d'una restricció d'unicitat. Fes servir un valor sentinella, encara que quedi lleig.
- Neteja a mà el costat polimòrfic en esborrar, perquè la base de dades no ho pot fer per tu.
- Guarda el valor canònic en text pla i les traduccions a part, si necessites ordenar o indexar per ell.
- I construeix els arbres en memòria amb una consulta, no recorrent nivells.
On és el codi
El paquet és a Packagist, el codi a GitHub i la documentació del paquet recull les taxonomies, els àmbits i els scopes de consulta. Són dues taules i unes mil set-centes línies.
Si tens un producte on la classificació s'ha anat repartint per columnes de text i arrays JSON en diferents models, unificar-ho és més barat del que sembla i es pot fer model a model. Ho treballem a desenvolupament Laravel i software legal, i si ens ensenyes com classifiques avui et diem per on començar.