A la lliçó anterior consultaves un esquema que ja existia. Aquí no hi ha esquema: hi ha un client que t'explica el que necessita, amb el desordre amb què ho expliquen els clients, i la teva feina és convertir aquell relat en taules, claus i restriccions que no es puguin trencar.
Com treballar aquesta lliçó. Cada exercici és un cas complet, no una pregunta d'un minut. Llegeix l'enunciat sencer, agafa paper —o un editor de text— i fes els quatre passos pel teu compte abans de mirar res:
- Identificar entitats i relacions: subratlla els substantius de l'enunciat, decideix quins són entitats i quins atributs, i anota la cardinalitat de cada relació.
- Dibuixar el diagrama ER amb notació de pota de gall (pots fer servir
mermaid, paper o qualsevol eina). - Escriure el
CREATE TABLEaplicant les deu regles de transformació de la lliçó 04-03, amb les seves claus foranes i les seves accionsON DELETE/ON UPDATE. - Afegir les restriccions —
NOT NULL,UNIQUE,CHECK,DEFAULT, dominis,EXCLUDE— que codifiquen les regles de negoci de l'enunciat.
Només després compara amb la solució proposada. És normal que el teu esquema no sigui idèntic: en disseny gairebé mai no hi ha una única resposta correcta, sinó respostes defensables i respostes que trenquen un requisit. Per això cada solució acaba amb un apartat de decisions discutibles, on s'explica quines alternatives també serien vàlides i què es guanya i es perd amb cadascuna. I al final de la lliçó hi ha una rúbrica d'autoavaluació amb la qual pots puntuar el teu propi disseny.
En aquesta lliçó no es fa anàlisi formal de dependències funcionals ni se citen formes normals: això és exactament la matèria de 07-03. Aquí dissenyem bé des del principi; allà diagnostiquem i arreglem dissenys que ja han sortit malament.
Abans de Començar
No necessites cap joc de dades carregat: quatre dels cinc casos són dominis nous, i el cinquè es recolza en l'esquema de BiblioRed que ja coneixes. Sí que necessites tenir a mà:
- Les deu regles de transformació ER → relacional (lliçó 04-03), en especial les de relacions N:M, entitats febles, jerarquies de generalització i relacions ternàries.
- El catàleg de restriccions de la lliçó 04-04:
CHECK,UNIQUEsobre diverses columnes,UNIQUEparcial mitjançant índex, dominis (CREATE DOMAIN), columnes generades iEXCLUDEambbtree_gist. - Una sessió
psqlper executar els teusCREATE TABLE. No donis per bo un esquema que no has executat: la meitat dels errors de disseny els detecta el mateix motor en crear les taules.
Per a l'exercici 2 convé tenir present l'esquema de BiblioRed, en particular sucursals, materials, exemplars, socis i prestecs.
Si penses fer servir restriccions EXCLUDE, activa l'extensió una sola vegada per base de dades:
SQLite. No admet CREATE DOMAIN, ni EXCLUDE, ni ALTER TABLE ADD CONSTRAINT, i només aplica les claus foranes si actives PRAGMA foreign_keys = ON. Els CHECK sí que funcionen. Allà on una solució faci servir una cosa exclusiva de PostgreSQL, s'indica l'alternativa portable.
Contingut
- Exercici 1 — Bàsic: videoclub de barri
- Exercici 2 — Intermedi: préstec interbibliotecari a BiblioRed
- Exercici 3 — Intermedi: plataforma de cursos en línia
- Exercici 4 — Avançat: taller mecànic amb jerarquia i relació ternària
- Exercici 5 — Avançat: tarifes amb vigència temporal
- Errors habituals i consells
- Exercicis de reforç
- Rúbrica d'autoavaluació
Exercici 1: Videoclub de barri
Dificultat: Bàsic
Enunciat. «Cinema Vallmar» és un videoclub que sobreviu llogant pel·lícules en format físic. El seu propietari t'explica el següent:
«Tinc unes 3.000 pel·lícules. De cadascuna en guardo el títol, l'any, la durada en minuts i la classificació per edats. Cada pel·lícula és d'un gènere —drama, comèdia, documental...— encara que n'hi ha algunes que són de dos, i m'agradaria poder buscar-les pels dos. De cada pel·lícula tinc entre una i sis còpies físiques; cada còpia té una etiqueta enganxada amb un codi, un format (DVD o Blu-ray) i un estat, perquè algunes estan ratllades i no les llogo. Els clients es fan socis amb nom, telèfon i correu; el correu no es pot repetir. Quan algú lloga una còpia apunto la data, la data de devolució prevista i, quan la porta, la real. Un mateix soci pot tenir diverses còpies llogades alhora, però una còpia només pot estar llogada a una persona. També m'agradaria poder buscar per actor: cada pel·lícula té diversos actors i cada actor surt en diverses pel·lícules, i m'interessa saber quin personatge interpretava.»
Necessita poder respondre: quines còpies d'una pel·lícula estan disponibles ara mateix, què té llogat un soci, quines pel·lícules hi ha d'un gènere, en quines pel·lícules ha treballat un actor i quins lloguers estan vençuts.
Pista. Hi ha dues relacions N:M a l'enunciat, i una d'elles té un atribut propi.
Solució
Pas 1 — Entitats i relacions
| Entitat | Justificació |
|---|---|
pellicules |
Té atributs propis i es referencia des de diversos llocs |
generes |
Un gènere és una entitat, no un text lliure: cal buscar-hi |
actors |
Té identitat pròpia i es repeteix entre pel·lícules |
copies |
L'objecte físic que es lloga; no és el mateix que la pel·lícula |
socis |
Clients |
lloguers |
El fet que una còpia surti de la botiga |
Relacions:
pelliculesN:Mgeneres→ taula intermèdiapellicules_generes.pelliculesN:Mactors, amb atributpersonatge→ taula intermèdiarepartiment.pellicules1:Ncopies(una pel·lícula té entre 1 i 6 còpies).socis1:Nlloguers,copies1:Nlloguers.
La distinció clau del cas és pel·lícula enfront de còpia. La pel·lícula és l'obra; la còpia és el disc de plàstic amb una etiqueta. El soci no lloga «Casablanca»: lloga la còpia CV-0412. Confondre-les és l'error de disseny més freqüent en aquest domini, i és exactament la mateixa distinció que hi ha a BiblioRed entre materials i exemplars.
Pas 2 — Diagrama ER
erDiagram
PELLICULES ||--o{ COPIES : "existeix en"
PELLICULES ||--o{ PELLICULES_GENERES : ""
GENERES ||--o{ PELLICULES_GENERES : ""
PELLICULES ||--o{ REPARTIMENT : ""
ACTORS ||--o{ REPARTIMENT : ""
COPIES ||--o{ LLOGUERS : "es lloga en"
SOCIS ||--o{ LLOGUERS : "fa"
PELLICULES {
int pellicula_id PK
text titol
int any_
int durada_min
text classificacio
}
GENERES {
int genere_id PK
text nom UK
}
PELLICULES_GENERES {
int pellicula_id PK_FK
int genere_id PK_FK
}
ACTORS {
int actor_id PK
text nom
text cognoms
}
REPARTIMENT {
int pellicula_id PK_FK
int actor_id PK_FK
text personatge PK
}
COPIES {
int copia_id PK
text codi UK
int pellicula_id FK
text format
text estat
}
SOCIS {
int soci_id PK
text nom
text email UK
text telefon
bool actiu
}
LLOGUERS {
int lloguer_id PK
int copia_id FK
int soci_id FK
date data_lloguer
date data_prevista
date data_devolucio
}
Pas 3 — CREATE TABLE
CREATE TABLE generes (
genere_id SERIAL PRIMARY KEY,
nom VARCHAR(40) NOT NULL UNIQUE
);
CREATE TABLE pellicules (
pellicula_id SERIAL PRIMARY KEY,
titol VARCHAR(200) NOT NULL,
any_ SMALLINT NOT NULL,
durada_min SMALLINT NOT NULL,
classificacio VARCHAR(5) NOT NULL
);
CREATE TABLE pellicules_generes (
pellicula_id INTEGER NOT NULL REFERENCES pellicules(pellicula_id) ON DELETE CASCADE,
genere_id INTEGER NOT NULL REFERENCES generes(genere_id) ON DELETE RESTRICT,
PRIMARY KEY (pellicula_id, genere_id)
);
CREATE TABLE actors (
actor_id SERIAL PRIMARY KEY,
nom VARCHAR(60) NOT NULL,
cognoms VARCHAR(80) NOT NULL
);
CREATE TABLE repartiment (
pellicula_id INTEGER NOT NULL REFERENCES pellicules(pellicula_id) ON DELETE CASCADE,
actor_id INTEGER NOT NULL REFERENCES actors(actor_id) ON DELETE RESTRICT,
personatge VARCHAR(80) NOT NULL,
PRIMARY KEY (pellicula_id, actor_id, personatge)
);
CREATE TABLE copies (
copia_id SERIAL PRIMARY KEY,
codi VARCHAR(12) NOT NULL UNIQUE,
pellicula_id INTEGER NOT NULL REFERENCES pellicules(pellicula_id) ON DELETE RESTRICT,
format VARCHAR(10) NOT NULL,
estat VARCHAR(12) NOT NULL DEFAULT 'disponible'
);
CREATE TABLE socis (
soci_id SERIAL PRIMARY KEY,
nom VARCHAR(100) NOT NULL,
email VARCHAR(120) NOT NULL UNIQUE,
telefon VARCHAR(15),
actiu BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE lloguers (
lloguer_id SERIAL PRIMARY KEY,
copia_id INTEGER NOT NULL REFERENCES copies(copia_id) ON DELETE RESTRICT,
soci_id INTEGER NOT NULL REFERENCES socis(soci_id) ON DELETE RESTRICT,
data_lloguer DATE NOT NULL DEFAULT CURRENT_DATE,
data_prevista DATE NOT NULL,
data_devolucio DATE
);Pas 4 — Restriccions que codifiquen les regles de negoci
ALTER TABLE pellicules
ADD CONSTRAINT ck_pellicules_any CHECK (any_ BETWEEN 1888 AND 2100),
ADD CONSTRAINT ck_pellicules_durada CHECK (durada_min BETWEEN 1 AND 600),
ADD CONSTRAINT ck_pellicules_classif CHECK (classificacio IN ('TP','7','12','16','18'));
ALTER TABLE copies
ADD CONSTRAINT ck_copies_format CHECK (format IN ('DVD','Blu-ray')),
ADD CONSTRAINT ck_copies_estat CHECK (estat IN ('disponible','llogada','malmesa','retirada'));
ALTER TABLE lloguers
ADD CONSTRAINT ck_llog_prevista CHECK (data_prevista > data_lloguer),
ADD CONSTRAINT ck_llog_devolucio CHECK (data_devolucio IS NULL
OR data_devolucio >= data_lloguer);
-- Regla dura: una còpia no pot estar llogada a dues persones alhora.
-- Un índex UNIQUE PARCIAL sobre els lloguers oberts ho garanteix.
CREATE UNIQUE INDEX uq_lloguer_obert
ON lloguers (copia_id)
WHERE data_devolucio IS NULL;
-- Consulta més freqüent del taulell: còpies disponibles d'una pel·lícula
CREATE INDEX idx_copies_pellicula ON copies (pellicula_id) WHERE estat = 'disponible';Resultat esperat
L'esquema ha de respondre les cinc consultes de l'enunciat. Comprovació ràpida:
| Pregunta del client | Consulta que la respon |
|---|---|
| Còpies disponibles d'una pel·lícula | SELECT ... FROM copies WHERE pellicula_id = ? AND estat='disponible' |
| Què té llogat un soci | lloguers JOIN copies JOIN pellicules WHERE soci_id=? AND data_devolucio IS NULL |
| Pel·lícules d'un gènere | pellicules JOIN pellicules_generes JOIN generes WHERE g.nom=? |
| Pel·lícules d'un actor | repartiment JOIN pellicules WHERE actor_id=? |
| Lloguers vençuts | WHERE data_devolucio IS NULL AND data_prevista < CURRENT_DATE |
Explicació i decisions discutibles
Per què generes és una taula i no una columna de text. El propietari va dir «n'hi ha algunes que són de dos gèneres». Això descarta de ple una columna genere VARCHAR(40) i descarta amb més força encara l'antipatró de la llista amb comes ('drama,comedia'), que ja vam veure a 04-01: fa impossible l'índex, impossible el JOIN i impossible garantir que el text estigui ben escrit. Una taula generes amb UNIQUE (nom) impedeix a més que hi convisquin «Documental», «documental» i «documentals».
La clau primària de repartiment inclou personatge. És una decisió discutible i val la pena entendre per què. Amb PRIMARY KEY (pellicula_id, actor_id), un actor que interpreta dos papers a la mateixa pel·lícula —bessons, dobles papers— seria impossible de registrar. Incloure personatge a la clau ho permet. L'alternativa igualment vàlida és posar una clau substituta repartiment_id SERIAL i un UNIQUE (pellicula_id, actor_id, personatge); l'efecte és el mateix i les claus foranes cap a repartiment queden més curtes si algun dia calen.
Les accions referencials no són totes iguals, i això és deliberat:
| Clau forana | Acció | Motiu |
|---|---|---|
pellicules_generes → pellicules |
ON DELETE CASCADE |
Si desapareix la pel·lícula, la seva classificació per gènere no significa res |
pellicules_generes → generes |
ON DELETE RESTRICT |
Esborrar el gènere «drama» no ha d'esborrar en cascada la seva assignació a 400 pel·lícules: primer cal reclassificar-les |
lloguers → socis |
ON DELETE RESTRICT |
L'històric de lloguers és informació comptable. Un soci que es dóna de baixa es marca actiu = FALSE, no s'esborra |
copies → pellicules |
ON DELETE RESTRICT |
Si hi ha còpies físiques a la prestatgeria, la pel·lícula no pot desaparèixer del catàleg |
L'índex únic parcial és la peça més interessant del disseny. La regla «una còpia només pot estar llogada a una persona» no es pot expressar amb un UNIQUE (copia_id) sobre lloguers, perquè llavors una còpia només es podria llogar una vegada en tota la seva història. El que cal restringir és que hi hagi com a molt un lloguer obert per còpia, i això és exactament el que fa CREATE UNIQUE INDEX ... WHERE data_devolucio IS NULL. És una restricció de PostgreSQL; a SQLite existeix el mateix índex parcial, a MySQL 8 no, i allà caldria resoldre-ho amb un disparador o confiant en l'aplicació (pitjor).
Alternativa raonable que no he pres: una columna copies.estat redundant amb l'existència d'un lloguer obert. L'he mantinguda perquè el propietari distingeix estats que no depenen del lloguer (malmesa, retirada), però això introdueix una possibilitat d'incoherència: una còpia disponible amb un lloguer obert. Si el volum fos més gran, valdria la pena mantenir-la amb un disparador; amb 3.000 pel·lícules, l'aplicació se'n pot encarregar.
Exercici 2: Préstec interbibliotecari a BiblioRed
Dificultat: Intermedi
Enunciat. BiblioRed vol llançar un servei de préstec interbibliotecari. La direcció ho descriu així:
«Si un soci de Sud vol un llibre que només és a Nord, ara s'ha de desplaçar. Volem que el pugui demanar des de la seva sucursal i que l'exemplar viatgi. També volem poder demanar a biblioteques d'altres ciutats amb les quals tenim conveni —tenen nom, ciutat, persona de contacte i correu—, i deixar-los-en a elles. Cada petició la fa un soci en una sucursal, sobre un material concret (no sobre un exemplar: ens és igual quin vingui). La petició passa per estats: sol·licitada, acceptada, en trànsit, disponible per recollir, prestada, retornada, rebutjada o cancel·lada, i de cada canvi d'estat volem saber quan va passar i qui el va fer. Quan la petició s'accepta, se li assigna un exemplar concret. L'enviament té un cost que paga la biblioteca sol·licitant, i les peticions a biblioteques externes tenen un termini màxim diferent de l'intern.»
Restriccions addicionals: una petició es dirigeix o bé a una altra sucursal de BiblioRed o bé a una biblioteca externa, mai a totes dues ni a cap. Un soci no pot tenir més de tres peticions actives simultàniament (aquesta la pots deixar per a l'aplicació, però indica-ho).
Pista. L'historial de canvis d'estat és una entitat feble que depèn de la petició.
Solució
Pas 1 — Entitats i relacions
Entitats noves (les existents de BiblioRed no es toquen):
| Entitat | Tipus | Justificació |
|---|---|---|
biblioteques_externes |
Forta | Té identitat i atributs propis |
peticions_interbib |
Forta | El fet central del servei |
peticions_estats |
Feble | No existeix sense la seva petició; la seva clau inclou la de la petició |
Relacions:
socis1:Npeticions_interbib(qui demana).sucursals1:Npeticions_interbibcom a origen (on ho recull).sucursals1:Npeticions_interbibcom a destí opcional (a qui es demana).biblioteques_externes1:Npeticions_interbibcom a destí opcional.materials1:Npeticions_interbib(què es demana).exemplars1:Npeticions_interbib(quin exemplar s'ha assignat, nul fins a l'acceptació).peticions_interbib1:Npeticions_estats(identificadora, entitat feble).
Pas 2 — Diagrama ER
erDiagram
SOCIS ||--o{ PETICIONS_INTERBIB : sollicita
SUCURSALS ||--o{ PETICIONS_INTERBIB : "origen / desti"
BIBLIOTEQUES_EXTERNES||--o{ PETICIONS_INTERBIB : "desti extern"
MATERIALS ||--o{ PETICIONS_INTERBIB : "es demana"
EXEMPLARS ||--o{ PETICIONS_INTERBIB : "s assigna"
PETICIONS_INTERBIB ||--|{ PETICIONS_ESTATS : "registra"
BIBLIOTEQUES_EXTERNES {
int biblioteca_id PK
text nom
text ciutat
text contacte_nom
text contacte_email
date conveni_des_de
bool activa
}
PETICIONS_INTERBIB {
int peticio_id PK
int soci_id FK
int sucursal_origen_id FK
int sucursal_desti_id FK "nul si es externa"
int biblioteca_externa_id FK "nul si es interna"
int material_id FK
int exemplar_id FK "nul fins a acceptar"
date data_solicitud
date data_limit
numeric cost_enviament
text estat_actual
}
PETICIONS_ESTATS {
int peticio_id PK_FK
int sequencia PK
text estat
timestamp moment
text usuari
text observacions
}
Pas 3 — CREATE TABLE
CREATE TABLE biblioteques_externes (
biblioteca_id SERIAL PRIMARY KEY,
nom VARCHAR(120) NOT NULL,
ciutat VARCHAR(60) NOT NULL,
contacte_nom VARCHAR(100),
contacte_email VARCHAR(120),
conveni_des_de DATE NOT NULL,
activa BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT uq_bibext_nom_ciutat UNIQUE (nom, ciutat)
);
CREATE TABLE peticions_interbib (
peticio_id SERIAL PRIMARY KEY,
soci_id INTEGER NOT NULL REFERENCES socis(soci_id) ON DELETE RESTRICT,
sucursal_origen_id INTEGER NOT NULL REFERENCES sucursals(sucursal_id) ON DELETE RESTRICT,
sucursal_desti_id INTEGER REFERENCES sucursals(sucursal_id) ON DELETE RESTRICT,
biblioteca_externa_id INTEGER REFERENCES biblioteques_externes(biblioteca_id) ON DELETE RESTRICT,
material_id INTEGER NOT NULL REFERENCES materials(material_id) ON DELETE RESTRICT,
exemplar_id INTEGER REFERENCES exemplars(exemplar_id) ON DELETE SET NULL,
data_solicitud DATE NOT NULL DEFAULT CURRENT_DATE,
data_limit DATE NOT NULL,
cost_enviament NUMERIC(6,2) NOT NULL DEFAULT 0,
estat_actual VARCHAR(22) NOT NULL DEFAULT 'sollicitada'
);
-- Entitat feble: la seva clau primària arrossega la de la petició
CREATE TABLE peticions_estats (
peticio_id INTEGER NOT NULL REFERENCES peticions_interbib(peticio_id) ON DELETE CASCADE,
sequencia SMALLINT NOT NULL,
estat VARCHAR(22) NOT NULL,
moment TIMESTAMPTZ NOT NULL DEFAULT now(),
usuari VARCHAR(60) NOT NULL,
observacions TEXT,
PRIMARY KEY (peticio_id, sequencia)
);Pas 4 — Restriccions
-- Un domini reutilitzable per al conjunt d'estats (PostgreSQL)
CREATE DOMAIN estat_interbib AS VARCHAR(22)
CHECK (VALUE IN ('sollicitada','acceptada','en_transit','disponible_recollida',
'prestada','retornada','rebutjada','cancellada'));
ALTER TABLE peticions_interbib
ALTER COLUMN estat_actual TYPE estat_interbib;
ALTER TABLE peticions_estats
ALTER COLUMN estat TYPE estat_interbib;
ALTER TABLE peticions_interbib
-- O destí intern O destí extern, exactament un dels dos
ADD CONSTRAINT ck_pet_desti_exclusiu CHECK (
(sucursal_desti_id IS NOT NULL AND biblioteca_externa_id IS NULL)
OR (sucursal_desti_id IS NULL AND biblioteca_externa_id IS NOT NULL)
),
-- No té sentit demanar-se un material a la mateixa sucursal
ADD CONSTRAINT ck_pet_origen_diferent CHECK (
sucursal_desti_id IS NULL OR sucursal_desti_id <> sucursal_origen_id
),
ADD CONSTRAINT ck_pet_limit CHECK (data_limit > data_solicitud),
ADD CONSTRAINT ck_pet_cost CHECK (cost_enviament >= 0),
-- Només hi pot haver exemplar assignat a partir d'«acceptada»
ADD CONSTRAINT ck_pet_exemplar_estat CHECK (
exemplar_id IS NOT NULL
OR estat_actual IN ('sollicitada','rebutjada','cancellada')
);
-- Un soci no pot demanar dues vegades el mateix material mentre la petició segueixi viva
CREATE UNIQUE INDEX uq_peticio_viva
ON peticions_interbib (soci_id, material_id)
WHERE estat_actual NOT IN ('retornada','rebutjada','cancellada');
CREATE INDEX idx_pet_estat ON peticions_interbib (estat_actual, data_limit);
CREATE INDEX idx_pet_soci ON peticions_interbib (soci_id, data_solicitud DESC);Resultat esperat
Tres taules noves, zero modificacions a les taules existents de BiblioRed. Exemple de càrrega coherent:
INSERT INTO biblioteques_externes (nom, ciutat, contacte_nom, contacte_email, conveni_des_de)
VALUES ('Biblioteca Municipal de Port Alt','Port Alt','Lídia Serna','[email protected]','2025-04-01');
INSERT INTO peticions_interbib
(soci_id, sucursal_origen_id, sucursal_desti_id, material_id, data_limit)
VALUES (16, 3, 2, 904, DATE '2026-08-20'); -- Núria Bastos (Sud) demana a Nord
INSERT INTO peticions_estats (peticio_id, sequencia, estat, usuari)
VALUES (1, 1, 'sollicitada', 'taulell.sud');I la comprovació que la regla del destí exclusiu funciona:
INSERT INTO peticions_interbib
(soci_id, sucursal_origen_id, sucursal_desti_id, biblioteca_externa_id, material_id, data_limit)
VALUES (16, 3, 2, 1, 904, DATE '2026-08-20');
-- ERROR: new row violates check constraint "ck_pet_desti_exclusiu"Explicació i decisions discutibles
Dissenyar sobre un esquema existent canvia les regles del joc. El requisit implícit més fort d'aquest exercici és que no pots trencar res. Per això la solució no afegeix columnes a prestecs ni a exemplars: qualsevol consulta, informe o índex que ja existís a BiblioRed segueix funcionant exactament igual després d'instal·lar el servei.
La petició apunta a materials i no a exemplars, perquè el soci va dir «m'és igual quin vingui». exemplar_id és nul al principi i s'omple en acceptar. Aquell NULL no és una mancança del disseny: és informació («encara no s'ha assignat»), i el CHECK ck_pet_exemplar_estat la lliga a l'estat perquè no hi pugui haver una petició «en trànsit» sense exemplar.
Dues claus foranes cap a la mateixa taula. sucursal_origen_id i sucursal_desti_id apunten totes dues a sucursals. És perfectament legal i molt habitual; l'única cosa que exigeix són noms de columna que diguin el seu paper, perquè sucursal_id a seques seria ambigu. És el mateix patró que ja apareix a BiblioRed entre socis.sucursal_id i exemplars.sucursal_id.
El destí exclusiu: tres alternatives.
| Opció | Com | Avantatge | Inconvenient |
|---|---|---|---|
L'escollida: dues columnes nul·lables + CHECK |
Un sol CHECK amb IS NULL/IS NOT NULL |
Simple, llegible, integritat garantida pel motor | Dues columnes on conceptualment n'hi ha una |
Jerarquia: taula destins amb subtipus |
destins genèrica + destins_sucursal i destins_externa |
Extensible a un tercer tipus de destí | Un JOIN més a tota consulta; sobredimensionat per a dos casos |
Columna genèrica desti_tipus + desti_id |
Una sola clau forana «polimòrfica» | Compacta | Impossible declarar la clau forana. Antipatró; el motor deixa de protegir la integritat |
La tercera és la que sol proposar qui ve d'un ORM i és l'única clarament incorrecta.
L'historial com a entitat feble. peticions_estats té clau primària (peticio_id, sequencia): el número de seqüència només té sentit dins de la seva petició. És el cas de llibre d'entitat feble amb relació identificadora, i per això porta ON DELETE CASCADE: si la petició desapareix, el seu historial no significa res.
Aquí hi ha una redundància deliberada: estat_actual a la capçalera duplica l'últim estat de l'historial. Es podria calcular sempre amb ORDER BY sequencia DESC LIMIT 1, però és la consulta més freqüent del sistema (el panell de safata d'entrada de cada sucursal) i la columna permet indexar-la. És la desnormalització controlada de la lliçó 05-04: s'accepta a canvi d'haver de mantenir les dues coses sincronitzades, idealment amb un disparador.
El límit de tres peticions actives per soci. No es pot expressar amb un CHECK, perquè un CHECK només veu la fila que s'està inserint i aquesta regla implica comptar files de la taula. Les opcions reals són un disparador BEFORE INSERT que compti i llanci excepció, o la lògica d'aplicació dins de la transacció amb un SELECT ... FOR UPDATE sobre el soci. L'important és dir-ho al disseny, no deixar-ho implícit: una regla de negoci sense restricció és una regla que algun dia s'incomplirà.
Exercici 3: Plataforma de cursos en línia
Dificultat: Intermedi
Enunciat. Una plataforma de formació vol la seva base de dades:
«Tenim cursos, i cada curs està dividit en mòduls, i cada mòdul en lliçons. Les lliçons van numerades dins del seu mòdul i els mòduls dins del seu curs. Una lliçó té títol, tipus (vídeo, text o qüestionari) i durada estimada. Els cursos poden tenir requisits previs: per fer «SQL avançat» cal haver fet abans «SQL bàsic», i un curs pot tenir diversos requisits. Els alumnes es matriculen a cursos; de la matrícula en guardem la data, el preu pagat i si està activa, completada o abandonada. Volem saber, de cada alumne i cada lliçó, si l'ha acabada i quan; també el percentatge d'avenç del curs. Les lliçons de tipus qüestionari tenen preguntes amb diverses opcions, de les quals una és la correcta, i cada intent d'un alumne guarda la nota i la data. Un alumne pot intentar un qüestionari diverses vegades.»
Pista. «Requisits previs» és una relació N:M d'una taula amb ella mateixa.
Solució
Pas 1 — Entitats i relacions
Jerarquia de continguts: cursos 1:N moduls 1:N llicons. Els tres són entitats fortes amb clau substituta, però moduls i llicons porten a més una clau alternativa que reflecteix la seva numeració dins del pare.
Autoreferència: cursos N:M cursos a través de requisits_curs (curs_id, requisit_id).
N:M amb atributs: alumnes N:M cursos a través de matricules, que té data, preu i estat propis.
Progrés: matricules N:M llicons a través de progres_llico. Fixa't que el progrés penja de la matrícula, no de l'alumne: si algú es matricula dues vegades al mateix curs, cada matrícula té el seu propi avenç.
Qüestionaris: llicons 1:N preguntes 1:N opcions; matricules 1:N intents (sobre una lliçó de tipus qüestionari).
Pas 2 — Diagrama ER
erDiagram
CURSOS ||--o{ MODULS : conte
MODULS ||--o{ LLICONS : conte
CURSOS ||--o{ REQUISITS_CURS : "exigeix"
CURSOS ||--o{ REQUISITS_CURS : "es requisit de"
ALUMNES ||--o{ MATRICULES : fa
CURSOS ||--o{ MATRICULES : rep
MATRICULES||--o{ PROGRES_LLICO : avanca
LLICONS ||--o{ PROGRES_LLICO : "es completa a"
LLICONS ||--o{ PREGUNTES : "questionari de"
PREGUNTES ||--|{ OPCIONS : ofereix
MATRICULES||--o{ INTENTS : genera
LLICONS ||--o{ INTENTS : "s avalua a"
CURSOS { int curs_id PK
text titol
text nivell
bool publicat }
MODULS { int modul_id PK
int curs_id FK
int ordre
text titol }
LLICONS { int llico_id PK
int modul_id FK
int ordre
text titol
text tipus
int durada_min }
REQUISITS_CURS { int curs_id PK_FK
int requisit_id PK_FK }
ALUMNES { int alumne_id PK
text email UK
text nom }
MATRICULES{ int matricula_id PK
int alumne_id FK
int curs_id FK
date data_matricula
numeric preu_pagat
text estat }
PROGRES_LLICO { int matricula_id PK_FK
int llico_id PK_FK
timestamp completada_en }
PREGUNTES { int pregunta_id PK
int llico_id FK
int ordre
text enunciat }
OPCIONS { int opcio_id PK
int pregunta_id FK
text text_opcio
bool correcta }
INTENTS { int intent_id PK
int matricula_id FK
int llico_id FK
timestamp moment
numeric nota }
Pas 3 — CREATE TABLE
CREATE TABLE cursos (
curs_id SERIAL PRIMARY KEY,
titol VARCHAR(150) NOT NULL,
nivell VARCHAR(15) NOT NULL,
publicat BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE moduls (
modul_id SERIAL PRIMARY KEY,
curs_id INTEGER NOT NULL REFERENCES cursos(curs_id) ON DELETE CASCADE,
ordre SMALLINT NOT NULL,
titol VARCHAR(150) NOT NULL,
CONSTRAINT uq_modul_ordre UNIQUE (curs_id, ordre)
);
CREATE TABLE llicons (
llico_id SERIAL PRIMARY KEY,
modul_id INTEGER NOT NULL REFERENCES moduls(modul_id) ON DELETE CASCADE,
ordre SMALLINT NOT NULL,
titol VARCHAR(150) NOT NULL,
tipus VARCHAR(12) NOT NULL,
durada_min SMALLINT,
CONSTRAINT uq_llico_ordre UNIQUE (modul_id, ordre)
);
CREATE TABLE requisits_curs (
curs_id INTEGER NOT NULL REFERENCES cursos(curs_id) ON DELETE CASCADE,
requisit_id INTEGER NOT NULL REFERENCES cursos(curs_id) ON DELETE RESTRICT,
PRIMARY KEY (curs_id, requisit_id),
CONSTRAINT ck_req_no_reflexiu CHECK (curs_id <> requisit_id)
);
CREATE TABLE alumnes (
alumne_id SERIAL PRIMARY KEY,
email VARCHAR(120) NOT NULL UNIQUE,
nom VARCHAR(100) NOT NULL,
alta DATE NOT NULL DEFAULT CURRENT_DATE
);
CREATE TABLE matricules (
matricula_id SERIAL PRIMARY KEY,
alumne_id INTEGER NOT NULL REFERENCES alumnes(alumne_id) ON DELETE RESTRICT,
curs_id INTEGER NOT NULL REFERENCES cursos(curs_id) ON DELETE RESTRICT,
data_matricula DATE NOT NULL DEFAULT CURRENT_DATE,
preu_pagat NUMERIC(8,2) NOT NULL,
estat VARCHAR(12) NOT NULL DEFAULT 'activa'
);
CREATE TABLE progres_llico (
matricula_id INTEGER NOT NULL REFERENCES matricules(matricula_id) ON DELETE CASCADE,
llico_id INTEGER NOT NULL REFERENCES llicons(llico_id) ON DELETE CASCADE,
completada_en TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (matricula_id, llico_id)
);
CREATE TABLE preguntes (
pregunta_id SERIAL PRIMARY KEY,
llico_id INTEGER NOT NULL REFERENCES llicons(llico_id) ON DELETE CASCADE,
ordre SMALLINT NOT NULL,
enunciat TEXT NOT NULL,
CONSTRAINT uq_pregunta_ordre UNIQUE (llico_id, ordre)
);
CREATE TABLE opcions (
opcio_id SERIAL PRIMARY KEY,
pregunta_id INTEGER NOT NULL REFERENCES preguntes(pregunta_id) ON DELETE CASCADE,
text_opcio TEXT NOT NULL,
correcta BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE intents (
intent_id SERIAL PRIMARY KEY,
matricula_id INTEGER NOT NULL REFERENCES matricules(matricula_id) ON DELETE CASCADE,
llico_id INTEGER NOT NULL REFERENCES llicons(llico_id) ON DELETE CASCADE,
moment TIMESTAMPTZ NOT NULL DEFAULT now(),
nota NUMERIC(5,2) NOT NULL
);Pas 4 — Restriccions
ALTER TABLE cursos
ADD CONSTRAINT ck_cursos_nivell CHECK (nivell IN ('inicial','intermedi','avancat'));
ALTER TABLE llicons
ADD CONSTRAINT ck_llic_tipus CHECK (tipus IN ('video','text','questionari')),
ADD CONSTRAINT ck_llic_dur CHECK (durada_min IS NULL OR durada_min > 0),
ADD CONSTRAINT ck_llic_ordre CHECK (ordre > 0);
ALTER TABLE matricules
ADD CONSTRAINT ck_matr_estat CHECK (estat IN ('activa','completada','abandonada')),
ADD CONSTRAINT ck_matr_preu CHECK (preu_pagat >= 0);
-- Un alumne no pot tenir dues matrícules actives al mateix curs,
-- però sí que es pot rematricular després d'abandonar
CREATE UNIQUE INDEX uq_matricula_activa
ON matricules (alumne_id, curs_id)
WHERE estat = 'activa';
ALTER TABLE intents
ADD CONSTRAINT ck_intent_nota CHECK (nota BETWEEN 0 AND 10);
-- Cada pregunta ha de tenir exactament una opció correcta
CREATE UNIQUE INDEX uq_opcio_correcta
ON opcions (pregunta_id)
WHERE correcta;Resultat esperat
Onze taules. La consulta del percentatge d'avenç, que és la raó de ser de mitja plataforma, surt directa:
SELECT m.matricula_id,
count(pl.llico_id) AS completades,
(SELECT count(*) FROM llicons l
JOIN moduls mo ON mo.modul_id = l.modul_id
WHERE mo.curs_id = m.curs_id) AS totals,
round(100.0 * count(pl.llico_id) /
NULLIF((SELECT count(*) FROM llicons l
JOIN moduls mo ON mo.modul_id = l.modul_id
WHERE mo.curs_id = m.curs_id), 0), 1) AS pct
FROM matricules m
LEFT JOIN progres_llico pl ON pl.matricula_id = m.matricula_id
WHERE m.matricula_id = 1
GROUP BY m.matricula_id, m.curs_id;Explicació i decisions discutibles
El progrés penja de la matrícula, no de l'alumne. És la decisió més important del disseny i la més fàcil d'errar. Si progres_llico tingués (alumne_id, llico_id), un alumne que abandona un curs i es torna a matricular arrossegaria tot el seu avenç anterior, i no hi hauria manera de saber a quina matrícula pertanyia cada lliçó completada. Amb (matricula_id, llico_id), cada intent de fer el curs té la seva pròpia història. El preu és un JOIN més per arribar de l'alumne al progrés; és un preu baix.
La numeració dins del pare: clau substituta + UNIQUE compost. moduls té modul_id com a clau primària i UNIQUE (curs_id, ordre) com a clau alternativa. L'alternativa —clau primària composta (curs_id, ordre)— també és defensable i és més «pura», però té dos inconvenients pràctics: reordenar els mòduls obliga a actualitzar la clau primària (i en cascada totes les lliçons), i les claus foranes cap a llicons acabarien essent de tres columnes. Amb clau substituta, reordenar és un UPDATE de la columna ordre i prou.
L'autoreferència N:M. requisits_curs és una taula de reunió les dues claus foranes de la qual apunten a la mateixa taula. El CHECK (curs_id <> requisit_id) impedeix el cas trivial que un curs sigui requisit d'ell mateix. El que no pot impedir cap restricció declarativa és un cicle més llarg: A exigeix B, B exigeix C, C exigeix A. Detectar això requereix recórrer el graf, i es fa amb una CTE recursiva —la veuràs a 07-04— o amb un disparador que l'executi abans d'inserir.
uq_opcio_correcta garanteix com a molt una de correcta, no exactament una. L'índex únic parcial impedeix dues opcions marcades com a correctes a la mateixa pregunta, però no impedeix que no n'hi hagi cap. Aquella segona meitat de la regla —«almenys una»— és una restricció de conjunt que les bases de dades relacionals no expressen bé de manera declarativa; es resol amb un disparador AFTER o amb una comprovació en publicar el curs. Reconèixer el límit és part del disseny.
Alternativa raonable que no he pres: guardar a intents les respostes concretes de cada pregunta (intents_respostes). L'enunciat només demanava la nota, i afegir aquella taula sense que ningú la demani és sobredisseny. Però si demà volen estadístiques de «quina pregunta falla més gent», caldria, i l'extensió seria neta: (intent_id, pregunta_id, opcio_id).
Exercici 4: Taller mecànic amb jerarquia i relació ternària
Dificultat: Avançat
Enunciat. Un taller mecànic de Vallmar:
«Atenem vehicles: cotxes, motos i furgonetes. De tots en guardem matrícula, marca, model, any i el client propietari. Dels cotxes ens interessa a més el nombre de places i el tipus de combustible; de les motos, la cilindrada; i de les furgonetes, la càrrega màxima en quilos i si tenen tacògraf. Un client pot tenir diversos vehicles i un vehicle té un únic propietari. Quan entra un vehicle obrim una ordre de reparació amb la data d'entrada, el quilometratge i una descripció del problema. En una ordre es fan diverses intervencions; cada intervenció la fa un mecànic concret sobre l'ordre, aplicant un tipus de treball del catàleg (canvi d'oli, alineació, diagnosi...), i anotem les hores dedicades. El mateix mecànic pot fer diversos tipus de treball a la mateixa ordre, i el mateix tipus de treball el poden fer mecànics diferents a la mateixa ordre en dies diferents. També registrem les peces que es fan servir a cada intervenció, amb la quantitat i el preu unitari aplicat aquell dia.»
Pista. «Intervenció» relaciona tres entitats alhora. I una jerarquia de generalització es pot transformar de tres maneres diferents: tria i justifica.
Solució
Pas 1 — Entitats i relacions
La jerarquia: vehicles és la superentitat, amb cotxes, motos i furgonetes com a subentitats. La generalització és total (tot vehicle és d'un dels tres tipus) i disjunta (cap no és dues coses alhora).
La relació ternària: intervencions relaciona ordres × mecanics × tipus_treball. Com que l'enunciat diu explícitament que el mateix mecànic pot repetir tipus de treball a la mateixa ordre en dies diferents, la ternària no es pot identificar per la terna: necessita clau substituta o incloure la data.
peces N:M intervencions amb atributs (quantitat, preu_unitari).
Pas 2 — Diagrama ER
erDiagram
CLIENTS ||--o{ VEHICLES : posseeix
VEHICLES ||--o| COTXES : "es un"
VEHICLES ||--o| MOTOS : "es un"
VEHICLES ||--o| FURGONETES : "es un"
VEHICLES ||--o{ ORDRES : genera
ORDRES ||--o{ INTERVENCIONS : inclou
MECANICS ||--o{ INTERVENCIONS : fa
TIPUS_TREBALL ||--o{ INTERVENCIONS : "s aplica a"
INTERVENCIONS ||--o{ INTERVENCIO_PECES : consumeix
PECES ||--o{ INTERVENCIO_PECES : "es fa servir a"
CLIENTS { int client_id PK
text nom
text nif UK
text telefon }
VEHICLES { int vehicle_id PK
text matricula UK
text tipus
int client_id FK
text marca
text model
int any_ }
COTXES { int vehicle_id PK_FK
int places
text combustible }
MOTOS { int vehicle_id PK_FK
int cilindrada }
FURGONETES { int vehicle_id PK_FK
int carrega_max_kg
bool tacograf }
ORDRES { int ordre_id PK
int vehicle_id FK
date data_entrada
int quilometratge
text descripcio
text estat }
MECANICS { int mecanic_id PK
text nom
text especialitat }
TIPUS_TREBALL { int tipus_treball_id PK
text codi UK
text nom
numeric preu_hora }
INTERVENCIONS { int intervencio_id PK
int ordre_id FK
int mecanic_id FK
int tipus_treball_id FK
date data
numeric hores }
PECES { int peca_id PK
text referencia UK
text descripcio
numeric preu_actual }
INTERVENCIO_PECES { int intervencio_id PK_FK
int peca_id PK_FK
int quantitat
numeric preu_unitari }
Pas 3 — CREATE TABLE
CREATE TABLE clients (
client_id SERIAL PRIMARY KEY,
nom VARCHAR(120) NOT NULL,
nif VARCHAR(12) NOT NULL UNIQUE,
telefon VARCHAR(15)
);
CREATE TABLE vehicles (
vehicle_id SERIAL PRIMARY KEY,
matricula VARCHAR(10) NOT NULL UNIQUE,
tipus VARCHAR(10) NOT NULL, -- discriminant
client_id INTEGER NOT NULL REFERENCES clients(client_id) ON DELETE RESTRICT,
marca VARCHAR(40) NOT NULL,
model VARCHAR(60) NOT NULL,
any_ SMALLINT NOT NULL,
CONSTRAINT ck_veh_tipus CHECK (tipus IN ('cotxe','moto','furgoneta')),
-- Truc perquè la subtaula només pugui enllaçar amb el seu tipus:
CONSTRAINT uq_veh_tipus UNIQUE (vehicle_id, tipus)
);
CREATE TABLE cotxes (
vehicle_id INTEGER PRIMARY KEY,
tipus VARCHAR(10) NOT NULL DEFAULT 'cotxe',
places SMALLINT NOT NULL,
combustible VARCHAR(12) NOT NULL,
CONSTRAINT ck_cotxes_tipus CHECK (tipus = 'cotxe'),
CONSTRAINT fk_cotxes_veh FOREIGN KEY (vehicle_id, tipus)
REFERENCES vehicles (vehicle_id, tipus) ON DELETE CASCADE,
CONSTRAINT ck_cotxes_places CHECK (places BETWEEN 1 AND 9),
CONSTRAINT ck_cotxes_comb CHECK (combustible IN ('gasolina','diesel','hibrid','electric','glp'))
);
CREATE TABLE motos (
vehicle_id INTEGER PRIMARY KEY,
tipus VARCHAR(10) NOT NULL DEFAULT 'moto',
cilindrada SMALLINT NOT NULL,
CONSTRAINT ck_motos_tipus CHECK (tipus = 'moto'),
CONSTRAINT fk_motos_veh FOREIGN KEY (vehicle_id, tipus)
REFERENCES vehicles (vehicle_id, tipus) ON DELETE CASCADE,
CONSTRAINT ck_motos_cc CHECK (cilindrada BETWEEN 49 AND 2500)
);
CREATE TABLE furgonetes (
vehicle_id INTEGER PRIMARY KEY,
tipus VARCHAR(10) NOT NULL DEFAULT 'furgoneta',
carrega_max_kg INTEGER NOT NULL,
tacograf BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT ck_furg_tipus CHECK (tipus = 'furgoneta'),
CONSTRAINT fk_furg_veh FOREIGN KEY (vehicle_id, tipus)
REFERENCES vehicles (vehicle_id, tipus) ON DELETE CASCADE,
CONSTRAINT ck_furg_carrega CHECK (carrega_max_kg BETWEEN 100 AND 5000)
);
CREATE TABLE ordres (
ordre_id SERIAL PRIMARY KEY,
vehicle_id INTEGER NOT NULL REFERENCES vehicles(vehicle_id) ON DELETE RESTRICT,
data_entrada DATE NOT NULL DEFAULT CURRENT_DATE,
data_sortida DATE,
quilometratge INTEGER NOT NULL,
descripcio TEXT NOT NULL,
estat VARCHAR(12) NOT NULL DEFAULT 'oberta',
CONSTRAINT ck_ord_estat CHECK (estat IN ('oberta','en_curs','tancada','facturada')),
CONSTRAINT ck_ord_km CHECK (quilometratge >= 0),
CONSTRAINT ck_ord_sortida CHECK (data_sortida IS NULL OR data_sortida >= data_entrada)
);
CREATE TABLE mecanics (
mecanic_id SERIAL PRIMARY KEY,
nom VARCHAR(100) NOT NULL,
especialitat VARCHAR(40),
actiu BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE tipus_treball (
tipus_treball_id SERIAL PRIMARY KEY,
codi VARCHAR(10) NOT NULL UNIQUE,
nom VARCHAR(80) NOT NULL,
preu_hora NUMERIC(7,2) NOT NULL CHECK (preu_hora > 0)
);
-- La relació ternària, amb clau substituta
CREATE TABLE intervencions (
intervencio_id SERIAL PRIMARY KEY,
ordre_id INTEGER NOT NULL REFERENCES ordres(ordre_id) ON DELETE CASCADE,
mecanic_id INTEGER NOT NULL REFERENCES mecanics(mecanic_id) ON DELETE RESTRICT,
tipus_treball_id INTEGER NOT NULL REFERENCES tipus_treball(tipus_treball_id) ON DELETE RESTRICT,
data DATE NOT NULL DEFAULT CURRENT_DATE,
hores NUMERIC(5,2) NOT NULL,
CONSTRAINT ck_int_hores CHECK (hores > 0 AND hores <= 24),
CONSTRAINT uq_intervencio UNIQUE (ordre_id, mecanic_id, tipus_treball_id, data)
);
CREATE TABLE peces (
peca_id SERIAL PRIMARY KEY,
referencia VARCHAR(30) NOT NULL UNIQUE,
descripcio VARCHAR(150) NOT NULL,
preu_actual NUMERIC(8,2) NOT NULL CHECK (preu_actual >= 0)
);
CREATE TABLE intervencio_peces (
intervencio_id INTEGER NOT NULL REFERENCES intervencions(intervencio_id) ON DELETE CASCADE,
peca_id INTEGER NOT NULL REFERENCES peces(peca_id) ON DELETE RESTRICT,
quantitat SMALLINT NOT NULL CHECK (quantitat > 0),
preu_unitari NUMERIC(8,2) NOT NULL CHECK (preu_unitari >= 0),
PRIMARY KEY (intervencio_id, peca_id)
);Pas 4 — La restricció que faltava
-- Coherència de quilometratge: una ordre posterior no pot tenir menys km
-- (regla de conjunt: no es pot expressar amb CHECK; disparador o aplicació)
-- Sí que és declarativa aquesta: l'ordre no es pot tancar sense cap intervenció
-- → tampoc no és un CHECK. Es documenta i s'implementa amb disparador AFTER.
-- El que sí que queda garantit pel motor:
-- * un vehicle pertany exactament a un subtipus (per la FK composta)
-- * no hi ha dues intervencions idèntiques el mateix dia (uq_intervencio)
-- * els preus i les hores són positiusResultat esperat
Dotze taules. Comprovació que la jerarquia està ben tancada:
INSERT INTO clients (nom, nif) VALUES ('Nerea Solans','44112233X');
INSERT INTO vehicles (matricula, tipus, client_id, marca, model, any_)
VALUES ('4471 KLM','moto',1,'Yamaha','MT-07',2021);
-- Correcte: la moto va a la seva subtaula
INSERT INTO motos (vehicle_id, cilindrada) VALUES (1, 689);
-- Incorrecte: intentar posar aquella mateixa moto com a cotxe
INSERT INTO cotxes (vehicle_id, places, combustible) VALUES (1, 5, 'gasolina');
-- ERROR: insert or update on table "cotxes" violates foreign key constraint "fk_cotxes_veh"Explicació i decisions discutibles
Les tres formes de transformar una jerarquia, i per què he triat aquesta. La lliçó 04-03 donava tres opcions:
| Opció | Com | Quan convé | Aquí |
|---|---|---|---|
| Taula única | Una taula vehicles amb totes les columnes dels tres subtipus, la majoria nul·les |
Pocs atributs específics, consultes sempre sobre el conjunt | Descartada: 5 columnes nul·lables i cap NOT NULL possible a cilindrada |
| Taula per subtipus (l'escollida) | Superentitat + una taula per subtipus amb vehicle_id com a PK i FK |
Atributs específics que han de ser obligatoris; generalització total i disjunta | Escollida |
| Només subtipus | Tres taules independents sense superentitat | Els subtipus no comparteixen relacions | Descartada: ordres necessita apuntar a un vehicle qualsevol, i amb tres taules caldria una FK polimòrfica |
L'opció tercera és la que trenca el disseny així que apareix ordres: cal poder referenciar «un vehicle» sense saber-ne el tipus.
El truc de la clau forana composta (vehicle_id, tipus) mereix atenció perquè és elegant i poc conegut. El problema que resol: amb una clau forana normal cotxes.vehicle_id → vehicles.vehicle_id, res no impedeix posar a cotxes un vehicle el tipus del qual sigui 'moto'. La solució té tres peces que només funcionen juntes:
UNIQUE (vehicle_id, tipus)avehicles—redundant amb la clau primària, però necessari perquè la FK composta tingui a què apuntar.- Una columna
tipusa la subtaula, ambCHECK (tipus = 'cotxe')iDEFAULT. - La clau forana composta de les dues columnes.
El resultat és que el motor impedeix posar una moto a la taula de cotxes. Sense el truc, aquella regla quedaria en mans de l'aplicació. El que segueix sense quedar garantit és la totalitat de la generalització (que tot vehicle tingui fila en alguna subtaula): això requereix restriccions diferides o un disparador.
La ternària: per què no n'hi ha prou amb la terna com a clau. La temptació és PRIMARY KEY (ordre_id, mecanic_id, tipus_treball_id). L'enunciat la desmunta: «el mateix tipus de treball el poden fer mecànics diferents a la mateixa ordre en dies diferents». I de fet el mateix mecànic pot repetir la mateixa feina dos dies seguits. Per això la solució fa servir clau substituta i afegeix UNIQUE (ordre_id, mecanic_id, tipus_treball_id, data), que sí que és la clau alternativa real. Si demà admeten dues intervencions iguals el mateix dia (matí i tarda), caldria substituir data per moment TIMESTAMPTZ o eliminar el UNIQUE.
La clau substituta té a més un avantatge decisiu: intervencio_peces necessita apuntar a la intervenció, i amb clau composta de quatre columnes la taula de peces tindria sis columnes de clau.
preu_unitari duplica peces.preu_actual, i està bé. És la desnormalització més justificada que existeix: el preu d'una peça canvia amb el temps i l'ordre ja facturada ha de conservar el preu que es va aplicar. peces.preu_actual és el preu d'avui, intervencio_peces.preu_unitari és el preu d'aquell dia. No són la mateixa dada, encara que coincideixin en el moment d'inserir. L'exercici 5 porta aquesta idea fins al final.
Alternativa raonable: modelar intervencions sense tipus_treball, posant el nom del treball com a text lliure. Seria més simple i seria un error: l'enunciat parla d'un «catàleg», i el preu per hora hi viu.
Exercici 5: Tarifes amb vigència temporal
Dificultat: Avançat
Enunciat. Una empresa municipal d'aparcaments de Vallmar:
«Tenim quatre aparcaments i cadascun aplica tarifes que canvien amb el temps. Una tarifa diu: per a aquest aparcament i aquest tipus d'usuari (resident, general, comercial), el preu de la primera hora, el de cada hora addicional i el màxim diari. Les tarifes s'aproven en ple i entren en vigor una data concreta; l'anterior deixa d'aplicar-se aquell mateix dia. Necessitem conservar totes les tarifes històriques, perquè hi ha reclamacions de fa tres anys i hem de poder dir quin preu estava vigent el 14 de març de 2024. De vegades s'aprova una tarifa amb mesos d'antelació, així que hi pot haver tarifes futures carregades. Mai no hi pot haver dues tarifes vigents alhora per al mateix aparcament i tipus d'usuari. També registrem les estades: entrada, sortida, matrícula i l'import cobrat.»
Pista. Pensa en la clau com a «què + des de quan», i busca la restricció de PostgreSQL que impedeix que dos intervals se solapin.
Solució
Pas 1 — Entitats i relacions
aparcaments: entitat forta, estable.tipus_usuari: catàleg petit.tarifes: l'entitat temporal. La seva identitat no és «l'aparcament i el tipus», sinó «l'aparcament, el tipus i el període de vigència».estades: els fets. Cada estada es cobra amb la tarifa vigent en el seu moment.
La relació clau: aparcaments × tipus_usuari 1:N tarifes, on el discriminant que completa la identitat és l'interval de vigència.
Pas 2 — Diagrama ER
erDiagram
APARCAMENTS ||--o{ TARIFES : "aplica"
TIPUS_USUARI ||--o{ TARIFES : "per a"
APARCAMENTS ||--o{ ESTADES : "allotja"
TIPUS_USUARI ||--o{ ESTADES : "classifica"
TARIFES ||--o{ ESTADES : "cobra segons"
APARCAMENTS { int aparcament_id PK
text nom UK
text adreca
int places }
TIPUS_USUARI { int tipus_usuari_id PK
text codi UK
text nom }
TARIFES { int tarifa_id PK
int aparcament_id FK
int tipus_usuari_id FK
date vigent_des_de
date vigent_fins_a "nul = vigent"
numeric preu_primera_hora
numeric preu_hora_extra
numeric maxim_diari
text acord_ple }
ESTADES { int estada_id PK
int aparcament_id FK
int tipus_usuari_id FK
int tarifa_id FK
text matricula
timestamp entrada
timestamp sortida
numeric import }
Pas 3 — CREATE TABLE
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE aparcaments (
aparcament_id SERIAL PRIMARY KEY,
nom VARCHAR(60) NOT NULL UNIQUE,
adreca VARCHAR(120) NOT NULL,
places SMALLINT NOT NULL CHECK (places > 0)
);
CREATE TABLE tipus_usuari (
tipus_usuari_id SERIAL PRIMARY KEY,
codi VARCHAR(12) NOT NULL UNIQUE,
nom VARCHAR(40) NOT NULL
);
CREATE TABLE tarifes (
tarifa_id SERIAL PRIMARY KEY,
aparcament_id INTEGER NOT NULL REFERENCES aparcaments(aparcament_id) ON DELETE RESTRICT,
tipus_usuari_id INTEGER NOT NULL REFERENCES tipus_usuari(tipus_usuari_id) ON DELETE RESTRICT,
vigent_des_de DATE NOT NULL,
vigent_fins_a DATE, -- NULL = vigent indefinidament
preu_primera_hora NUMERIC(6,2) NOT NULL,
preu_hora_extra NUMERIC(6,2) NOT NULL,
maxim_diari NUMERIC(6,2) NOT NULL,
acord_ple VARCHAR(40),
CONSTRAINT ck_tar_periode CHECK (vigent_fins_a IS NULL OR vigent_fins_a > vigent_des_de),
CONSTRAINT ck_tar_preus CHECK (preu_primera_hora >= 0
AND preu_hora_extra >= 0
AND maxim_diari >= preu_primera_hora)
);
CREATE TABLE estades (
estada_id SERIAL PRIMARY KEY,
aparcament_id INTEGER NOT NULL REFERENCES aparcaments(aparcament_id) ON DELETE RESTRICT,
tipus_usuari_id INTEGER NOT NULL REFERENCES tipus_usuari(tipus_usuari_id) ON DELETE RESTRICT,
tarifa_id INTEGER NOT NULL REFERENCES tarifes(tarifa_id) ON DELETE RESTRICT,
matricula VARCHAR(10) NOT NULL,
entrada TIMESTAMPTZ NOT NULL,
sortida TIMESTAMPTZ,
import NUMERIC(8,2),
CONSTRAINT ck_est_sortida CHECK (sortida IS NULL OR sortida > entrada),
CONSTRAINT ck_est_import CHECK (import IS NULL OR import >= 0)
);Pas 4 — La restricció de no solapament
-- La regla de l'enunciat: mai dues tarifes vigents alhora
-- per al mateix aparcament i tipus d'usuari.
ALTER TABLE tarifes ADD CONSTRAINT ex_tarifes_no_solapen
EXCLUDE USING gist (
aparcament_id WITH =,
tipus_usuari_id WITH =,
daterange(vigent_des_de, vigent_fins_a, '[)') WITH &&
);
-- Índex per a la consulta més freqüent: quina tarifa aplicava el dia X?
CREATE INDEX idx_tarifes_vigencia
ON tarifes (aparcament_id, tipus_usuari_id, vigent_des_de DESC);Resultat esperat
Càrrega d'exemple amb dos trams històrics i un de futur:
INSERT INTO aparcaments (nom, adreca, places)
VALUES ('P1 Plaça Major','Plaça Major s/n',240);
INSERT INTO tipus_usuari (codi, nom) VALUES ('RES','Resident'),('GEN','General');
INSERT INTO tarifes (aparcament_id, tipus_usuari_id, vigent_des_de, vigent_fins_a,
preu_primera_hora, preu_hora_extra, maxim_diari, acord_ple)
VALUES (1,2,'2023-01-01','2024-07-01', 1.80, 1.20, 14.00, 'PLE-2022/114'),
(1,2,'2024-07-01','2026-01-01', 2.00, 1.35, 16.00, 'PLE-2024/037'),
(1,2,'2026-01-01', NULL, 2.20, 1.50, 18.00, 'PLE-2025/206');Consulta de reclamació: «quin preu s'aplicava el 14 de març de 2024?»
SELECT preu_primera_hora, preu_hora_extra, maxim_diari, acord_ple
FROM tarifes
WHERE aparcament_id = 1
AND tipus_usuari_id = 2
AND vigent_des_de <= DATE '2024-03-14'
AND (vigent_fins_a IS NULL OR vigent_fins_a > DATE '2024-03-14');| preu_primera_hora | preu_hora_extra | maxim_diari | acord_ple |
|---|---|---|---|
| 1.80 | 1.20 | 14.00 | PLE-2022/114 |
I la comprovació que el motor bloqueja un solapament:
INSERT INTO tarifes (aparcament_id, tipus_usuari_id, vigent_des_de, vigent_fins_a,
preu_primera_hora, preu_hora_extra, maxim_diari)
VALUES (1, 2, '2025-06-01', '2025-12-01', 2.10, 1.40, 17.00);
-- ERROR: conflicting key value violates exclusion constraint "ex_tarifes_no_solapen"Explicació i decisions discutibles
La decisió central: no hi ha UPDATE de preus, hi ha files noves. L'instint d'un principiant és UPDATE tarifes SET preu_primera_hora = 2.20 WHERE .... Aquell UPDATE destrueix la informació que l'enunciat demana conservar: així que s'executa, ja no hi ha manera de respondre a la reclamació de 2024. Quan un requisit diu «històric», l'operació de negoci «canviar el preu» es tradueix en un INSERT, no en un UPDATE.
Interval tancat-obert [des de, fins a). L'enunciat diu que la nova tarifa entra en vigor «una data concreta» i l'anterior deixa d'aplicar-se «aquell mateix dia». Això és exactament un interval tancat per l'esquerra i obert per la dreta: la tarifa antiga val fins al 30 de juny inclòs i la nova des de l'1 de juliol, i totes dues s'escriuen amb 2024-07-01 com a frontera. L'alternativa —vigent_fins_a = '2024-06-30' amb interval tancat pels dos costats— també funciona, però obliga a fer aritmètica de dates cada vegada que s'encadena un tram i falla així que la granularitat passa de dies a hores. Tancat-obert és la convenció que cal adoptar per defecte en dades temporals.
vigent_fins_a IS NULL significa «encara vigent». És un ús legítim del nul i encaixa amb daterange(des_de, fins_a), que interpreta el NULL superior com a infinit. L'alternativa és posar '9999-12-31'; simplifica les consultes (BETWEEN funciona sense OR ... IS NULL) a canvi de ficar-hi una data màgica que algun dia algú mostrarà per pantalla.
Tres formes de garantir el no solapament:
| Opció | Portabilitat | Força |
|---|---|---|
EXCLUDE USING gist amb daterange |
Només PostgreSQL | Total: el motor ho garanteix, fins i tot amb concurrència |
Índex únic parcial (aparcament_id, tipus_usuari_id) WHERE vigent_fins_a IS NULL |
PostgreSQL i SQLite | Parcial: garanteix una sola tarifa oberta, però no impedeix solapaments entre trams tancats |
| Disparador que consulta abans d'inserir | Qualsevol | Depèn del nivell d'aïllament; amb READ COMMITTED dues sessions simultànies s'hi poden colar |
La primera és superior i per això és la de la solució; la segona és un bon segon premi i val la pena conèixer-la perquè cobreix el 90 % dels casos amb sintaxi estàndard.
estades.tarifa_id guarda la tarifa aplicada, i és imprescindible. Es podria deduir a partir d'entrada i la taula de tarifes, però fixar-la a l'estada té dos avantatges: la factura queda immutable encara que algú corregeixi a posteriori una data de vigència mal carregada, i la consulta de facturació no necessita el JOIN per rang, que és molt més car que un JOIN per igualtat. És la mateixa lògica que intervencio_peces.preu_unitari a l'exercici 4.
Alternativa raonable que no he pres: dues taules, tarifes_vigents i tarifes_historic. És un patró molt estès i té un avantatge clar (la taula calenta es manté diminuta) i dos inconvenients seriosos: les consultes que creuen la frontera necessiten un UNION, i el pas d'una taula a l'altra és una operació que cal escriure bé i que pot fallar. Amb una sola taula i un índex adequat, PostgreSQL aguanta milions de trams sense despentinar-se.
Errors Habituals i Consells
1. Confondre l'obra amb l'objecte físic. Pel·lícula/còpia, material/exemplar, model/unitat. Si dues coses poden estar en llocs diferents i en estats diferents, són dues entitats.
2. Atributs multivaluats amagats. «De vegades són dos gèneres», «els telèfons», «els idiomes dels subtítols». Cada vegada que el client digui «de vegades en són uns quants», allà hi ha una taula.
3. Claus foranes polimòrfiques. Una columna objecte_tipus + objecte_id que apunta a taules diferents segons el tipus. El motor no pot declarar aquella clau forana, així que la integritat queda sense protegir. Fes servir columnes nul·lables excloents amb CHECK, o una jerarquia.
4. Posar totes les claus foranes amb ON DELETE CASCADE «per si de cas». CASCADE està bé per al que no existeix sense el seu pare (línies d'una comanda, historial d'una petició). Per a l'històric comptable —préstecs, lloguers, factures— l'acció correcta gairebé sempre és RESTRICT, i la baixa es fa amb una columna actiu.
5. Oblidar que un CHECK només veu la seva pròpia fila. «Màxim tres peticions actives», «almenys una opció correcta», «la suma de les línies ha de quadrar amb el total»: cap no és expressable amb CHECK. Reconeix-ho al disseny i decideix on va (disparador, aplicació o restricció diferida).
6. Clau primària composta en entitats que es referenciaran molt. És tècnicament correcte i pràcticament incòmode: cada taula filla arrossega totes les columnes. Clau substituta + UNIQUE sobre la clau natural és gairebé sempre millor compromís.
7. Preus i tarifes sense congelar. Un import facturat que es llegeix de la taula de preus actual canvia tot sol quan canvien els preus. Copia el preu aplicat a la línia del document.
8. Nomenclatura inconsistent. pelliculaID, id_genere, SociId al mateix esquema. Tria una convenció (snake_case, singular o plural, sufix _id) i no la trenquis. És el primer que veu qui hereti el teu esquema.
9. Dissenyar per a consultes que ningú no ha demanat. Cada taula que afegeixes és una taula que cal mantenir. Si l'enunciat no esmenta una necessitat, apunta-la com a possible extensió, no la construeixis.
Consell de mètode. Abans d'escriure el primer CREATE TABLE, escriu la llista de preguntes que l'esquema ha de respondre i deixa-la a la vista. En acabar, comprova una per una que pots escriure la consulta. Aquell és el criteri d'«acabat»; no ho és que el CREATE TABLE s'executi sense error.
Exercicis
Sense pistes. Cadascun demana els quatre passos complets.
Exercici A: Gimnàs municipal
Dissenya l'esquema d'un gimnàs: socis amb quota mensual, classes dirigides amb horari setmanal fix (dia de la setmana i hora), monitors, sales amb aforament, i reserves de plaça dels socis per a una sessió concreta d'una classe en una data concreta. Un soci no pot reservar dues vegades la mateixa sessió, i una sessió no pot admetre més reserves que l'aforament de la seva sala. A més, dues classes no poden ocupar la mateixa sala a la mateixa hora.
Exercici B: Ampliació de BiblioRed — donacions i expurgació
Amplia BiblioRed per gestionar l'origen i el final de vida dels exemplars: d'on va venir cada exemplar (compra a un proveïdor amb factura, donació d'un particular o d'una entitat, o intercanvi amb una altra biblioteca) i, quan es retira, per quin motiu, amb quina data i quin destí va tenir (venda benèfica, reciclatge, cessió). No pots modificar la taula exemplars llevat que sigui per afegir-hi columnes, i cap consulta existent no es pot trencar.
Solucions
Solució A — Gimnàs
Entitats: socis, sales, monitors, classes (la definició: nom, nivell, durada), sessions (una ocurrència concreta: classe + data + hora + sala + monitor), reserves (soci × sessió).
La distinció clau és classe enfront de sessió: «Pilates dels dimarts a les 19:00» és la classe; «el Pilates del dimarts 4 d'agost de 2026 a les 19:00 a la Sala 2 amb l'Aitor» és la sessió. Les reserves es fan sobre sessions.
CREATE TABLE sales (
sala_id SERIAL PRIMARY KEY,
nom VARCHAR(40) NOT NULL UNIQUE,
aforament SMALLINT NOT NULL CHECK (aforament > 0)
);
CREATE TABLE monitors (
monitor_id SERIAL PRIMARY KEY,
nom VARCHAR(100) NOT NULL,
actiu BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE classes (
classe_id SERIAL PRIMARY KEY,
nom VARCHAR(60) NOT NULL UNIQUE,
nivell VARCHAR(15) NOT NULL,
durada_min SMALLINT NOT NULL CHECK (durada_min BETWEEN 15 AND 180)
);
CREATE TABLE socis (
soci_id SERIAL PRIMARY KEY,
nom VARCHAR(100) NOT NULL,
email VARCHAR(120) NOT NULL UNIQUE,
quota_mes NUMERIC(6,2) NOT NULL CHECK (quota_mes >= 0),
actiu BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE sessions (
sessio_id SERIAL PRIMARY KEY,
classe_id INTEGER NOT NULL REFERENCES classes(classe_id) ON DELETE RESTRICT,
sala_id INTEGER NOT NULL REFERENCES sales(sala_id) ON DELETE RESTRICT,
monitor_id INTEGER NOT NULL REFERENCES monitors(monitor_id) ON DELETE RESTRICT,
inici TIMESTAMPTZ NOT NULL,
fi TIMESTAMPTZ NOT NULL,
places SMALLINT NOT NULL CHECK (places > 0),
CONSTRAINT ck_ses_horari CHECK (fi > inici)
);
CREATE TABLE reserves (
sessio_id INTEGER NOT NULL REFERENCES sessions(sessio_id) ON DELETE CASCADE,
soci_id INTEGER NOT NULL REFERENCES socis(soci_id) ON DELETE RESTRICT,
moment TIMESTAMPTZ NOT NULL DEFAULT now(),
estat VARCHAR(12) NOT NULL DEFAULT 'confirmada'
CHECK (estat IN ('confirmada','cancellada','assistida')),
PRIMARY KEY (sessio_id, soci_id) -- un soci, una reserva per sessió
);
-- Dues classes no poden ocupar la mateixa sala a la mateixa hora
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE sessions ADD CONSTRAINT ex_sala_ocupada
EXCLUDE USING gist (sala_id WITH =, tstzrange(inici, fi) WITH &&);
-- Un monitor tampoc no pot estar en dos llocs alhora
ALTER TABLE sessions ADD CONSTRAINT ex_monitor_ocupat
EXCLUDE USING gist (monitor_id WITH =, tstzrange(inici, fi) WITH &&);Dos comentaris sobre les decisions:
PRIMARY KEY (sessio_id, soci_id)resol de franc la regla «un soci no pot reservar dues vegades la mateixa sessió». És el cas ideal: una regla de negoci que es converteix en la clau primària.- L'aforament no és un
CHECK. «No més reserves que places» exigeix comptar files, i unCHECKno pot.sessions.placescopia l'aforament de la sala en el moment de programar-la (per poder oferir menys places que l'aforament real), i el control es fa a la transacció de reserva ambSELECT ... FOR UPDATEsobre la sessió —exactament el problema de l'última plaça que resoldràs a 07-04. - Les dues restriccions
EXCLUDEsón el mateix patró de l'exercici 5 aplicat a intervals de temps en lloc de a vigències.
Solució B — Donacions i expurgació a BiblioRed
CREATE TABLE proveidors (
proveidor_id SERIAL PRIMARY KEY,
nom VARCHAR(120) NOT NULL,
nif VARCHAR(12) UNIQUE,
contacte VARCHAR(120)
);
CREATE TABLE donants (
donant_id SERIAL PRIMARY KEY,
tipus VARCHAR(12) NOT NULL CHECK (tipus IN ('particular','entitat')),
nom VARCHAR(150) NOT NULL,
email VARCHAR(120),
anonim BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE adquisicions (
adquisicio_id SERIAL PRIMARY KEY,
exemplar_id INTEGER NOT NULL UNIQUE REFERENCES exemplars(exemplar_id) ON DELETE CASCADE,
via VARCHAR(12) NOT NULL,
data DATE NOT NULL,
proveidor_id INTEGER REFERENCES proveidors(proveidor_id) ON DELETE RESTRICT,
donant_id INTEGER REFERENCES donants(donant_id) ON DELETE RESTRICT,
biblioteca_origen VARCHAR(150),
num_factura VARCHAR(30),
cost NUMERIC(8,2),
CONSTRAINT ck_adq_via CHECK (via IN ('compra','donacio','intercanvi')),
CONSTRAINT ck_adq_coherencia CHECK (
(via = 'compra' AND proveidor_id IS NOT NULL AND donant_id IS NULL
AND num_factura IS NOT NULL AND cost IS NOT NULL)
OR (via = 'donacio' AND donant_id IS NOT NULL AND proveidor_id IS NULL)
OR (via = 'intercanvi' AND biblioteca_origen IS NOT NULL
AND proveidor_id IS NULL AND donant_id IS NULL)
)
);
CREATE TABLE expurgacions (
expurgacio_id SERIAL PRIMARY KEY,
exemplar_id INTEGER NOT NULL UNIQUE REFERENCES exemplars(exemplar_id) ON DELETE RESTRICT,
data DATE NOT NULL DEFAULT CURRENT_DATE,
motiu VARCHAR(20) NOT NULL
CHECK (motiu IN ('deteriorament','obsolet','duplicat','perdua','baixa_demanda')),
desti VARCHAR(20) NOT NULL
CHECK (desti IN ('venda_benefica','reciclatge','cessio','destruccio')),
autoritzat_per VARCHAR(80) NOT NULL,
observacions TEXT
);
CREATE INDEX idx_adq_via ON adquisicions (via, data);
CREATE INDEX idx_exp_data ON expurgacions (data DESC);Les decisions que cal saber defensar:
UNIQUE (exemplar_id)a les dues taules converteix la relació en 1:1 opcional: un exemplar té com a molt un origen registrat i com a molt una expurgació. Sense aquellUNIQUEseria 1:N i un exemplar podria aparèixer donat dues vegades.- El
CHECKde coherència per via és la peça de disseny més valuosa: fa impossible una compra sense factura o una donació amb proveïdor. L'alternativa —tres taules fillesadquisicions_compra,adquisicions_donacio,adquisicions_intercanviamb el patró de jerarquia de l'exercici 4— és més neta conceptualment i més pesada de consultar. Amb tres subtipus de dues o tres columnes cadascun, elCHECKguanya. expurgacions → exemplarsésRESTRICTiadquisicions → exemplarsésCASCADE. No és una incoherència: si s'esborra l'exemplar de la base de dades, la seva procedència deixa d'importar, però una expurgació és un acte administratiu que ha de sobreviure i impedir l'esborrat.- No es modifica
exemplars. L'estatretiratja existia; l'única cosa que aporta l'expurgació és la documentació del perquè. Qualsevol consulta anterior segueix funcionant paraula per paraula.
Rúbrica d'Autoavaluació
Puntua el teu disseny de cada exercici amb aquesta llista. Un disseny correcte compleix els vuit primers punts; els dos últims separen un disseny correcte d'un bon disseny.
| # | Criteri | Com comprovar-ho |
|---|---|---|
| 1 | Totes les preguntes de l'enunciat es poden respondre | Escriu la consulta de cada pregunta. Si alguna necessita una dada que no és a cap taula, el disseny està incomplet |
| 2 | Cap atribut multivaluat | Busca columnes que puguin contenir «diversos» valors: llistes amb comes, telefon1/telefon2, camps de text amb separadors |
| 3 | Tota taula té clau primària | Sense excepcions, incloses les taules de reunió N:M |
| 4 | Tota relació està materialitzada amb clau forana declarada | No val «l'aplicació ho controla». Si el motor no ho declara, no està garantit |
| 5 | Cada clau forana té una acció ON DELETE decidida a consciència |
Recorre la llista i justifica'n una per una: CASCADE, RESTRICT, SET NULL, SET DEFAULT o NO ACTION |
| 6 | Cada regla de negoci de l'enunciat té la seva restricció... o la seva nota | Fes la llista de regles de l'enunciat i marca al costat de cadascuna: CHECK, UNIQUE, índex parcial, EXCLUDE, disparador o «responsabilitat de l'aplicació» |
| 7 | Els conjunts tancats de valors estan restringits | Tot estat, tipus o motiu ha de tenir CHECK IN (...), un domini o una taula de catàleg |
| 8 | Nomenclatura consistent | Mateix idioma, mateix nombre (singular/plural), mateix estil de sufix _id, mateix estil de nom de restricció |
| 9 | Les dades històriques no es poden destruir amb un UPDATE |
Preus, tarifes i imports facturats congelats a la fila que els va usar; vigències com a files noves, no com a modificacions |
| 10 | Hi ha índexs per a les consultes freqüents de l'enunciat | Almenys les claus foranes que es fan servir en JOIN i les columnes dels filtres habituals |
Com puntuar. Si falles el punt 1, torna a l'enunciat: el disseny no serveix. Si falles el 2, el 3 o el 4, tens un problema estructural. Els punts 5 a 8 són els que separen un esquema d'estudiant d'un de producció. Els punts 9 i 10 només s'apliquen als enunciats que els esmenten.
Conclusió
Has dissenyat cinc esquemes complets des de zero: un videoclub amb dues relacions N:M —una d'elles amb atribut propi—, una ampliació de BiblioRed que va haver d'encaixar amb un esquema en producció sense tocar-lo, una plataforma de cursos amb jerarquia de continguts i autoreferència, un taller mecànic amb jerarquia de generalització i relació ternària, i un sistema de tarifes on la clau inclou el temps. En tots cinc, la feina real no va ser al CREATE TABLE sinó a les dues decisions que el precedeixen: què és una entitat i què és un atribut, i quina regla de negoci pot garantir el motor i quina no.
Repassa la llista d'eines que has fet servir per codificar regles: CHECK d'una columna i de diverses, UNIQUE compost, índex únic parcial (una còpia llogada, una matrícula activa, una opció correcta), clau forana composta amb discriminant per tancar una jerarquia, EXCLUDE USING gist per a intervals que no se solapen, i dominis per a conjunts de valors reutilitzables. I recorda les tres regles que cap d'elles va poder expressar —el màxim de peticions actives, l'aforament d'una sessió i la totalitat d'una jerarquia—, perquè saber on s'acaba el que és declaratiu és tan important com saber usar-ho.
A 07-03, Exercicis de Normalització, la feina s'inverteix. En lloc de partir de requisits i arribar a taules, partiràs de taules que ja existeixen i que estan malament: llistats plans amb dades repetides, històrics amb el nom del soci copiat a cada fila, taules on canviar un telèfon obliga a tocar catorze registres. N'hauràs d'escriure les dependències funcionals, calcular clausures, trobar totes les claus candidates, dir en quina forma normal estan i per què, i descompondre-les pas a pas amb l'SQL que fa la migració. Res de requisits: només taules, dades i l'anàlisi formal que revela el que amaguen.
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
