Vam acabar la lliçó anterior amb un esquema complet i una confessió: els tipus eren provisionals i les deu regles de negoci del document de requisits no eren enlloc. Avui, a l'esquema tal com va quedar, és perfectament possible inserir una multa de −40 €, un esdeveniment que acaba abans de començar, una sala amb aforament zero, una inscripció amb estat 'confimada' i un soci amb quaranta telèfons. L'esquema és estructuralment correcte i semànticament indefens.
Aquesta lliçó el blinda. Té dos blocs i tots dos són igual d'importants.
El primer és l'elecció raonada de tipus de dades. No és la llista de tipus de PostgreSQL —això és al manual—, sinó les decisions que hi ha al darrere: quan un enter es queda curt, per què els diners de les multes de BiblioRed mai no poden anar en coma flotant (amb una demostració executable el resultat de la qual sorprèn), quina diferència hi ha entre TIMESTAMP i TIMESTAMPTZ i per què aquesta diferència arruïna agendes d'esdeveniments, si limitar la longitud d'un text serveix d'alguna cosa, i per què la intercalació decideix si "Àngels" apareix abans o després d'"Angel" quan un soci busca al catàleg.
El segon és el catàleg de restriccions: NOT NULL, DEFAULT, UNIQUE amb el seu comportament sorprenent davant dels NULL, CHECK d'una i de diverses columnes, columnes generades, dominis reutilitzables, com anomenar-les perquè els errors de producció siguin llegibles i com afegir-les a una taula que ja té dades sense blocar el servei.
L'entregable és la versió definitiva i blindada de l'esquema de BiblioRed ampliat, i amb ella es tanca el mòdul 4.
Contingut
- Per què el tipus de dada és una decisió de disseny
- Enters:
SMALLINT,INTEGER,BIGINTi el dia que s'acaben NUMERICenfront deREAL: per què els diners mai no van en coma flotant- Text:
CHAR,VARCHAR(n)iTEXT - Dates i hores:
TIMESTAMPenfront deTIMESTAMPTZ, iINTERVAL BOOLEANi les trampes de'S'/'N'UUIDenfront d'enter seqüencial com a clau- Conjunts tancats de valors:
ENUM, taula de catàleg oCHECK JSONBi arrays com a escapatòries controladesBYTEAi per què les portades no van a la base- Codificació i intercalació: cercar títols en català i castellà
- Els tipus permissius de SQLite enfront dels estrictes de PostgreSQL
NOT NULL: la decisió de permetre absènciesDEFAULT: valors per omissióUNIQUEsimple i compost, i la seva relació ambNULLPRIMARY KEYcom a combinació de les anteriorsCHECK: codificar regles de negoci a l'esquema- Columnes generades i dominis
- Anomenar les restriccions i llegir els errors de producció
- Afegir restriccions a una taula que ja té dades
- Quines regles van a la base de dades i quines a l'aplicació
- Entregable: l'esquema definitiu de BiblioRed ampliat
- Errors Habituals i Consells
- Exercicis
- Conclusió
- Per què el tipus de dada és una decisió de disseny
Triar un tipus sembla un tràmit. En realitat s'estan decidint quatre coses alhora:
| Es decideix | Conseqüència |
|---|---|
| Quins valors són possibles | És la primera línia de defensa de la integritat: DATE fa impossible el 31 de febrer |
| Quines operacions tenen sentit | Sobre DATE es pot restar i obtenir dies; sobre el text '2026-05-14', no |
| Com s'ordena i es compara | '10' va abans que '9' en text i després en número |
| Quant ocupa i quant costa | Multiplicat per milions de files i pels índexs que s'hi construeixin |
Un tipus mal triat no dóna error: dóna resultats incorrectes en silenci, que és pitjor. Una data desada com a VARCHAR funciona perfectament fins al dia que algú ordena l'agenda d'esdeveniments i apareix desembre abans que febrer.
- Enters:
SMALLINT, INTEGER, BIGINT i el dia que s'acaben
SMALLINT, INTEGER, BIGINT i el dia que s'acaben| Tipus | Bytes | Rang | Ús típic a BiblioRed |
|---|---|---|---|
SMALLINT |
2 | −32.768 a 32.767 | aforament, planta, acompanyants, num_pagines, any_publicacio, durada_min |
INTEGER |
4 | ±2.147.483.647 | Gairebé totes les claus primàries |
BIGINT |
8 | ±9,2 × 10¹⁸ | Claus de taules d'altíssim volum (registres d'auditoria, esdeveniments de log) |
Quan un identificador es queda curt
La pregunta correcta no és "quantes files hi haurà?", sinó "quantes vegades s'incrementarà el comptador?", i són coses diferents: les files esborrades consumeixen valors que no es reutilitzen.
Els volums de BiblioRed a tres anys (apartat D del document de requisits) són 55.000 exemplars, 30.000 inscripcions i 9.000 multes. INTEGER dóna marge per a 2.147 milions. Encara que BiblioRed multipliqués la seva mida per mil, no s'hi acostaria. INTEGER és correcte per a totes les claus d'aquest esquema.
El cas en què no ho és té un senyal clar: taules on s'insereixen files per esdeveniments automàtics, no per accions humanes. Un registre d'accessos al web amb 500 insercions per segon esgota un INTEGER en uns cinquanta dies. La regla pràctica:
Si les files les genera una persona,
INTEGERen va sobrat. Si les genera una màquina, calcula.
I un advertiment real: canviar d'INTEGER a BIGINT en una taula gran i molt referenciada és una de les migracions més doloroses que existeixen, perquè reescriu la taula, tots els seus índexs i totes les columnes de les claus foranes que la referencien. Si hi ha un dubte raonable, BIGINT des del principi costa 4 bytes per fila.
Per què fer servir SMALLINT on encaixa
No és per estalviar bytes: és per documentar la intenció. Un aforament SMALLINT diu a qui llegeix l'esquema que cap sala municipal no acollirà 40.000 persones. És una restricció feble però gratuïta, i complementa el CHECK que li posarem després.
-- Provisional a 04-03
aforament INTEGER NOT NULL
-- Definitiu
aforament SMALLINT NOT NULL -- + CHECK (aforament > 0 AND aforament <= 2000)
NUMERIC enfront de REAL: per què els diners mai no van en coma flotant
NUMERIC enfront de REAL: per què els diners mai no van en coma flotantAquest és l'apartat més important de la lliçó i el que més vegades s'ignora en projectes reals.
| Tipus | Família | Precisió | Ús |
|---|---|---|---|
REAL / FLOAT4 |
Coma flotant binària | ~6 dígits | Magnituds físiques aproximades |
DOUBLE PRECISION / FLOAT8 |
Coma flotant binària | ~15 dígits | Càlcul científic |
NUMERIC(p,s) / DECIMAL(p,s) |
Decimal exacta | Exacta fins a p dígits |
Diners, quantitats exactes |
La demostració
Els tipus de coma flotant representen els números en binari. I hi ha decimals senzills que en binari són periòdics infinits, igual que 1/3 ho és en decimal. 0,1 és un d'ells. El que es desa no és 0,1: és el més proper a 0,1 que cap en 64 bits.
Tres multes de BiblioRed: 3,10 €, 2,20 € i 4,30 €. El total hauria de ser exactament 9,60 €.
SELECT 3.10::double precision
+ 2.20::double precision
+ 4.30::double precision AS total_flotant,
3.10::numeric
+ 2.20::numeric
+ 4.30::numeric AS total_numeric;total_flotant | total_numeric ---------------------+--------------- 9.600000000000001 | 9.60 (1 row)
No és un error de PostgreSQL: és com funciona el binari, i passa igual a Java, Python, JavaScript i C. Ara les conseqüències pràctiques:
SELECT (3.10::double precision + 2.20::double precision + 4.30::double precision) = 9.60
AS coincideix_flotant,
(3.10::numeric + 2.20::numeric + 4.30::numeric) = 9.60
AS coincideix_numeric;Una multa pagada íntegrament apareixeria com a impagada. La comprovació de RN5 ("la suma dels pagaments mai no supera l'import") i la consulta C7 ("socis amb deute superior a 20 €") donarien resultats aleatoris.
L'error s'agreuja en acumular. Els recàrrecs de BiblioRed són de 0,20 €/dia:
SELECT SUM(0.20::real) AS total_real,
SUM(0.20::numeric(6,2)) AS total_numeric
FROM generate_series(1, 1000);Tres centèsimes de diferència en mil operacions. En un tancament comptable municipal, això és una incidència que algú ha d'investigar. (El valor exacte pot variar lleugerament segons la plataforma; l'invariable és que no sigui exacte.)
La regla
Tot el que es compta en diners, tot el que es factura i tot el que es compara per igualtat va en
NUMERIC(p,s). La coma flotant és per a magnituds físiques on un error de 10⁻¹⁵ és irrellevant.
A BiblioRed:
| Columna | Tipus definitiu | Motiu |
|---|---|---|
multes.import |
NUMERIC(6,2) |
Diners. Fins a 9.999,99 € |
pagaments.import |
NUMERIC(6,2) |
Diners |
participacions.honoraris |
NUMERIC(8,2) |
Diners, amb més marge |
prestecs.recarrec |
NUMERIC(6,2) |
Ja estava bé |
informes_esdeveniment.valoracio_mitjana |
NUMERIC(3,2) |
0,00 a 5,00. Es compara i es mostra exacta |
Sobre NUMERIC(6,2): 6 és el total de dígits (precisió) i 2 els decimals (escala), així que la part entera admet quatre dígits. El gestor arrodoneix en inserir i rebutja el que desborda:
INSERT INTO multes (soci_id, motiu, import) VALUES (15, 'retard', 12.348);
SELECT import FROM multes WHERE soci_id = 15;ERROR: numeric field overflow DETAIL: A field with precision 6, scale 2 must round to an absolute value less than 10^4.
El desbordament avisa; l'arrodoniment no. Si BiblioRed arribés a emetre multes per pèrdua de material de més de 10.000 €, caldria ampliar a NUMERIC(8,2).
Nota sobre
MONEY: PostgreSQL té un tipusMONEY, però depèn de la configuració regional del servidor i no porta la moneda a dins. Es desaconsella:NUMERICés l'opció portable.
- Text:
CHAR, VARCHAR(n) i TEXT
CHAR, VARCHAR(n) i TEXT| Tipus | Comportament | Quan fer-lo servir |
|---|---|---|
CHAR(n) |
Omple amb espais fins a n |
Pràcticament mai |
VARCHAR(n) |
Longitud variable amb topall | Quan el topall és una regla real |
TEXT |
Longitud variable sense topall | Tota la resta |
El problema de CHAR(n)
SELECT 'ca'::char(5) = 'ca'::text AS iguals,
length('ca'::char(5)) AS longitud,
'[' || 'ca'::char(5) || ']' AS visualitzacio;El valor porta tres espais d'ompliment que apareixen en concatenar, en exportar a CSV i en comparar des d'una aplicació que no aplica les regles de SQL. És una font d'errors desproporcionada respecte al benefici, que a PostgreSQL és cap: CHAR(n) no és més ràpid ni ocupa menys.
Serveix d'alguna cosa limitar la longitud?
A PostgreSQL, VARCHAR(n) i TEXT s'emmagatzemen exactament igual. VARCHAR(200) no reserva 200 bytes; la n és només una restricció de validació. Així que la pregunta és si aquesta validació aporta res.
Sí que aporta quan el límit és una regla de negoci real:
| Columna | Tipus | Motiu |
|---|---|---|
materials_llibre.isbn |
VARCHAR(13) |
Un ISBN té 13 dígits per definició |
materials_revista.issn |
VARCHAR(9) |
Format normalitzat NNNN-NNNN |
sucursals.adr_codi_postal |
CHAR(5) o VARCHAR(5) |
Cinc dígits |
materials.idioma |
VARCHAR(5) |
Codis ISO 639-1 i variants (es, ca, pt-BR) |
telefons_soci.numero |
VARCHAR(20) |
Amb prefix internacional |
No aporta quan el número és inventat. titol VARCHAR(200) no respon a cap regla: és un número que algú va posar perquè n'havia de posar un. I té un cost real, perquè el dia que arribi un títol de 214 caràcters —n'hi ha— la inserció fallarà en producció, amb un error que l'equip haurà de diagnosticar de matinada:
INSERT INTO materials (tipus_material, titol, idioma)
VALUES ('llibre', 'Història general i natural de les Índies, illes i terra ferma del mar oceà, amb les anotacions i notes de l''il·lustríssim senyor cronista de la Corona de Castella, edició commemorativa del cinquè centenari', 'ca');Criteri de BiblioRed: VARCHAR(n) només on n surt d'una norma externa. TEXT per a títols, descripcions, observacions, biografies i noms. On el negoci vulgui un topall orientatiu però no crític, es posa amb un CHECK anomenat, que es pot relaxar sense reescriure la taula:
titol TEXT NOT NULL,
CONSTRAINT chk_materials_titol_longitud CHECK (char_length(titol) BETWEEN 1 AND 300)Avantatge no obvi: ampliar VARCHAR(200) a VARCHAR(300) és un ALTER TABLE que en versions antigues reescrivia la taula; relaxar un CHECK és deixar anar i tornar a crear una restricció.
- Dates i hores:
TIMESTAMP enfront de TIMESTAMPTZ, i INTERVAL
TIMESTAMP enfront de TIMESTAMPTZ, i INTERVAL| Tipus | Desa | Fus horari |
|---|---|---|
DATE |
Només data | No s'aplica |
TIME |
Només hora | No |
TIMESTAMP |
Data i hora | No: desa literalment el que li dónes |
TIMESTAMPTZ |
Data i hora | Sí: normalitza a UTC i converteix en llegir |
INTERVAL |
Una durada | No s'aplica |
La diferència que trenca agendes
TIMESTAMPTZ no emmagatzema el fus horari. Emmagatzema l'instant absolut (UTC) i el converteix al fus de la sessió en llegir-lo. TIMESTAMP desa una paret de rellotge sense context.
CREATE TABLE prova_hores (
amb_tz TIMESTAMPTZ,
sense_tz TIMESTAMP
);
SET TIME ZONE 'Europe/Madrid';
INSERT INTO prova_hores VALUES ('2026-05-14 18:00:00', '2026-05-14 18:00:00');
SELECT * FROM prova_hores;amb_tz | sense_tz ------------------------+--------------------- 2026-05-14 18:00:00+02 | 2026-05-14 18:00:00
Ara un soci consulta l'agenda des de l'estranger, o un procés automàtic s'executa amb una altra configuració:
amb_tz | sense_tz ------------------------+--------------------- 2026-05-14 11:00:00-05 | 2026-05-14 18:00:00
amb_tz diu correctament que el club de lectura de les 18:00 a Vallmar són les 11:00 a Bogotà: és el mateix instant. sense_tz diu 18:00 a totes dues, cosa que és falsa en una d'elles i no hi ha manera de saber en quina.
I hi ha un cas on el dany passa sense sortir de Vallmar: el canvi d'hora. La matinada de l'últim diumenge d'octubre, les 02:30 existeixen dues vegades. Un TIMESTAMP sense fus no les pot distingir; un TIMESTAMPTZ sí, perquè internament són dos instants UTC diferents.
Regla: tot instant d'un fet —quan comença un esdeveniment, quan es va fer una inscripció, quan es va registrar un pagament— va en
TIMESTAMPTZ. Fes-lo servir per defecte i justifica quan no ho facis.
TIMESTAMP sense fus té un ús legítim i estret: horaris recurrents que són "hora local" per definició, com "la biblioteca obre a les 9:00" independentment de l'estació. Allà el correcte acostuma a ser TIME, no TIMESTAMP.
DATE quan l'hora no existeix
No tot necessita hora. data_prestec, data_devolucio_prevista, data_emissio d'una multa i data_alta d'un material són dates: la biblioteca no cobra per hores. Fer servir TIMESTAMPTZ allà obliga a arrossegar un 00:00:00 que confon i complica les comparacions de rang.
| Columna | Tipus | Motiu |
|---|---|---|
esdeveniments.inici, esdeveniments.fi |
TIMESTAMPTZ |
Instants concrets, amb hora |
inscripcions.data_inscripcio |
TIMESTAMPTZ |
Instant d'un fet; l'ordre importa per a la llista d'espera |
pagaments.data_pagament |
TIMESTAMPTZ |
Instant comptable |
multes.data_emissio |
DATE |
L'ordenança compta dies |
prestecs.data_prestec |
DATE |
Ja estava bé |
informes_esdeveniment.data_redaccio |
DATE |
N'hi ha prou amb el dia |
INTERVAL per calcular venciments
INTERVAL representa una durada i se suma directament a dates:
SELECT p.prestec_id,
p.data_prestec,
p.data_prestec + INTERVAL '21 days' AS venciment_calculat,
p.data_devolucio_prevista,
CURRENT_DATE - p.data_devolucio_prevista AS dies_retard
FROM prestecs p
WHERE p.data_devolucio IS NULL
AND p.data_devolucio_prevista < CURRENT_DATE
ORDER BY dies_retard DESC; prestec_id | data_prestec | venciment_calculat | data_devolucio_prevista | dies_retard
------------+--------------+---------------------+-------------------------+-------------
4188 | 2026-06-05 | 2026-06-26 00:00:00 | 2026-06-26 | 37
4201 | 2026-06-14 | 2026-07-05 00:00:00 | 2026-07-05 | 28
(2 rows)Detall a notar: DATE − DATE dóna un enter de dies, no un interval, que és just el que cal per calcular el recàrrec de R10 a 0,20 €/dia. En canvi TIMESTAMPTZ − TIMESTAMPTZ sí que retorna un INTERVAL, i el farem servir a l'apartat 18 per a la durada dels esdeveniments.
BOOLEAN i les trampes de 'S'/'N'
BOOLEAN i les trampes de 'S'/'N'PostgreSQL té BOOLEAN amb tres valors possibles: TRUE, FALSE i NULL (lògica trivalent de 02-01).
SELECT nom, cognoms FROM socis WHERE actiu; -- sense '= TRUE'
SELECT nom, cognoms FROM socis WHERE NOT actiu;Comparem-ho amb el costum heretat de sistemes antics de fer servir CHAR(1) amb 'S'/'N':
Problema de 'S'/'N' |
Amb BOOLEAN |
|---|---|
Admet 's', 'S', 'Y', '1', 'X', ' ' i '' |
Només tres valors possibles |
Necessita un CHECK per restringir-lo |
Restringit pel tipus |
No funciona amb AND/OR/NOT directament |
Sí |
No l'agrega count(*) FILTER (WHERE ...) sense conversió |
Directe |
Depèn de l'idioma: 'S' en català, 'Y' en anglès |
Universal |
| Ocupa 1 byte + capçalera de text | 1 byte |
A BiblioRed són BOOLEAN: socis.actiu, sales.accessible, esdeveniments.publicat, ponents.extern.
Una precisió que evita un error freqüent: un booleà NULL significa "no se sap", i això de vegades és legítim (accessible d'una sala que encara no s'ha inspeccionat) i de vegades és un descuit. Si el tercer valor no té sentit en el domini, declara NOT NULL DEFAULT i elimina el dubte. Tots els booleans de BiblioRed són NOT NULL.
UUID enfront d'enter seqüencial com a clau
UUID enfront d'enter seqüencial com a clauEnter seqüencial (IDENTITY) |
UUID |
|
|---|---|---|
| Mida | 4-8 bytes | 16 bytes |
| Llegible | SELECT * FROM socis WHERE soci_id = 14 |
...= 'f47ac10b-58cc-4372-a567-0e02b2c3d479' |
| Es pot generar al client | No: cal anar al servidor | Sí, sense coordinació |
| Filtra informació | Sí: revela quants socis hi ha i en quin ordre es van donar d'alta | No |
| Fusionar dades de diverses bases | Col·lisiona | No col·lisiona |
| Localitat en índexs | Excel·lent (valors creixents) | Dolenta en UUIDv4; bona en UUIDv7 |
Quan compensa UUID: identificadors que viatgen en URL públiques, sistemes distribuïts que generen files sense connexió, fusió de bases de dades independents.
El cas de BiblioRed: claus internes d'una base centralitzada amb volums moderats. Enter seqüencial, sense discussió. Els soci_id i esdeveniment_id no apareixen en URL públiques; si demà el web exposés /esdeveniments/47, la solució no és canviar la clau primària sinó afegir un identificador públic a part:
ALTER TABLE esdeveniments ADD COLUMN slug_public UUID NOT NULL DEFAULT gen_random_uuid();
ALTER TABLE esdeveniments ADD CONSTRAINT uq_esdeveniments_slug UNIQUE (slug_public);Així la clau primària continua sent compacta per a les claus foranes i els índexs, i l'exposició pública no filtra el volum del negoci. És la millor de les dues opcions i gairebé ningú no la considera.
- Conjunts tancats de valors:
ENUM, taula de catàleg o CHECK
ENUM, taula de catàleg o CHECKBiblioRed té vuit columnes amb un conjunt tancat: materials.tipus_material, exemplars.estat, reserves.estat, esdeveniments.estat, inscripcions.estat, multes.motiu, multes.estat, pagaments.metode, telefons_soci.tipus, participacions.rol i esdeveniments_materials.paper. Hi ha tres maneres d'implementar-ho.
Opció A — Tipus enumerat de PostgreSQL
CREATE TYPE estat_multa AS ENUM ('pendent', 'pagada', 'condonada', 'anullada');
ALTER TABLE multes ALTER COLUMN estat TYPE estat_multa USING estat::estat_multa;ERROR: invalid input value for enum estat_multa: "pagadaa" LINE 2: VALUES (15, 'retard', 4.00, 'pagadaa');
Compacte (4 bytes), ordenació natural segons l'ordre de declaració, error claríssim. Els seus dos inconvenients són reals: no es poden eliminar valors (només afegir, i des de PostgreSQL 12 amb ADD VALUE), i no admet atributs: no es pot desar la descripció de cada estat ni el seu ordre de presentació.
Opció B — Taula de catàleg
CREATE TABLE estats_multa (
codi VARCHAR(15) PRIMARY KEY,
nom TEXT NOT NULL,
es_final BOOLEAN NOT NULL DEFAULT FALSE,
ordre SMALLINT NOT NULL
);
ALTER TABLE multes
ADD CONSTRAINT fk_multes_estat FOREIGN KEY (estat) REFERENCES estats_multa (codi);Els valors es gestionen amb INSERT/UPDATE sense tocar l'esquema, admeten atributs i es poden traduir. El preu és un JOIN cada vegada que es vol el nom llegible, i una taula més.
Opció C — CHECK sobre una columna de text
ALTER TABLE multes ADD CONSTRAINT chk_multes_estat
CHECK (estat IN ('pendent','pagada','condonada','anullada'));ERROR: new row for relation "multes" violates check constraint "chk_multes_estat" DETAIL: Failing row contains (312, 15, null, retard, 4.00, 2026-08-02, pagadaa).
Sense tipus nous, sense taules noves, i modificar la llista és deixar anar i recrear la restricció.
Comparativa
| Criteri | ENUM |
Taula de catàleg | CHECK |
|---|---|---|---|
| Rebutja valors invàlids | Sí | Sí | Sí |
| Afegir un valor | ALTER TYPE |
INSERT |
Recrear el CHECK |
| Treure un valor | Molt difícil | DELETE (si no s'usa) |
Recrear el CHECK |
| L'usuari final ho pot gestionar | No | Sí | No |
| Admet atributs (descripció, ordre, traducció) | No | Sí | No |
| Llistar els valors vàlids des de l'app | Consulta al catàleg del sistema | SELECT normal |
Analitzar el text de la restricció |
| Espai | 4 bytes | Mida del codi + índex de la FK | Mida del text |
| Portable a SQLite/MySQL | No | Sí | Sí |
| Cost de llegir amb el nom bonic | Cap | Un JOIN |
Cap |
La recomanació, aplicada a BiblioRed
Si el conjunt el gestiona l'usuari, taula de catàleg. Si el gestiona el desenvolupador i canvia poc,
CHECK.ENUMnomés quan el conjunt sigui immutable de debò.
| Columna | Decisió | Motiu |
|---|---|---|
esdeveniments.tipus_esdeveniment_id |
Taula de catàleg (tipus_esdeveniment) |
R5 ho demanava explícitament: l'ajuntament afegeix tipus |
multes.estat, esdeveniments.estat, inscripcions.estat |
CHECK |
Estats del flux de l'aplicació; canviar-los implica canviar codi de totes maneres |
multes.motiu |
CHECK |
Fixat per l'ordenança municipal |
pagaments.metode |
CHECK |
Tres mètodes; afegir-ne un implica integrar una passarel·la, és a dir, codi |
materials.tipus_material |
CHECK |
Afegir un tipus implica crear una subtaula (04-03, regla 10), és a dir, codi |
telefons_soci.tipus, participacions.rol, esdeveniments_materials.paper |
CHECK |
Llistes curtes i estables |
Cap columna no fa servir ENUM. La raó és la portabilitat —l'esquema ha de poder carregar-se a SQLite per a proves— i la rigidesa d'eliminar valors. És una decisió defensable, no l'única possible.
JSONB i arrays com a escapatòries controlades
JSONB i arrays com a escapatòries controladesA 03-03 vam modelar documents a MongoDB i a 03-04 vam veure que PostgreSQL desa documents amb jsonb. Aquí toca la pregunta de disseny: quan és legítim fer-los servir en un esquema relacional?
Arrays
-- Alternativa a la taula subtitols_dvd
ALTER TABLE materials_dvd ADD COLUMN subtitols TEXT[];
UPDATE materials_dvd SET subtitols = ARRAY['es','ca','en'] WHERE material_id = 1204;
SELECT material_id FROM materials_dvd WHERE subtitols @> ARRAY['ca'];Un array no és una llista amb comes: conserva l'estructura, té operadors propis (@> conté, && s'encavalca) i es pot indexar amb GIN (06-03). Però comparat amb la taula subtitols_dvd de 04-03 perd tres coses: no pot tenir clau forana a un catàleg d'idiomes, no admet atributs per element (són subtítols per a sords?) i les agregacions ("quants DVD per idioma?") requereixen unnest.
Decisió de BiblioRed: la taula. L'array és l'opció correcta quan els elements són etiquetes sense estructura i només es consulten per pertinença.
JSONB
L'ús legítim és dades genuïnament variables l'estructura de les quals no es coneix en temps de disseny. A BiblioRed hi ha un cas: les respostes a les enquestes de satisfacció dels esdeveniments (R9). Cada tipus d'esdeveniment té el seu qüestionari, el nombre i tipus de preguntes canvia, i s'afegeixen preguntes sense avisar.
ALTER TABLE informes_esdeveniment ADD COLUMN respostes_enquesta JSONB;
UPDATE informes_esdeveniment
SET respostes_enquesta = '{
"versio_questionari": 3,
"num_respostes": 18,
"preguntes": [
{"id": "p1", "text": "Valoració general", "mitjana": 4.3},
{"id": "p2", "text": "Adequació de la sala", "mitjana": 3.8},
{"id": "p5", "text": "Repetiries?", "si": 16, "no": 2}
]}'::jsonb
WHERE esdeveniment_id = 47;
SELECT esdeveniment_id, respostes_enquesta -> 'num_respostes' AS n
FROM informes_esdeveniment
WHERE respostes_enquesta @> '{"versio_questionari": 3}';La línia que no s'ha de creuar: JSONB no és un lloc on ficar columnes per no dissenyar-les. Si una dada es consulta sempre, es filtra, s'agrega o té una regla de negoci associada, és una columna. Ficar assistents_reals dins del JSON seria l'antipatró EAV de 04-01 amb sintaxi moderna.
Criteri: columna si la dada té nom conegut, tipus conegut i regles;
JSONBsi la forma la decideix algú aliè a l'esquema. I si acabes escrivint unCHECKcomplex sobre una clau del JSON, aquesta clau volia ser una columna.
BYTEA i per què les portades no van a la base
BYTEA i per què les portades no van a la baseBYTEA desa dades binàries. La temptació és desar dins de la base les portades dels llibres i els PDF dels informes. No ho facis, llevat que hi hagi molt bones raons:
| Problema | Detall |
|---|---|
| Còpies de seguretat | 40.000 portades de 300 KB són 12 GB. La còpia diària passa de 2 minuts a 40, i la restauració igual |
| Memòria compartida | La memòria cau del gestor s'omple d'imatges en lloc d'índexs i files consultades |
| Replicació | Cada imatge viatja pel registre de transaccions a totes les rèpliques |
| No hi ha servei directe | El servidor web no les pot servir: cal llegir-les, transferir-les i reenviar-les |
| Sense CDN ni memòria cau HTTP | Es perd tota la infraestructura de distribució d'estàtics |
El correcte: desar el fitxer al sistema de fitxers o en emmagatzematge d'objectes, i a la base la ruta i les metadades:
ALTER TABLE materials ADD COLUMN portada_url TEXT;
ALTER TABLE materials ADD COLUMN portada_hash CHAR(64); -- SHA-256, per detectar duplicatsLes excepcions legítimes són poques i recognoscibles: binaris petits (menys d'uns pocs KB), poc nombrosos, que hagin de participar de la transacció i del control d'accés de la base —una signatura digital, un segell de temps—.
- Codificació i intercalació: cercar títols en català i castellà
BiblioRed gestiona títols en català i castellà. Això converteix dos paràmetres normalment invisibles en decisions de disseny.
Codificació: UTF-8, sempre
UTF-8 representa qualsevol caràcter de qualsevol idioma: ñ, ç, à, ï, · (el punt volat català de "col·lecció"), cometes tipogràfiques i emojis. Qualsevol codificació d'un sol byte (LATIN1) trencarà alguna cosa tard o d'hora, i la migració posterior és laboriosa. No hi ha cap decisió a prendre aquí: UTF-8.
Intercalació: com s'ordena i es compara
La intercalació són les regles d'ordenació i comparació de cadenes. No és un detall cosmètic: decideix el resultat d'ORDER BY, de < i >, i dels índexs que es recolzin en aquest ordre.
SELECT titol FROM (VALUES ('Ángeles'),('Antología'),('Àngels'),('anatomía'),('Zoo'))
AS t(titol)
ORDER BY titol COLLATE "C";La intercalació "C" ordena pel valor byte a byte: totes les majúscules abans que totes les minúscules, i els accentuats al final. És ràpida i completament inacceptable per a un catàleg bibliotecari.
SELECT titol FROM (VALUES ('Ángeles'),('Antología'),('Àngels'),('anatomía'),('Zoo'))
AS t(titol)
ORDER BY titol COLLATE "es-ES-x-icu";Aquest és l'ordre que espera qualsevol persona: els accents i les majúscules no alteren la posició alfabètica.
Cerca insensible a majúscules i accents
Un soci que busca "mapa del temps" ha de trobar "El Mapa del Temps", i qui busca "angels" ha de trobar "Àngels". Dues peces:
-- Insensible a accents: extensió unaccent
CREATE EXTENSION IF NOT EXISTS unaccent;
SELECT titol FROM materials
WHERE unaccent(titol) ILIKE unaccent('%angels%');Des de PostgreSQL 12 existeix a més l'opció més neta: una intercalació no determinista que ignora accents i majúscules per a la comparació, aplicable a una columna concreta.
CREATE COLLATION cerca_es (
provider = icu,
locale = 'es-ES-u-ks-level1', -- nivell 1: ignora accents i majúscules
deterministic = false
);Decisió de BiblioRed: intercalació per defecte es-ES-x-icu a la base (ordenació correcta) i cerca amb unaccent + ILIKE a les consultes del catàleg. Les columnes de codis (isbn, codi d'exemplar, idioma) porten intercalació "C" explícita: són codis, no text, i comparar byte a byte és el correcte i el més ràpid.
Advertiment operatiu: canviar la intercalació d'una base amb dades invalida els índexs construïts sobre columnes de text, perquè el seu ordre deixa de ser vàlid. Cal reconstruir-los (
REINDEX). És una decisió que es pren en crear la base i no es canvia a la lleugera.
- Els tipus permissius de SQLite enfront dels estrictes de PostgreSQL
A 01-04 vam instal·lar SQLite com a alternativa lleugera. El seu sistema de tipus funciona d'una manera que sorprèn qui ve de PostgreSQL, i cal conèixer-la perquè és una font clàssica de dades corruptes.
SQLite fa servir afinitat de tipus: el tipus declarat és una preferència, no una restricció.
-- A SQLite
CREATE TABLE prova (id INTEGER, import NUMERIC, data DATE);
INSERT INTO prova VALUES ('hola', 'molts diners', 'el dijous');
SELECT * FROM prova;Cap error. La mateixa sentència a PostgreSQL:
ERROR: invalid input syntax for type integer: "hola"
LINE 1: INSERT INTO prova VALUES ('hola', 'molts diners', 'el dijous');Les diferències que més afecten el disseny:
| Aspecte | PostgreSQL | SQLite |
|---|---|---|
| Tipus | Estrictes: valor invàlid, error | Afinitat: converteix si pot, desa tal qual si no |
| Tipus disponibles | Més de 40, més els definits per l'usuari | Cinc classes d'emmagatzematge |
BOOLEAN |
Tipus real | INTEGER 0/1 |
| Dates i hores | DATE, TIMESTAMPTZ, INTERVAL |
No existeixen: text ISO-8601, número o enter Unix |
NUMERIC exacte |
Sí | No: REAL de coma flotant. Els diners exigeixen desar cèntims com a enters |
VARCHAR(n) |
Valida n |
Ignora n completament |
CHECK |
Sí | Sí |
| Claus foranes | Actives sempre | Desactivades per defecte (PRAGMA foreign_keys = ON, 02-06) |
| Intercalació | Completa via ICU | Tres intercalacions bàsiques; sense regles d'idioma |
Taules STRICT
Des de SQLite 3.37 (2021) existeix una solució parcial:
CREATE TABLE prova (id INTEGER, import REAL, data TEXT) STRICT;
INSERT INTO prova VALUES ('hola', 1.0, '2026-05-14');Recomanació per a BiblioRed: PostgreSQL és la referència. Si es fa servir SQLite per a proves locals, cal declarar les taules STRICT, activar PRAGMA foreign_keys = ON i no desar diners en REAL —els imports s'emmagatzemen com a enters de cèntims—. Sabent això, SQLite és una eina excel·lent; sense saber-ho, és una trampa.
NOT NULL: la decisió de permetre absències
NOT NULL: la decisió de permetre absènciesComença el segon bloc. NOT NULL és la restricció més simple i la que més es decideix per inèrcia.
A 02-01 vam veure que NULL no és zero ni cadena buida: és "no se sap" o "no s'aplica". Permetre'l en una columna és afirmar que aquest cas existeix en el negoci. La pregunta correcta és: "existeix una fila legítima en què això no se sàpiga?"
| Columna | NOT NULL? |
Raonament |
|---|---|---|
esdeveniments.titol |
Sí | Un esdeveniment sense títol no és un esdeveniment |
esdeveniments.sala_id |
No | Decisió D4: esdeveniments a l'aire lliure |
esdeveniments.fi |
Sí | Sempre es coneix en programar-lo |
prestecs.data_devolucio |
No | NULL significa "encara no retornat", i és informació valuosa |
multes.prestec_id |
No | Decisió D6: multes per pèrdua a la sala |
multes.import |
Sí | Una multa sense import no té sentit |
ponents.email |
No | Hi ha externs dels quals només es té el telèfon |
inscripcions.acompanyants |
Sí, amb DEFAULT 0 |
"Cap" és 0, no "no se sap" |
informes_esdeveniment.valoracio_mitjana |
No | Pot no haver-hi enquestes |
informes_esdeveniment.assistents_reals |
Sí | Si es redacta l'informe, es compta |
La fila d'acompanyants és la més instructiva. És la distinció entre zero i desconegut, i confondre-la és l'error més comú amb NULL. Un soci que hi va sol té 0 acompanyants, no un nombre desconegut d'acompanyants. La combinació NOT NULL DEFAULT 0 ho expressa exactament i a més fa que SUM(1 + acompanyants) funcioni sense COALESCE.
Consell pràctic: comença declarant tot
NOT NULLi treu la restricció només on puguis anomenar la fila legítima que la incompleix. El sentit per defecte oposat —permetre nuls i restringir després— produeix esquemes on ningú no sap quines columnes poden faltar.
DEFAULT: valors per omissió
DEFAULT: valors per omissiódata_alta DATE NOT NULL DEFAULT CURRENT_DATE,
data_pagament TIMESTAMPTZ NOT NULL DEFAULT now(),
estat TEXT NOT NULL DEFAULT 'programat',
publicat BOOLEAN NOT NULL DEFAULT FALSE,
acompanyants SMALLINT NOT NULL DEFAULT 0,
honoraris NUMERIC(8,2) NOT NULL DEFAULT 0Un DEFAULT s'aplica quan la columna s'omet a l'INSERT, no quan se li passa NULL explícitament:
INSERT INTO esdeveniments (titol, tipus_esdeveniment_id, inici, fi, places_ofertes)
VALUES ('Contacontes de tardor', 4, '2026-10-03 17:30+02', '2026-10-03 18:30+02', 30);
SELECT titol, estat, publicat FROM esdeveniments WHERE titol = 'Contacontes de tardor';titol | estat | publicat -----------------------+------------+---------- Contacontes de tardor | programat | f
INSERT INTO esdeveniments (titol, tipus_esdeveniment_id, inici, fi, places_ofertes, estat)
VALUES ('Taller de haiku', 2, '2026-10-10 18:00+02', '2026-10-10 20:00+02', 15, NULL);El DEFAULT no rescata un NULL explícit. És un comportament correcte i sorprèn molta gent.
now() enfront de CURRENT_DATE
CURRENT_DATE→ data del dia.now()/CURRENT_TIMESTAMP→ instant d'inici de la transacció, no de la sentència. Totes les files inserides a la mateixa transacció porten la mateixa marca, cosa que acostuma a ser el desitjable.clock_timestamp()→ instant real de cada crida. Es fa servir per mesurar, gairebé mai com aDEFAULT.
DEFAULT enfront de l'aplicació
Posar el valor per defecte a la base garanteix que qualsevol via d'inserció el respecti: l'aplicació web, un script d'importació, una càrrega massiva o un INSERT manual des de psql. Si el valor per defecte només viu al codi de l'aplicació, la primera càrrega massiva se'l saltarà. És el mateix argument que tanca l'apartat 21.
UNIQUE simple i compost, i la seva relació amb NULL
UNIQUE simple i compost, i la seva relació amb NULLUNIQUE garanteix que no hi hagi dues files amb el mateix valor. A BiblioRed, cada clau natural de l'apartat 10 de 04-01 porta la seva:
CONSTRAINT uq_materials_llibre_isbn UNIQUE (isbn),
CONSTRAINT uq_exemplars_codi UNIQUE (codi),
CONSTRAINT uq_ponents_email UNIQUE (email),
CONSTRAINT uq_sales_sucursal_nom UNIQUE (sucursal_id, nom),
CONSTRAINT uq_materials_revista_issn_num UNIQUE (issn, numero)El compost s'aplica a la combinació, no a cada columna:
INSERT INTO sales (sucursal_id, nom, aforament) VALUES (1, 'Sala Polivalent', 60);
INSERT INTO sales (sucursal_id, nom, aforament) VALUES (2, 'Sala Polivalent', 45);Dues sales amb el mateix nom en sucursals diferents: correcte segons R3.
ERROR: duplicate key value violates unique constraint "uq_sales_sucursal_nom" DETAIL: Key (sucursal_id, nom)=(1, Sala Polivalent) already exists.
UNIQUE i NULL: el comportament que sorprèn
En SQL estàndard, NULL no és igual a NULL (lògica trivalent, 02-01). Com que UNIQUE prohibeix valors iguals i dos NULL no ho són, una columna UNIQUE admet tants NULL com vulguis:
INSERT INTO ponents (nom, cognoms, email) VALUES ('Elena', 'Roig', NULL);
INSERT INTO ponents (nom, cognoms, email) VALUES ('Marc', 'Duran', NULL);
INSERT INTO ponents (nom, cognoms, email) VALUES ('Aina', 'Ferrer', NULL);
SELECT count(*) FROM ponents WHERE email IS NULL;Tres ponents sense correu, amb UNIQUE (email). No és una fallada: és el que permet combinar "el correu és opcional" amb "dos ponents no comparteixen correu", que era just el que necessitàvem a 04-03.
El cas on aquest comportament fa mal a BiblioRed
Recorda la restricció uq_multes_prestec_motiu UNIQUE (prestec_id, motiu), que implementava R10 ("un préstec genera com a molt una multa de cada motiu"). Com que prestec_id és anul·lable per la decisió D6, té un forat:
INSERT INTO multes (soci_id, prestec_id, motiu, import) VALUES (16, NULL, 'perdua', 18.00);
INSERT INTO multes (soci_id, prestec_id, motiu, import) VALUES (16, NULL, 'perdua', 18.00);
INSERT INTO multes (soci_id, prestec_id, motiu, import) VALUES (16, NULL, 'perdua', 18.00);Tres multes idèntiques per la mateixa pèrdua, i l'UNIQUE no ha dit res perquè cada (NULL, 'perdua') és diferent dels altres. És un duplicat real en producció.
Des de PostgreSQL 15 hi ha una solució declarativa:
ALTER TABLE multes DROP CONSTRAINT uq_multes_prestec_motiu;
ALTER TABLE multes ADD CONSTRAINT uq_multes_prestec_motiu
UNIQUE NULLS NOT DISTINCT (prestec_id, motiu);ERROR: duplicate key value violates unique constraint "uq_multes_prestec_motiu" DETAIL: Key (prestec_id, motiu)=(null, perdua) already exists.
Amb NULLS NOT DISTINCT, els NULL es consideren iguals entre si a efectes d'unicitat. En versions anteriors a la 15 es resolia amb un índex únic parcial, que és matèria de 06-03.
La lliçó de fons: un
UNIQUEsobre columnes anul·lables no garanteix el que sembla garantir. Cada vegada que en declaris un, comprova si alguna de les seves columnes admetNULLi decideix conscientment.
PRIMARY KEY com a combinació de les anteriors
PRIMARY KEY com a combinació de les anteriorsPRIMARY KEY no és una restricció nova: equival a UNIQUE + NOT NULL, més el paper d'identificador per defecte de les claus foranes.
-- Aquestes dues declaracions són gairebé equivalents
CONSTRAINT pk_sales PRIMARY KEY (sala_id)
sala_id INTEGER NOT NULL,
CONSTRAINT uq_sales_id UNIQUE (sala_id)Les tres diferències que sí que importen:
- Només hi pot haver una clau primària per taula; claus
UNIQUE, les que calguin. REFERENCES taulasense columna apunta implícitament a la clau primària.- Eines, ORM i clients gràfics la fan servir per identificar la fila.
A BiblioRed conviuen els dos usos: materials té PRIMARY KEY (material_id) i a més UNIQUE (material_id, tipus_material), aquesta última existint únicament perquè les subtaules la puguin referenciar amb la clau forana composta de la regla 10.
CHECK: codificar regles de negoci a l'esquema
CHECK: codificar regles de negoci a l'esquemaAquí és on les deu regles de negoci de 04-01 entren per fi a l'esquema.
CHECK d'una columna
CONSTRAINT chk_multes_import_no_negatiu CHECK (import >= 0), -- RN5
CONSTRAINT chk_sales_aforament_positiu CHECK (aforament > 0),
CONSTRAINT chk_inscripcions_acompanyants CHECK (acompanyants BETWEEN 0 AND 3), -- R6
CONSTRAINT chk_multes_motiu CHECK (motiu IN ('retard','deteriorament','perdua')),
CONSTRAINT chk_multes_estat CHECK (estat IN ('pendent','pagada','condonada','anullada'))Comprovació:
ERROR: new row for relation "multes" violates check constraint "chk_multes_import_no_negatiu" DETAIL: Failing row contains (318, 14, null, retard, -40.00, 2026-08-02, pendent).
ERROR: new row for relation "inscripcions" violates check constraint "chk_inscripcions_acompanyants" DETAIL: Failing row contains (47, 16, 2026-08-02 12:14:03.221+02, confirmada, 7).
CHECK de diverses columnes
Un CHECK declarat a nivell de taula pot referir-se a diverses columnes de la mateixa fila:
-- RN3: l'esdeveniment no pot acabar abans de començar
CONSTRAINT chk_esdeveniments_fi_posterior CHECK (fi > inici),
-- El préstec no es retorna abans de prestar-se
CONSTRAINT chk_prestecs_devolucio CHECK (data_devolucio IS NULL
OR data_devolucio >= data_prestec),
-- RN8: un esdeveniment publicat necessita sala i places
CONSTRAINT chk_esdeveniments_publicat CHECK (NOT publicat
OR (sala_id IS NOT NULL AND places_ofertes > 0))INSERT INTO esdeveniments (titol, tipus_esdeveniment_id, inici, fi, places_ofertes)
VALUES ('Xerrada mal programada', 5, '2026-09-10 19:00+02', '2026-09-10 18:00+02', 40);ERROR: new row for relation "esdeveniments" violates check constraint "chk_esdeveniments_fi_posterior" DETAIL: Failing row contains (52, Xerrada mal programada, null, 5, null, 2026-09-10 19:00:00+02, 2026-09-10 18:00:00+02, 40, programat, f).
Fixa't en la construcció de chk_prestecs_devolucio: inclou el cas NULL explícitament. És imprescindible saber com tracta CHECK els nuls.
CHECK i NULL: la regla que cal memoritzar
Un CHECK accepta la fila quan l'expressió dóna TRUE o NULL. Només la rebutja quan dóna FALSE.
-- CHECK (data_devolucio >= data_prestec), sense contemplar NULL
INSERT INTO prestecs (soci_id, exemplar_id, data_prestec, data_devolucio_prevista)
VALUES (14, 3081, '2026-07-01', '2026-07-22');S'accepta perquè data_devolucio és NULL i NULL >= '2026-07-01' dóna NULL, no FALSE. En aquest cas concret és el que volíem —un préstec no retornat és vàlid—, però el comportament és fàcil de confondre. Si vols rebutjar els nuls, fes-ho explícit amb NOT NULL o amb IS NOT NULL dins del CHECK.
El que un CHECK NO pot fer
Un CHECK només veu la fila que s'està inserint o modificant. No pot consultar altres taules ni altres files. Això deixa fora quatre de les deu regles de negoci:
| Regla | Per què no cap en un CHECK |
On viu |
|---|---|---|
| RN1: places ≤ aforament de la sala | L'aforament és a sales |
Disparador o aplicació |
| RN2: inscripcions ≤ places ofertes | Requereix agregar inscripcions |
Disparador o aplicació (amb bloqueig) |
| RN4: dos esdeveniments no s'encavalquen a la mateixa sala | Requereix consultar altres files | Restricció d'exclusió (vegeu més avall) |
| RN10: no reservar un material sense exemplars | Requereix comptar exemplars |
Disparador o aplicació |
| R12: màxim 3 telèfons per soci | Requereix comptar telefons_soci |
Disparador o aplicació |
Per a RN4, PostgreSQL ofereix una eina específica i poc coneguda, la restricció d'exclusió, que generalitza UNIQUE a qualsevol operador:
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE esdeveniments ADD CONSTRAINT excl_esdeveniments_encavalcament_sala
EXCLUDE USING gist (
sala_id WITH =,
tstzrange(inici, fi) WITH &&
) WHERE (estat <> 'cancellat' AND sala_id IS NOT NULL);Es llegeix: no poden existir dues files no cancel·lades amb la mateixa sala_id els rangs de temps de les quals s'encavalquin.
INSERT INTO esdeveniments (titol, tipus_esdeveniment_id, sala_id, inici, fi, places_ofertes)
VALUES ('Club de lectura', 1, 3, '2026-09-17 18:00+02', '2026-09-17 20:00+02', 20);
INSERT INTO esdeveniments (titol, tipus_esdeveniment_id, sala_id, inici, fi, places_ofertes)
VALUES ('Presentació', 3, 3, '2026-09-17 19:00+02', '2026-09-17 21:00+02', 50);INSERT 0 1 ERROR: conflicting key value violates exclusion constraint "excl_esdeveniments_encavalcament_sala" DETAIL: Key (sala_id, tstzrange(inici, fi))=(3, ["2026-09-17 19:00:00+02","2026-09-17 21:00:00+02")) conflicts with existing key (sala_id, tstzrange(inici, fi))=(3, ["2026-09-17 18:00:00+02","2026-09-17 20:00:00+02")).
RN4 garantida pel servidor, amb concurrència correcta i sense una línia de codi d'aplicació. És del millor que ofereix PostgreSQL i no té equivalent a SQLite.
- Columnes generades i dominis
Columnes generades
Una columna generada es calcula a partir d'altres de la mateixa fila. És la manera correcta de materialitzar un derivat senzill sense risc de desincronització, perquè el gestor la manté i ningú no la pot escriure.
ALTER TABLE inscripcions ADD COLUMN places_ocupades SMALLINT
GENERATED ALWAYS AS (1 + acompanyants) STORED;
ALTER TABLE esdeveniments ADD COLUMN durada_min INTEGER
GENERATED ALWAYS AS (EXTRACT(EPOCH FROM (fi - inici)) / 60) STORED; esdeveniment_id | inici | fi | durada_min
-----------------+------------------------+------------------------+------------
47 | 2026-09-17 18:00:00+02 | 2026-09-17 20:00:00+02 | 120ERROR: column "durada_min" can only be updated to DEFAULT DETAIL: Column "durada_min" is a generated column.
No es pot mentir. Aquesta és la diferència essencial amb la desnormalització manual de 05-04: aquí el gestor garanteix la coherència.
Dos límits: a PostgreSQL només existeix STORED (es desa; no hi ha columnes virtuals calculades al vol), i l'expressió ha de ser immutable i referir-se només a columnes de la mateixa fila. Per això places_lliures d'un esdeveniment —que agrega una altra taula— no pot ser una columna generada i continua sent la vista v_esdeveniments_ocupacio de 04-03.
Dominis
Un domini és un tipus propi construït sobre un altre, amb restriccions incorporades. Serveix per no repetir la mateixa validació en quinze llocs i perquè l'esquema expressi el vocabulari del negoci.
CREATE DOMAIN dom_email AS TEXT
CHECK (VALUE ~ '^[^@[:space:]]+@[^@[:space:]]+\.[A-Za-z]{2,}$');
CREATE DOMAIN dom_import_eur AS NUMERIC(8,2)
CHECK (VALUE >= 0);
CREATE DOMAIN dom_idioma AS VARCHAR(5)
CHECK (VALUE ~ '^[a-z]{2}(-[A-Z]{2})?$');
CREATE DOMAIN dom_codi_postal AS CHAR(5)
CHECK (VALUE ~ '^[0-9]{5}$');I es fan servir com si fossin tipus:
CREATE TABLE ponents (
ponent_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
email dom_email,
...
);
INSERT INTO ponents (nom, cognoms, email)
VALUES ('Elena', 'Roig', 'elena.roig-arrova-example.org');| Avantatge | Detall |
|---|---|
| Una sola definició | La validació del correu és en un sol lloc, per a socis i ponents |
| Canvi centralitzat | ALTER DOMAIN ... ADD CONSTRAINT afecta totes les columnes |
| Documentació | email dom_email diu més que email TEXT |
| Menys errors de còpia | No hi ha quinze CHECK que puguin divergir |
El seu inconvenient és la portabilitat: els dominis són de SQL estàndard però SQLite no els té, així que un esquema amb dominis necessita una versió alternativa per a les proves locals.
- Anomenar les restriccions i llegir els errors de producció
Si no anomenes una restricció, PostgreSQL li posa un nom automàtic. Compara els dos missatges.
Sense nom:
CREATE TABLE sales_sense_noms (
sala_id INTEGER PRIMARY KEY,
sucursal_id INTEGER NOT NULL REFERENCES sucursals(sucursal_id),
nom TEXT NOT NULL,
aforament SMALLINT NOT NULL CHECK (aforament > 0),
UNIQUE (sucursal_id, nom)
);
INSERT INTO sales_sense_noms VALUES (1, 1, 'Sala Polivalent', 0);ERROR: new row for relation "sales_sense_noms" violates check constraint "sales_sense_noms_aforament_check"
Amb nom:
Sembla un detall estètic. No ho és, per tres raons concretes:
- L'equip de suport llegeix el missatge.
chk_sales_aforament_positius'entén sense obrir l'esquema;sales_aforament_checkobliga a investigar. Amb dosCHECKa la mateixa columna, el nom autogenerat éssales_aforament_check1, i aleshores ja no hi ha res a fer. - L'aplicació pot reaccionar al nom. El codi captura l'error, llegeix el nom de la restricció i mostra el missatge adequat a l'usuari. Amb noms autogenerats, aquesta lògica es trenca tan bon punt algú recrea una taula i els sufixos canvien d'ordre.
- Les migracions necessiten el nom.
ALTER TABLE ... DROP CONSTRAINTexigeix saber-lo, i consultar el catàleg cada vegada és fricció innecessària.
Convenció de BiblioRed:
| Prefix | Tipus | Exemple |
|---|---|---|
pk_ |
Clau primària | pk_inscripcions |
uq_ |
Unicitat | uq_sales_sucursal_nom |
fk_ |
Clau forana | fk_inscripcions_esdeveniment |
chk_ |
Comprovació | chk_esdeveniments_fi_posterior |
excl_ |
Exclusió | excl_esdeveniments_encavalcament_sala |
dom_ |
Domini | dom_import_eur |
I la forma del nom: <prefix>_<taula>_<què comprova>. Els noms són globals per esquema, així que incloure la taula evita col·lisions.
- Afegir restriccions a una taula que ja té dades
El cas real: prestecs té 4.312 files i cap restricció sobre el recàrrec. Si intentes afegir-la directament:
Passen dues coses. Si hi ha dades que la incompleixen:
I si no n'hi ha, PostgreSQL recorre la taula sencera per verificar-ho, mantenint un bloqueig que impedeix llegir i escriure. Amb 4.312 files és instantani; amb deu milions, és una aturada de servei.
El procediment correcte en tres passos
Pas 1 — Esbrinar quantes files l'incompleixen:
Pas 2 — Corregir les dades existents:
Pas 3 — Afegir la restricció amb NOT VALID i validar-la després:
NOT VALID significa: "la restricció s'aplica des d'ara a tota fila nova o modificada, però no comprovis les que ja hi són". És instantani i pren un bloqueig molt més lleuger. La base queda protegida immediatament contra dades noves incorrectes.
Després, en una finestra tranquil·la:
VALIDATE CONSTRAINT recorre la taula, però amb un bloqueig que permet lectures i escriptures concurrents. Si troba una fila que l'incompleix, falla i la restricció es queda en NOT VALID; no es perd res.
Es pot consultar l'estat al catàleg:
SELECT conname, convalidated FROM pg_constraint
WHERE conrelid = 'prestecs'::regclass AND contype = 'c';
NOT VALIDfunciona igual amb claus foranes i és la tècnica estàndard per afegir integritat referencial a una base gran sense parar el servei.NOT NULLés l'excepció: no admetNOT VALIDfins a PostgreSQL 18; el rodeig clàssic és afegir primer unCHECK (col IS NOT NULL) NOT VALID, validar-lo i convertir-lo després.
- Quines regles van a la base de dades i quines a l'aplicació
A 02-06 vam defensar la integritat referencial al servidor. Ara toca el criteri general, perquè no tot cap a l'esquema.
L'argument de fons
La base de dades és l'únic punt pel qual passen tots els camins. L'aplicació web, l'aplicació mòbil, l'script nocturn d'importació del catàleg, la càrrega massiva del proveïdor, el becari amb psql i el procés de migració: tots escriuen a la mateixa base. Una regla que viu només a l'aplicació web l'incompleixen els altres cinc.
A més, les aplicacions es reescriuen; les dades romanen. I una regla a la base s'aplica retroactivament en el sentit que impedeix que el problema torni a aparèixer, mentre que corregir el codi no arregla les dades ja corrompudes.
La taula de decisió
| Tipus de regla | On | Motiu |
|---|---|---|
Format i rang d'un valor (import >= 0) |
Base (CHECK, domini) |
Barata, universal, impossible de saltar |
| Unicitat | Base (UNIQUE) |
L'aplicació no la pot garantir amb concurrència |
| Integritat referencial | Base (FOREIGN KEY, 02-06) |
Igual |
Coherència entre columnes d'una fila (fi > inici) |
Base (CHECK) |
Barata |
| No encavalcament (RN4) | Base (EXCLUDE) |
Requereix concurrència correcta |
| Agregats sobre altres taules (RN2: aforament) | Base amb disparador, o aplicació amb bloqueig | No cap en un CHECK; el disparador és més segur però més opac |
Flux d'estats (de programat a obert, no a celebrat) |
Aplicació | És lògica de procés, canvia sovint |
| Càlcul de l'import d'una multa segons l'ordenança | Aplicació | Canvia amb les tarifes; el resultat es congela a la base |
| Bloqueig per deute > 20 € (R14) | Aplicació | Depèn d'un llindar polític que canvia i requereix agregar diverses taules |
| Validació de forma per donar millor missatge a l'usuari | Totes dues | L'aplicació per experiència d'ús, la base per garantia |
La duplicació deliberada
L'última fila incomoda molta gent: no és duplicar feina? No, perquè compleixen funcions diferents:
- L'aplicació valida per donar una bona experiència: un missatge clar al costat del camp, abans d'enviar el formulari, en l'idioma de l'usuari.
- La base valida per donar una garantia: res no hi entra malament, vingui d'on vingui.
La primera es pot saltar; la segona, no. Duplicar la validació de format als dos llocs és correcte i esperable. El que no és correcte és tenir-la només a l'aplicació.
La regla que resumeix l'apartat: si la dada incorrecta causaria un problema encara que ningú no la mirés mai per pantalla, la regla va a la base.
- Entregable: l'esquema definitiu de BiblioRed ampliat
Aquí hi ha el resultat del mòdul 4 complet. És la migració V005, que revisa els tipus provisionals de 04-03 i hi afegeix el blindatge.
-- =====================================================================
-- BiblioRed · Migració V005: tipus definitius i restriccions
-- Tanca el mòdul 4. Referències R1..R14 i RN1..RN10 del document v1.0
-- =====================================================================
-- ---------------------------------------------------------------------
-- 0 · Dominis reutilitzables
-- ---------------------------------------------------------------------
CREATE DOMAIN dom_email AS TEXT
CHECK (VALUE ~ '^[^@[:space:]]+@[^@[:space:]]+\.[A-Za-z]{2,}$');
CREATE DOMAIN dom_import_eur AS NUMERIC(8,2)
CHECK (VALUE >= 0); -- RN5
CREATE DOMAIN dom_idioma AS VARCHAR(5)
CHECK (VALUE ~ '^[a-z]{2}(-[A-Z]{2})?$');
CREATE DOMAIN dom_codi_postal AS CHAR(5)
CHECK (VALUE ~ '^[0-9]{5}$');
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE EXTENSION IF NOT EXISTS unaccent;
-- ---------------------------------------------------------------------
-- 1 · Catàleg de materials
-- ---------------------------------------------------------------------
CREATE TABLE materials (
material_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
tipus_material VARCHAR(15) NOT NULL,
titol TEXT NOT NULL,
autor_id INTEGER,
editorial TEXT,
any_publicacio SMALLINT,
idioma dom_idioma NOT NULL,
data_alta DATE NOT NULL DEFAULT CURRENT_DATE,
portada_url TEXT,
CONSTRAINT pk_materials PRIMARY KEY (material_id),
CONSTRAINT uq_materials_id_tipus UNIQUE (material_id, tipus_material),
CONSTRAINT chk_materials_tipus
CHECK (tipus_material IN ('llibre','dvd','revista','audiollibre')),
CONSTRAINT chk_materials_titol CHECK (char_length(titol) BETWEEN 1 AND 300),
CONSTRAINT chk_materials_any CHECK (any_publicacio BETWEEN 1450 AND 2100),
CONSTRAINT fk_materials_autor FOREIGN KEY (autor_id)
REFERENCES autors (autor_id) ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE TABLE materials_llibre (
material_id INTEGER NOT NULL,
tipus_material VARCHAR(15) NOT NULL DEFAULT 'llibre',
isbn VARCHAR(13) COLLATE "C" NOT NULL,
num_pagines SMALLINT,
enquadernacio VARCHAR(20),
CONSTRAINT pk_materials_llibre PRIMARY KEY (material_id),
CONSTRAINT uq_materials_llibre_isbn UNIQUE (isbn), -- RN9
CONSTRAINT chk_materials_llibre_tipus CHECK (tipus_material = 'llibre'),
CONSTRAINT chk_materials_llibre_isbn CHECK (isbn ~ '^[0-9]{13}$'),
CONSTRAINT chk_materials_llibre_pags CHECK (num_pagines IS NULL OR num_pagines > 0),
CONSTRAINT chk_materials_llibre_enq
CHECK (enquadernacio IS NULL
OR enquadernacio IN ('tapa_dura','tapa_tova','espiral','butxaca')),
CONSTRAINT fk_materials_llibre_material FOREIGN KEY (material_id, tipus_material)
REFERENCES materials (material_id, tipus_material)
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE materials_dvd (
material_id INTEGER NOT NULL,
tipus_material VARCHAR(15) NOT NULL DEFAULT 'dvd',
durada_min SMALLINT NOT NULL,
format_video VARCHAR(15),
codi_regio SMALLINT,
CONSTRAINT pk_materials_dvd PRIMARY KEY (material_id),
CONSTRAINT chk_materials_dvd_tipus CHECK (tipus_material = 'dvd'),
CONSTRAINT chk_materials_dvd_dur CHECK (durada_min > 0 AND durada_min < 1000),
CONSTRAINT chk_materials_dvd_reg CHECK (codi_regio IS NULL
OR codi_regio BETWEEN 0 AND 8),
CONSTRAINT fk_materials_dvd_material FOREIGN KEY (material_id, tipus_material)
REFERENCES materials (material_id, tipus_material)
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE materials_revista (
material_id INTEGER NOT NULL,
tipus_material VARCHAR(15) NOT NULL DEFAULT 'revista',
issn VARCHAR(9) COLLATE "C" NOT NULL,
numero VARCHAR(20) NOT NULL,
periodicitat VARCHAR(20),
CONSTRAINT pk_materials_revista PRIMARY KEY (material_id),
CONSTRAINT uq_materials_revista_issn UNIQUE (issn, numero), -- RN9
CONSTRAINT chk_materials_revista_tipus CHECK (tipus_material = 'revista'),
CONSTRAINT chk_materials_revista_issn CHECK (issn ~ '^[0-9]{4}-[0-9]{3}[0-9X]$'),
CONSTRAINT chk_materials_revista_per
CHECK (periodicitat IS NULL
OR periodicitat IN ('diaria','setmanal','quinzenal','mensual',
'bimestral','trimestral','semestral','anual')),
CONSTRAINT fk_materials_revista_material FOREIGN KEY (material_id, tipus_material)
REFERENCES materials (material_id, tipus_material)
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE materials_audiollibre (
material_id INTEGER NOT NULL,
tipus_material VARCHAR(15) NOT NULL DEFAULT 'audiollibre',
durada_min SMALLINT NOT NULL,
narrador TEXT,
format_audio VARCHAR(15),
CONSTRAINT pk_materials_audiollibre PRIMARY KEY (material_id),
CONSTRAINT chk_materials_audio_tipus CHECK (tipus_material = 'audiollibre'),
CONSTRAINT chk_materials_audio_dur CHECK (durada_min > 0),
CONSTRAINT chk_materials_audio_format
CHECK (format_audio IS NULL OR format_audio IN ('mp3','m4b','flac','ogg')),
CONSTRAINT fk_materials_audio_material FOREIGN KEY (material_id, tipus_material)
REFERENCES materials (material_id, tipus_material)
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE subtitols_dvd (
material_id INTEGER NOT NULL,
idioma dom_idioma NOT NULL,
CONSTRAINT pk_subtitols_dvd PRIMARY KEY (material_id, idioma),
CONSTRAINT fk_subtitols_dvd_material FOREIGN KEY (material_id)
REFERENCES materials_dvd (material_id) ON DELETE CASCADE ON UPDATE CASCADE
);
-- ---------------------------------------------------------------------
-- 2 · Socis: telèfons (R12) i adreces de sucursal (R13)
-- ---------------------------------------------------------------------
CREATE TABLE telefons_soci (
soci_id INTEGER NOT NULL,
numero VARCHAR(20) NOT NULL,
tipus VARCHAR(10) NOT NULL DEFAULT 'mobil',
CONSTRAINT pk_telefons_soci PRIMARY KEY (soci_id, numero),
CONSTRAINT chk_telefons_tipus CHECK (tipus IN ('mobil','fix','feina')),
CONSTRAINT chk_telefons_num CHECK (numero ~ '^\+?[0-9 ]{6,20}$'),
CONSTRAINT fk_telefons_soci FOREIGN KEY (soci_id)
REFERENCES socis (soci_id) ON DELETE CASCADE ON UPDATE CASCADE
);
-- R12 (màxim 3 per soci): no expressable en CHECK. Disparador o aplicació.
ALTER TABLE sucursals
ALTER COLUMN adr_codi_postal TYPE dom_codi_postal,
ALTER COLUMN adr_carrer SET NOT NULL,
ALTER COLUMN adr_ciutat SET NOT NULL;
ALTER TABLE socis ALTER COLUMN email TYPE dom_email;
-- ---------------------------------------------------------------------
-- 3 · Sales i esdeveniments (R3, R4, R5)
-- ---------------------------------------------------------------------
CREATE TABLE sales (
sala_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
sucursal_id INTEGER NOT NULL,
nom TEXT NOT NULL,
aforament SMALLINT NOT NULL,
planta SMALLINT NOT NULL DEFAULT 0,
accessible BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT pk_sales PRIMARY KEY (sala_id),
CONSTRAINT uq_sales_sucursal_nom UNIQUE (sucursal_id, nom), -- R3
CONSTRAINT chk_sales_aforament_positiu CHECK (aforament > 0 AND aforament <= 2000),
CONSTRAINT chk_sales_planta CHECK (planta BETWEEN -3 AND 20),
CONSTRAINT chk_sales_nom CHECK (char_length(nom) BETWEEN 1 AND 80),
CONSTRAINT fk_sales_sucursal FOREIGN KEY (sucursal_id)
REFERENCES sucursals (sucursal_id) ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE TABLE tipus_esdeveniment (
tipus_esdeveniment_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
codi VARCHAR(30) COLLATE "C" NOT NULL,
nom TEXT NOT NULL,
descripcio TEXT,
durada_estandard_min SMALLINT,
actiu BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT pk_tipus_esdeveniment PRIMARY KEY (tipus_esdeveniment_id),
CONSTRAINT uq_tipus_esdeveniment_codi UNIQUE (codi),
CONSTRAINT chk_tipus_esdeveniment_codi CHECK (codi ~ '^[a-z][a-z0-9_]{2,29}$'),
CONSTRAINT chk_tipus_esdeveniment_dur
CHECK (durada_estandard_min IS NULL OR durada_estandard_min > 0)
);
CREATE TABLE esdeveniments (
esdeveniment_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
titol TEXT NOT NULL,
descripcio TEXT,
tipus_esdeveniment_id INTEGER NOT NULL,
sala_id INTEGER, -- D4: opcional
inici TIMESTAMPTZ NOT NULL,
fi TIMESTAMPTZ NOT NULL,
places_ofertes SMALLINT NOT NULL,
estat VARCHAR(15) NOT NULL DEFAULT 'programat',
publicat BOOLEAN NOT NULL DEFAULT FALSE,
durada_min INTEGER GENERATED ALWAYS AS
(EXTRACT(EPOCH FROM (fi - inici)) / 60) STORED,
CONSTRAINT pk_esdeveniments PRIMARY KEY (esdeveniment_id),
CONSTRAINT chk_esdeveniments_titol CHECK (char_length(titol) BETWEEN 3 AND 200),
CONSTRAINT chk_esdeveniments_fi_posterior CHECK (fi > inici), -- RN3
CONSTRAINT chk_esdeveniments_places CHECK (places_ofertes >= 0 AND places_ofertes <= 2000),
CONSTRAINT chk_esdeveniments_estat
CHECK (estat IN ('programat','obert','complet','celebrat','cancellat')),
CONSTRAINT chk_esdeveniments_publicat -- RN8
CHECK (NOT publicat OR (sala_id IS NOT NULL AND places_ofertes > 0)),
CONSTRAINT fk_esdeveniments_tipus FOREIGN KEY (tipus_esdeveniment_id)
REFERENCES tipus_esdeveniment (tipus_esdeveniment_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_esdeveniments_sala FOREIGN KEY (sala_id)
REFERENCES sales (sala_id) ON DELETE RESTRICT ON UPDATE CASCADE
);
ALTER TABLE esdeveniments ADD CONSTRAINT excl_esdeveniments_encavalcament_sala -- RN4
EXCLUDE USING gist (sala_id WITH =, tstzrange(inici, fi) WITH &&)
WHERE (estat <> 'cancellat' AND sala_id IS NOT NULL);
CREATE TABLE ponents (
ponent_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
nom TEXT NOT NULL,
cognoms TEXT NOT NULL,
email dom_email,
biografia TEXT,
extern BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT pk_ponents PRIMARY KEY (ponent_id),
CONSTRAINT uq_ponents_email UNIQUE (email), -- diversos NULL permesos
CONSTRAINT chk_ponents_nom CHECK (char_length(nom) > 0
AND char_length(cognoms) > 0)
);
-- ---------------------------------------------------------------------
-- 4 · Inscripcions, informes, participacions i materials de l'esdeveniment
-- ---------------------------------------------------------------------
CREATE TABLE inscripcions (
esdeveniment_id INTEGER NOT NULL,
soci_id INTEGER NOT NULL,
data_inscripcio TIMESTAMPTZ NOT NULL DEFAULT now(),
estat VARCHAR(15) NOT NULL DEFAULT 'confirmada',
acompanyants SMALLINT NOT NULL DEFAULT 0,
places_ocupades SMALLINT GENERATED ALWAYS AS (1 + acompanyants) STORED,
CONSTRAINT pk_inscripcions PRIMARY KEY (esdeveniment_id, soci_id), -- R6
CONSTRAINT chk_inscripcions_acompanyants CHECK (acompanyants BETWEEN 0 AND 3),
CONSTRAINT chk_inscripcions_estat
CHECK (estat IN ('confirmada','llista_espera','cancellada','assistida')),
CONSTRAINT fk_inscripcions_esdeveniment FOREIGN KEY (esdeveniment_id)
REFERENCES esdeveniments (esdeveniment_id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT fk_inscripcions_soci FOREIGN KEY (soci_id)
REFERENCES socis (soci_id) ON DELETE RESTRICT ON UPDATE CASCADE
);
-- RN2 (aforament), RN6 (soci actiu) i RN7 (data < inici): disparador o aplicació.
CREATE TABLE informes_esdeveniment (
esdeveniment_id INTEGER NOT NULL,
assistents_reals SMALLINT NOT NULL,
valoracio_mitjana NUMERIC(3,2),
observacions TEXT,
respostes_enquesta JSONB,
data_redaccio DATE NOT NULL DEFAULT CURRENT_DATE,
CONSTRAINT pk_informes_esdeveniment PRIMARY KEY (esdeveniment_id), -- R9, 1:1
CONSTRAINT chk_informes_assistents CHECK (assistents_reals >= 0),
CONSTRAINT chk_informes_valoracio
CHECK (valoracio_mitjana IS NULL OR valoracio_mitjana BETWEEN 0 AND 5),
CONSTRAINT fk_informes_esdeveniment FOREIGN KEY (esdeveniment_id)
REFERENCES esdeveniments (esdeveniment_id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE participacions (
esdeveniment_id INTEGER NOT NULL,
ponent_id INTEGER NOT NULL,
rol VARCHAR(25) NOT NULL,
honoraris dom_import_eur NOT NULL DEFAULT 0,
CONSTRAINT pk_participacions PRIMARY KEY (esdeveniment_id, ponent_id, rol), -- R7
CONSTRAINT chk_participacions_rol
CHECK (rol IN ('moderador','tallerista','autor_convidat','presentador')),
CONSTRAINT fk_participacions_esdeveniment FOREIGN KEY (esdeveniment_id)
REFERENCES esdeveniments (esdeveniment_id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT fk_participacions_ponent FOREIGN KEY (ponent_id)
REFERENCES ponents (ponent_id) ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE TABLE esdeveniments_materials (
esdeveniment_id INTEGER NOT NULL,
material_id INTEGER NOT NULL,
paper VARCHAR(15) NOT NULL DEFAULT 'recomanat',
CONSTRAINT pk_esdeveniments_materials PRIMARY KEY (esdeveniment_id, material_id), -- R8
CONSTRAINT chk_esdeveniments_materials_paper
CHECK (paper IN ('principal','recomanat')),
CONSTRAINT fk_esdeveniments_materials_esdeveniment FOREIGN KEY (esdeveniment_id)
REFERENCES esdeveniments (esdeveniment_id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT fk_esdeveniments_materials_material FOREIGN KEY (material_id)
REFERENCES materials (material_id) ON DELETE CASCADE ON UPDATE CASCADE
);
-- ---------------------------------------------------------------------
-- 5 · Multes i pagaments (R10, R11)
-- ---------------------------------------------------------------------
CREATE TABLE multes (
multa_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
soci_id INTEGER NOT NULL,
prestec_id INTEGER, -- D6: opcional
motiu VARCHAR(15) NOT NULL,
import dom_import_eur NOT NULL, -- RN5
data_emissio DATE NOT NULL DEFAULT CURRENT_DATE,
estat VARCHAR(15) NOT NULL DEFAULT 'pendent',
CONSTRAINT pk_multes PRIMARY KEY (multa_id),
CONSTRAINT uq_multes_prestec_motiu -- R10
UNIQUE NULLS NOT DISTINCT (prestec_id, motiu),
CONSTRAINT chk_multes_motiu CHECK (motiu IN ('retard','deteriorament','perdua')),
CONSTRAINT chk_multes_estat
CHECK (estat IN ('pendent','pagada','condonada','anullada')),
CONSTRAINT chk_multes_import_max CHECK (import <= 500),
CONSTRAINT fk_multes_soci FOREIGN KEY (soci_id)
REFERENCES socis (soci_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_multes_prestec FOREIGN KEY (prestec_id)
REFERENCES prestecs (prestec_id) ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE TABLE pagaments (
pagament_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
multa_id INTEGER NOT NULL,
data_pagament TIMESTAMPTZ NOT NULL DEFAULT now(),
import dom_import_eur NOT NULL,
metode VARCHAR(15) NOT NULL,
referencia VARCHAR(50),
CONSTRAINT pk_pagaments PRIMARY KEY (pagament_id),
CONSTRAINT chk_pagaments_import CHECK (import > 0),
CONSTRAINT chk_pagaments_metode CHECK (metode IN ('efectiu','targeta','passarella')),
CONSTRAINT chk_pagaments_referencia
CHECK (metode = 'efectiu' OR referencia IS NOT NULL),
CONSTRAINT fk_pagaments_multa FOREIGN KEY (multa_id)
REFERENCES multes (multa_id) ON DELETE RESTRICT ON UPDATE CASCADE
);
-- RN5 (suma de pagaments <= import de la multa): disparador o aplicació.
-- ---------------------------------------------------------------------
-- 6 · Blindatge de les taules preexistents
-- ---------------------------------------------------------------------
ALTER TABLE prestecs
ALTER COLUMN recarrec TYPE NUMERIC(6,2),
ALTER COLUMN recarrec SET DEFAULT 0,
ADD CONSTRAINT chk_prestecs_recarrec CHECK (recarrec >= 0) NOT VALID,
ADD CONSTRAINT chk_prestecs_devolucio
CHECK (data_devolucio IS NULL OR data_devolucio >= data_prestec) NOT VALID,
ADD CONSTRAINT chk_prestecs_prevista
CHECK (data_devolucio_prevista > data_prestec) NOT VALID;
ALTER TABLE prestecs VALIDATE CONSTRAINT chk_prestecs_recarrec;
ALTER TABLE prestecs VALIDATE CONSTRAINT chk_prestecs_devolucio;
ALTER TABLE prestecs VALIDATE CONSTRAINT chk_prestecs_prevista;
ALTER TABLE exemplars
ALTER COLUMN num_exemplar TYPE SMALLINT,
ALTER COLUMN codi TYPE VARCHAR(15) COLLATE "C",
ADD CONSTRAINT chk_exemplars_estat
CHECK (estat IN ('disponible','prestat','reservat','reparacio','baixa')),
ADD CONSTRAINT chk_exemplars_num CHECK (num_exemplar > 0),
ADD CONSTRAINT chk_exemplars_codi CHECK (codi ~ '^EJ-[0-9]{4,6}$');
ALTER TABLE reserves
ADD CONSTRAINT chk_reserves_estat
CHECK (estat IN ('activa','disponible','recollida','expirada','cancellada')),
ADD CONSTRAINT chk_reserves_expiracio CHECK (data_expiracio > data_reserva);
-- ---------------------------------------------------------------------
-- 7 · Documentació al catàleg
-- ---------------------------------------------------------------------
COMMENT ON TABLE materials IS
'Superclasse de la jerarquia de catàleg (R1). Estratègia: taula per subclasse.';
COMMENT ON COLUMN multes.import IS
'Import en euros congelat en el moment de l''emissió (R10). No recalcular.';
COMMENT ON COLUMN prestecs.recarrec IS
'OBSOLETA. Històric anterior a l''ampliació; la veritat viu a multes.import.';
COMMENT ON CONSTRAINT excl_esdeveniments_encavalcament_sala ON esdeveniments IS
'RN4: dos esdeveniments no cancel·lats no es poden encavalcar a la mateixa sala.';Estat final de les deu regles de negoci
| Regla | On queda garantida |
|---|---|
| RN1 places ≤ aforament de la sala | Disparador o aplicació (referència a una altra taula) |
| RN2 inscripcions ≤ places ofertes | Disparador o aplicació (agregat amb bloqueig) |
| RN3 fi > inici | chk_esdeveniments_fi_posterior |
| RN4 sense encavalcament d'esdeveniments en una sala | excl_esdeveniments_encavalcament_sala |
| RN5 import ≥ 0 | dom_import_eur; la suma de pagaments ≤ import, en disparador |
| RN6 només socis actius | Aplicació |
| RN7 inscripció anterior a l'inici | Disparador o aplicació |
| RN8 esdeveniment publicat amb sala i places | chk_esdeveniments_publicat |
| RN9 ISBN i ISSN+número únics | uq_materials_llibre_isbn, uq_materials_revista_issn |
| RN10 no reservar material sense exemplars | Disparador o aplicació |
Cinc de deu a l'esquema, amb garantia absoluta. Les altres cinc requereixen consultar altres taules o agregar files, i queden documentades amb la seva ubicació decidida. Això també és disseny: saber exactament on viu cada regla i per què.
Errors Habituals i Consells
Desar diners en REAL o DOUBLE PRECISION. L'error més car d'aquesta lliçó i el més freqüent en projectes reals. Els imports no quadren, les sumes fallen per cèntims i ningú no en troba el motiu durant setmanes. NUMERIC, sempre.
Fer servir TIMESTAMP sense fus horari "perquè només operem a Catalunya". N'hi ha prou amb un servidor en un altre fus, un contenidor amb UTC per defecte o un canvi d'hora perquè l'agenda d'esdeveniments deixi de ser fiable. TIMESTAMPTZ per defecte.
Desar dates com a text. '14/05/2026' ordena malament, no permet restar, no valida i depèn del format regional. Una data és un DATE.
Posar VARCHAR(n) amb una n inventada. L'error apareix en producció amb el primer valor llarg, i en el pitjor moment. TEXT més un CHECK de longitud si cal.
Declarar UNIQUE sobre columnes anul·lables sense pensar-hi. Els NULL no es consideren iguals: la unicitat que et pensaves tenir no existeix. És el forat d'uq_multes_prestec_motiu, i es tanca amb NULLS NOT DISTINCT.
Confondre zero amb desconegut. acompanyants = 0 significa que hi va sol; acompanyants IS NULL significa que no ho sabem. Si el negoci no admet el segon cas, declara NOT NULL DEFAULT 0.
Oblidar que CHECK accepta NULL. CHECK (import > 0) no impedeix import IS NULL. Si el valor és obligatori, cal també NOT NULL.
No anomenar les restriccions. El dia que l'error surti en producció a les tres de la matinada, algú haurà d'esbrinar què significa esdeveniments_check2.
Afegir una restricció a una taula gran sense NOT VALID. Bloca la taula durant la verificació completa. Amb NOT VALID la protecció és immediata i la verificació es fa després sense tallar el servei.
Ficar a JSONB el que volia ser una columna. Si la dada es filtra, s'agrega o té regles, és una columna. JSONB és per a estructures genuïnament variables.
Consell: escriu primer la restricció i després intenta violar-la. Un CHECK que mai no has vist fallar pot estar mal escrit. Els missatges d'error d'aquest capítol van sortir tots d'executar l'INSERT que havia de fallar.
Consell: repassa l'esquema columna per columna preguntant "quin valor absurd hi cap, aquí?". Aforament zero, import negatiu, esdeveniment de durada negativa, estat amb errada, codi postal de tres xifres. Cada resposta és una restricció que falta.
Consell: els dominis es paguen sols a partir de la tercera columna igual. Tres correus, quatre imports, cinc codis d'idioma: si repeteixes el mateix CHECK, ja és un domini.
Exercicis
Exercici 1 — Triar el tipus definitiu
Per a cada columna, tria el tipus definitiu i justifica'l en una o dues frases. Indica també si ha de portar NOT NULL i amb quin DEFAULT.
sales.metres_quadrats— superfície de la sala, amb un decimal.esdeveniments.aforament_reduit_covid— percentatge enter d'aforament permès (0-100).pagaments.referencia_passarella— identificador que retorna la passarel·la de l'ajuntament, cadena alfanumèrica de 32 caràcters.socis.data_naixement— per a estadístiques per franja d'edat.materials.vegades_prestat— nombre total de préstecs històrics del material.inscripcions.recordatori_enviat_a— quan es va enviar el recordatori, si es va enviar.
Exercici 2 — Traduir regles de negoci a restriccions
Per a cada regla, decideix si es pot expressar amb una restricció declarativa. Si és que sí, escriu l'ALTER TABLE ... ADD CONSTRAINT amb nom; si és que no, explica per què i on hauria de viure.
- La valoració mitjana d'un informe és entre 0 i 5.
- Un esdeveniment cancel·lat no pot estar publicat.
- Un exemplar en estat
'baixa'no es pot prestar. - El codi d'un exemplar comença sempre per
EJ-. - Una sala no pot tenir dos esdeveniments encavalcats.
- Un soci no pot tenir més de tres telèfons.
- Els honoraris d'un ponent no extern són sempre 0.
Exercici 3 — Diagnòstic d'un esquema mal tipat
Un equip extern lliura aquesta taula per gestionar les quotes anuals dels socis. Localitza almenys set problemes de tipus o de restricció, explica el dany concret a BiblioRed i escriu la versió corregida.
CREATE TABLE quotes (
id VARCHAR(50),
soci INTEGER,
exercici VARCHAR(4),
import FLOAT,
pagada CHAR(1) DEFAULT 'N',
data_pagament VARCHAR(20),
metode VARCHAR(50),
descompte_pct FLOAT,
observacions CHAR(500)
);Solucions
Solució a l'Exercici 1
| # | Columna | Tipus definitiu | Justificació |
|---|---|---|---|
| 1 | metres_quadrats |
NUMERIC(6,1), nul permès |
És una mesura que es mostra i de vegades se suma en informes de patrimoni; NUMERIC evita sorpreses en agregar. Nul permès perquè pot no estar mesurada. CHECK (metres_quadrats > 0) |
| 2 | aforament_reduit_covid |
SMALLINT NOT NULL DEFAULT 100 |
Enter petit de 0 a 100. DEFAULT 100 perquè la situació normal és sense reducció, i NOT NULL perquè "sense dada" no aporta res. CHECK (BETWEEN 0 AND 100) |
| 3 | referencia_passarella |
VARCHAR(32) COLLATE "C", nul permès |
La longitud surt d'una norma externa, així que el topall és legítim. Intercalació "C" perquè és un codi: comparar byte a byte és correcte i ràpid. Nul permès perquè els pagaments en efectiu no en tenen |
| 4 | data_naixement |
DATE, nul permès |
No hi ha hora. Nul permès perquè no és una dada obligatòria per donar-se d'alta. CHECK (data_naixement < CURRENT_DATE) no és vàlid: CURRENT_DATE no és immutable en un CHECK, així que la validació va a l'aplicació |
| 5 | vegades_prestat |
Cap: és un atribut derivat | Es compta sobre prestecs (regla 4 de 04-03). Desar-lo crea la possibilitat que menteixi. Si el rendiment ho exigís, seria desnormalització deliberada amb disparador, i això és 05-04 |
| 6 | recordatori_enviat_a |
TIMESTAMPTZ, nul permès |
Instant d'un fet. El NULL és informatiu: significa "no s'ha enviat", i així l'anti-join WHERE recordatori_enviat_a IS NULL dóna la llista de pendents sense necessitat d'una columna booleana addicional |
La 5 és la resposta clau: la pregunta demanava un tipus i la resposta correcta és que la columna no ha d'existir.
Solució a l'Exercici 2
| # | Regla | Declarativa? | Solució |
|---|---|---|---|
| 1 | Valoració 0-5 | Sí, CHECK d'una columna |
Ja hi és: chk_informes_valoracio |
| 2 | Cancel·lat no publicat | Sí, CHECK de dues columnes de la mateixa fila |
Vegeu més avall |
| 3 | Exemplar de baixa no prestable | No: implica dues taules (exemplars i prestecs) |
Disparador o aplicació. Un CHECK a prestecs no pot llegir exemplars.estat |
| 4 | El codi comença per EJ- |
Sí, CHECK amb expressió regular |
Ja hi és: chk_exemplars_codi |
| 5 | Sense encavalcaments en una sala | Sí, restricció d'exclusió | Ja hi és: excl_esdeveniments_encavalcament_sala |
| 6 | Màxim tres telèfons | No: requereix comptar files de la mateixa taula | Disparador (BEFORE INSERT que compti) o aplicació |
| 7 | Honoraris 0 si no és extern | No directament: extern és a ponents i honoraris a participacions |
Disparador, o desnormalitzar copiant extern a participacions, o comprovar-ho a l'aplicació |
-- 2
ALTER TABLE esdeveniments ADD CONSTRAINT chk_esdeveniments_cancellat_no_publicat
CHECK (estat <> 'cancellat' OR NOT publicat);Verificació:
ERROR: new row for relation "esdeveniments" violates check constraint "chk_esdeveniments_cancellat_no_publicat"
Comentari sobre la 7: la temptació és "doncs copio extern a participacions". És redundància i crea la possibilitat que les dues còpies discrepin. La resposta correcta a la v1.0 és l'aplicació, i anotar-ho al diccionari de dades.
Solució a l'Exercici 3
| # | Problema | Dany concret |
|---|---|---|
| 1 | id VARCHAR(50) sense PRIMARY KEY |
La taula admet duplicats exactes, no es pot referenciar i no es pot actualitzar fila a fila amb seguretat. A més, una clau de text de 50 caràcters per a un comptador és un malbaratament |
| 2 | soci sense FOREIGN KEY ni NOT NULL |
Quotes òrfenes apuntant a socis inexistents (02-06). El nom, a més, incompleix la convenció soci_id |
| 3 | exercici VARCHAR(4) |
No es pot sumar ni comparar per rang amb seguretat, admet 'ahir' i '20226', i ordena malament tan bon punt aparegui un valor d'una altra longitud |
| 4 | import FLOAT |
L'error greu. Coma flotant per a diners: les sumes de recaptació no quadren, la comparació amb l'import esperat falla |
| 5 | pagada CHAR(1) DEFAULT 'N' |
Admet 'S', 's', 'Y', '1', 'X'; no es pot fer servir a WHERE pagada; el CHAR(1) afegeix ompliment |
| 6 | data_pagament VARCHAR(20) |
No ordena, no resta, no valida. La consulta C6 (recaptació per mes) es torna impossible sense conversions |
| 7 | metode VARCHAR(50) sense CHECK |
Conviuran 'Targeta', 'targeta', 'TPV' i 'tarjeta'; agrupar per mètode dóna quatre files per al mateix |
| 8 | descompte_pct FLOAT sense rang |
Descomptes del 500 % o negatius |
| 9 | observacions CHAR(500) |
Omple amb espais fins a 500 caràcters cada fila |
| 10 | Falta UNIQUE (soci_id, exercici) |
Un soci pot tenir quinze quotes del mateix any |
| 11 | Cap restricció anomenada | Errors il·legibles en producció |
| 12 | Sense NOT NULL enlloc |
Quotes sense soci, sense any i sense import |
Versió corregida:
CREATE TABLE quotes (
quota_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
soci_id INTEGER NOT NULL,
exercici SMALLINT NOT NULL,
import dom_import_eur NOT NULL,
descompte_pct SMALLINT NOT NULL DEFAULT 0,
pagada BOOLEAN NOT NULL DEFAULT FALSE,
data_pagament DATE,
metode VARCHAR(15),
observacions TEXT,
import_final NUMERIC(8,2) GENERATED ALWAYS AS
(ROUND(import * (100 - descompte_pct) / 100.0, 2)) STORED,
CONSTRAINT pk_quotes PRIMARY KEY (quota_id),
CONSTRAINT uq_quotes_soci_exercici UNIQUE (soci_id, exercici),
CONSTRAINT chk_quotes_exercici CHECK (exercici BETWEEN 2000 AND 2100),
CONSTRAINT chk_quotes_descompte CHECK (descompte_pct BETWEEN 0 AND 100),
CONSTRAINT chk_quotes_metode
CHECK (metode IS NULL OR metode IN ('efectiu','targeta','passarella','domiciliacio')),
CONSTRAINT chk_quotes_coherencia_pagament
CHECK ((pagada AND data_pagament IS NOT NULL AND metode IS NOT NULL)
OR (NOT pagada AND data_pagament IS NULL AND metode IS NULL)),
CONSTRAINT fk_quotes_soci FOREIGN KEY (soci_id)
REFERENCES socis (soci_id) ON DELETE RESTRICT ON UPDATE CASCADE
);Les dues millores que van més enllà de corregir tipus són chk_quotes_coherencia_pagament —que impedeix l'estat incoherent "pagada sense data de pagament", un CHECK de tres columnes— i la columna generada import_final, que garanteix que el descompte aplicat mai no es pugui desincronitzar de l'import base.
Conclusió
Aquesta lliçó ha convertit un esquema estructuralment correcte en un esquema que es defensa sol, i amb ella es tanca el mòdul 4.
Sobre els tipus:
- El tipus decideix quatre coses alhora: quins valors hi caben, quines operacions tenen sentit, com s'ordena i quant costa. Un tipus mal triat no dóna error: dóna resultats incorrectes en silenci.
- Enters:
SMALLINTdocumenta la intenció,INTEGERva sobrat per a tot el que genera una persona,BIGINTper al que genera una màquina. Migrar d'INTEGERaBIGINTen una taula gran i referenciada és de les pitjors migracions que existeixen. NUMERICenfront de la coma flotant és l'apartat que cal recordar:3.10 + 2.20 + 4.30dóna9.600000000000001enDOUBLE PRECISION, i per això una multa pagada íntegrament pot aparèixer com a impagada. Tot el que són diners va enNUMERIC.- Text:
CHAR(n)gairebé mai;VARCHAR(n)només quannsurt d'una norma externa (ISBN, ISSN, codi postal);TEXTper a tota la resta, ambCHECKde longitud si el negoci vol un topall. - Dates:
TIMESTAMPTZper defecte per als instants de fets, perquè desa el moment absolut i sobreviu als fusos horaris i al canvi d'hora;DATEquan l'hora no existeix en el domini;INTERVALper calcular venciments. BOOLEANen lloc de'S'/'N', iNOT NULLquan el tercer valor no significa res.UUIDnomés si els identificadors viatgen per URL públiques o hi ha generació distribuïda; per a BiblioRed, enter seqüencial, i si cal exposar alguna cosa, un identificador públic addicional.- Conjunts tancats: taula de catàleg si els gestiona l'usuari,
CHECKsi els gestiona el desenvolupador,ENUMnomés si són immutables de debò. JSONBi arrays són escapatòries legítimes per a estructures genuïnament variables —les enquestes dels esdeveniments—, no un lloc on ficar columnes sense dissenyar.BYTEA: les portades no van a la base; van en emmagatzematge d'objectes i a la base la seva ruta i el seu hash.- Codificació i intercalació: UTF-8 sense discussió, i la intercalació decideix si "Àngels" s'ordena on una persona espera. Els codis porten intercalació
"C"; el text de catàleg,es-ES-x-icuambunaccentper cercar. - SQLite fa servir afinitat de tipus: accepta text en una columna
INTEGER. AmbSTRICT,PRAGMA foreign_keys = ONi els imports en cèntims enters, és una eina excel·lent; sense això, és una trampa.
Sobre les restriccions:
NOT NULLper defecte, i treure'l només on puguis anomenar la fila legítima que l'incompleix. La distinció entre zero i desconegut és l'error més comú.DEFAULTviu a la base perquè el respectin totes les vies d'escriptura, no només l'aplicació web. No rescata unNULLexplícit.UNIQUEsobre columnes anul·lables no garanteix el que sembla: diversosNULLhi conviuen, i això va obrir un forat real auq_multes_prestec_motiuque es va tancar ambNULLS NOT DISTINCT.CHECKaccepta la fila quan l'expressió dónaTRUEoNULL; només la rebutja ambFALSE. I no pot mirar altres files ni altres taules, cosa que deixa fora cinc de les deu regles de negoci. Per al no encavalcament existeix la restricció d'exclusió, que garanteix RN4 al servidor amb concurrència correcta.- Columnes generades materialitzen derivats de la mateixa fila amb garantia del gestor: no es poden escriure a mà, així que no poden mentir.
- Dominis centralitzen una validació repetida i fan que l'esquema parli l'idioma del negoci.
- Anomenar les restriccions converteix un error de producció il·legible en un diagnòstic immediat, permet que l'aplicació reaccioni al nom i fa possibles les migracions.
NOT VALID+VALIDATE CONSTRAINTés la manera d'afegir integritat a una taula gran sense parar el servei: protegeix immediatament el que és nou i verifica el que és vell després.- El criteri de fons: si la dada incorrecta causaria un problema encara que ningú no la mirés mai per pantalla, la regla va a la base. L'aplicació valida per donar bona experiència; la base valida per donar garantia. Duplicar-ho és correcte; tenir-ho només a l'aplicació, no.
Amb això es tanca el mòdul 4, Disseny d'Esquemes, i l'ampliació de BiblioRed està acabada de cap a cap: vam partir d'una frase de l'ajuntament, la vam convertir en un document de requisits amb catorze punts, deu regles de negoci i dotze consultes; el vam dibuixar com un model conceptual amb vint-i-una entitats i nou decisions raonades; el vam transformar en taules aplicant deu regles mecàniques; i l'hem blindat amb tipus triats un a un i restriccions que fan impossibles els estats invàlids. L'esquema que tenim ara no és el que hauríem escrit el primer dia obrint l'editor, i aquesta diferència és exactament el que ensenya aquest mòdul. Al mòdul 5, Normalització, sotmetem aquest esquema a un examen que fins ara hem evitat deliberadament: deixem la intuïció d'"una cosa, un lloc" i passem a l'instrumental formal —dependències funcionals, primera, segona i tercera forma normal, Boyce-Codd— per comprovar amb matemàtiques si el disseny de BiblioRed aguanta, corregir el que no aguanti, i entendre després per què de vegades convé trencar aquestes regles a propòsit.
Fonaments de Bases de Dades
Mòdul 1: Introducció a les Bases de Dades
- Conceptes Bàsics de Bases de Dades
- Tipus de Bases de Dades
- Història i Evolució de les Bases de Dades
- Sistemes Gestors de Bases de Dades i Arquitectura
Mòdul 2: Bases de Dades Relacionals
- Model Relacional
- Llenguatge SQL
- Operacions Bàsiques en SQL
- Consultes Multitaula: JOIN i Subconsultes
- Agregació i Agrupació de Dades
- Integritat Referencial
Mòdul 3: Bases de Dades No Relacionals
- Introducció a NoSQL
- Tipus de Bases de Dades NoSQL
- Modelatge de Dades en NoSQL
- Comparació entre Bases de Dades Relacionals i No Relacionals
Mòdul 4: Disseny d'Esquemes
- Principis de Disseny d'Esquemes
- Diagrames Entitat-Relació (ER)
- Transformació de Diagrames ER a Esquemes Relacionals
- Tipus de Dades i Restriccions
Mòdul 5: Normalització
Mòdul 6: Transaccions, Rendiment i Seguretat
- Transaccions i Propietats ACID
- Concurrència i Nivells d'Aïllament
- Índexs i Optimització de Consultes
- Seguretat, Permisos i Còpies de Seguretat
Mòdul 7: Exercicis Pràctics
- Exercicis de SQL
- Exercicis de Disseny d'Esquemes
- Exercicis de Normalització
- Exercicis de Consultes Avançades i Transaccions
Mòdul 8: Casos d'Estudi
- Cas d'Estudi: Base de Dades Relacional
- Cas d'Estudi: Base de Dades No Relacional
- Cas d'Estudi: Persistència Poliglota
