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

  1. Per què el tipus de dada és una decisió de disseny
  2. Enters: SMALLINT, INTEGER, BIGINT i el dia que s'acaben
  3. NUMERIC enfront de REAL: per què els diners mai no van en coma flotant
  4. Text: CHAR, VARCHAR(n) i TEXT
  5. Dates i hores: TIMESTAMP enfront de TIMESTAMPTZ, i INTERVAL
  6. BOOLEAN i les trampes de 'S'/'N'
  7. UUID enfront d'enter seqüencial com a clau
  8. Conjunts tancats de valors: ENUM, taula de catàleg o CHECK
  9. JSONB i arrays com a escapatòries controlades
  10. BYTEA i per què les portades no van a la base
  11. Codificació i intercalació: cercar títols en català i castellà
  12. Els tipus permissius de SQLite enfront dels estrictes de PostgreSQL
  13. NOT NULL: la decisió de permetre absències
  14. DEFAULT: valors per omissió
  15. UNIQUE simple i compost, i la seva relació amb NULL
  16. PRIMARY KEY com a combinació de les anteriors
  17. CHECK: codificar regles de negoci a l'esquema
  18. Columnes generades i dominis
  19. Anomenar les restriccions i llegir els errors de producció
  20. Afegir restriccions a una taula que ja té dades
  21. Quines regles van a la base de dades i quines a l'aplicació
  22. Entregable: l'esquema definitiu de BiblioRed ampliat
  23. Errors Habituals i Consells
  24. Exercicis
  25. Conclusió

  1. 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.

  1. Enters: 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, INTEGER en 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)

  1. NUMERIC enfront de REAL: per què els diners mai no van en coma flotant

Aquest é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;
 coincideix_flotant | coincideix_numeric
--------------------+--------------------
 f                  | t
(1 row)

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);
 total_real | total_numeric
------------+---------------
  200.00003 |        200.00
(1 row)

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;
 import
--------
  12.35
INSERT INTO multes (soci_id, motiu, import) VALUES (15, 'perdua', 25000.00);
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 tipus MONEY, però depèn de la configuració regional del servidor i no porta la moneda a dins. Es desaconsella: NUMERIC és l'opció portable.

  1. 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;
 iguals | longitud | visualitzacio
--------+----------+---------------
 t      |        2 | [ca   ]
(1 row)

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');
ERROR:  value too long for type character varying(200)

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ó.

  1. Dates i hores: 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 : 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ó:

SET TIME ZONE 'America/Bogota';
SELECT * FROM prova_hores;
         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.

  1. 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
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.

  1. UUID enfront d'enter seqüencial com a clau

Enter 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 , sense coordinació
Filtra informació : 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.

  1. Conjunts tancats de valors: ENUM, taula de catàleg o CHECK

BiblioRed 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;
INSERT INTO multes (soci_id, motiu, import, estat)
VALUES (15, 'retard', 4.00, 'pagadaa');
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'));
INSERT INTO multes (soci_id, motiu, import, estat)
VALUES (15, 'retard', 4.00, 'pagadaa');
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
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 No
Admet atributs (descripció, ordre, traducció) No 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
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. ENUM nomé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.

  1. JSONB i arrays com a escapatòries controlades

A 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'];
 material_id
-------------
        1204
(1 row)

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}';
 esdeveniment_id | n
-----------------+----
              47 | 18
(1 row)

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; JSONB si la forma la decideix algú aliè a l'esquema. I si acabes escrivint un CHECK complex sobre una clau del JSON, aquesta clau volia ser una columna.

  1. BYTEA i per què les portades no van a la base

BYTEA 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 duplicats

Les 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—.

  1. 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

CREATE DATABASE biblioredb ENCODING 'UTF8' LC_COLLATE 'es_ES.UTF-8' LC_CTYPE 'es_ES.UTF-8';

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";
   titol
------------
 Antología
 Zoo
 anatomía
 Ángeles
 Àngels
(5 rows)

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";
   titol
------------
 anatomía
 Ángeles
 Àngels
 Antología
 Zoo
(5 rows)

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 majúscules: ILIKE
SELECT titol FROM materials WHERE titol ILIKE '%mapa del temps%';
       titol
--------------------
 El mapa del temps
(1 row)
-- Insensible a accents: extensió unaccent
CREATE EXTENSION IF NOT EXISTS unaccent;
SELECT titol FROM materials
 WHERE unaccent(titol) ILIKE unaccent('%angels%');
      titol
-------------------
 Àngels de paper
(1 row)

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.

  1. 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;
id    import        data
----  ------------  ----------
hola  molts diners  el dijous

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 No: REAL de coma flotant. Els diners exigeixen desar cèntims com a enters
VARCHAR(n) Valida n Ignora n completament
CHECK
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');
Runtime error: cannot store TEXT value in INTEGER column prova.id

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.


  1. NOT NULL: la decisió de permetre absències

Començ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 Un esdeveniment sense títol no és un esdeveniment
esdeveniments.sala_id No Decisió D4: esdeveniments a l'aire lliure
esdeveniments.fi 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 Una multa sense import no té sentit
ponents.email No Hi ha externs dels quals només es té el telèfon
inscripcions.acompanyants , amb DEFAULT 0 "Cap" és 0, no "no se sap"
informes_esdeveniment.valoracio_mitjana No Pot no haver-hi enquestes
informes_esdeveniment.assistents_reals 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 NULL i 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.

  1. 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 0

Un 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);
ERROR:  null value in column "estat" of relation "esdeveniments" violates not-null constraint

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 a DEFAULT.

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.

  1. UNIQUE simple i compost, i la seva relació amb NULL

UNIQUE 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);
INSERT 0 1
INSERT 0 1

Dues sales amb el mateix nom en sucursals diferents: correcte segons R3.

INSERT INTO sales (sucursal_id, nom, aforament) VALUES (1, 'Sala Polivalent', 60);
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;
 count
-------
     3

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);
INSERT 0 1
INSERT 0 1
INSERT 0 1

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);
INSERT INTO multes (soci_id, prestec_id, motiu, import) VALUES (16, NULL, 'perdua', 18.00);
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 UNIQUE sobre columnes anul·lables no garanteix el que sembla garantir. Cada vegada que en declaris un, comprova si alguna de les seves columnes admet NULL i decideix conscientment.

  1. PRIMARY KEY com a combinació de les anteriors

PRIMARY 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:

  1. Només hi pot haver una clau primària per taula; claus UNIQUE, les que calguin.
  2. REFERENCES taula sense columna apunta implícitament a la clau primària.
  3. Eines, ORM i clients gràfics la fan servir per identificar la fila.

A BiblioRed conviuen els dos usos: materialsPRIMARY 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.

  1. CHECK: codificar regles de negoci a l'esquema

Aquí é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ó:

INSERT INTO multes (soci_id, motiu, import) VALUES (14, 'retard', -40.00);
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).
INSERT INTO inscripcions (esdeveniment_id, soci_id, acompanyants) VALUES (47, 16, 7);
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');
INSERT 0 1

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.

  1. 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;
SELECT esdeveniment_id, inici, fi, durada_min FROM esdeveniments WHERE esdeveniment_id = 47;
 esdeveniment_id |         inici          |           fi           | durada_min
-----------------+------------------------+------------------------+------------
              47 | 2026-09-17 18:00:00+02 | 2026-09-17 20:00:00+02 |        120
UPDATE esdeveniments SET durada_min = 999 WHERE esdeveniment_id = 47;
ERROR:  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');
ERROR:  value for domain dom_email violates check constraint "dom_email_check"
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.

  1. 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:

INSERT INTO sales VALUES (DEFAULT, 1, 'Sala Polivalent', 0);
ERROR:  new row for relation "sales" violates check constraint "chk_sales_aforament_positiu"

Sembla un detall estètic. No ho és, per tres raons concretes:

  1. L'equip de suport llegeix el missatge. chk_sales_aforament_positiu s'entén sense obrir l'esquema; sales_aforament_check obliga a investigar. Amb dos CHECK a la mateixa columna, el nom autogenerat és sales_aforament_check1, i aleshores ja no hi ha res a fer.
  2. 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.
  3. Les migracions necessiten el nom. ALTER TABLE ... DROP CONSTRAINT exigeix 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.

  1. 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:

ALTER TABLE prestecs ADD CONSTRAINT chk_prestecs_recarrec CHECK (recarrec >= 0);

Passen dues coses. Si hi ha dades que la incompleixen:

ERROR:  check constraint "chk_prestecs_recarrec" of relation "prestecs" is violated by some row

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:

SELECT count(*) FROM prestecs WHERE recarrec < 0;
 count
-------
     3

Pas 2 — Corregir les dades existents:

UPDATE prestecs SET recarrec = 0 WHERE recarrec < 0;
UPDATE 3

Pas 3 — Afegir la restricció amb NOT VALID i validar-la després:

ALTER TABLE prestecs
    ADD CONSTRAINT chk_prestecs_recarrec CHECK (recarrec >= 0) NOT VALID;
ALTER TABLE

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:

ALTER TABLE prestecs VALIDATE CONSTRAINT chk_prestecs_recarrec;
ALTER TABLE

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';
        conname         | convalidated
------------------------+--------------
 chk_prestecs_recarrec  | t

NOT VALID funciona 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 admet NOT VALID fins a PostgreSQL 18; el rodeig clàssic és afegir primer un CHECK (col IS NOT NULL) NOT VALID, validar-lo i convertir-lo després.

  1. 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.

  1. 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.

  1. sales.metres_quadrats — superfície de la sala, amb un decimal.
  2. esdeveniments.aforament_reduit_covid — percentatge enter d'aforament permès (0-100).
  3. pagaments.referencia_passarella — identificador que retorna la passarel·la de l'ajuntament, cadena alfanumèrica de 32 caràcters.
  4. socis.data_naixement — per a estadístiques per franja d'edat.
  5. materials.vegades_prestat — nombre total de préstecs històrics del material.
  6. 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.

  1. La valoració mitjana d'un informe és entre 0 i 5.
  2. Un esdeveniment cancel·lat no pot estar publicat.
  3. Un exemplar en estat 'baixa' no es pot prestar.
  4. El codi d'un exemplar comença sempre per EJ-.
  5. Una sala no pot tenir dos esdeveniments encavalcats.
  6. Un soci no pot tenir més de tres telèfons.
  7. 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 , CHECK d'una columna Ja hi és: chk_informes_valoracio
2 Cancel·lat no publicat , 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- , CHECK amb expressió regular Ja hi és: chk_exemplars_codi
5 Sense encavalcaments en una sala , 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ó:

UPDATE esdeveniments SET estat = 'cancellat' WHERE esdeveniment_id = 47 AND publicat;
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: SMALLINT documenta la intenció, INTEGER va sobrat per a tot el que genera una persona, BIGINT per al que genera una màquina. Migrar d'INTEGER a BIGINT en una taula gran i referenciada és de les pitjors migracions que existeixen.
  • NUMERIC enfront de la coma flotant és l'apartat que cal recordar: 3.10 + 2.20 + 4.30 dóna 9.600000000000001 en DOUBLE PRECISION, i per això una multa pagada íntegrament pot aparèixer com a impagada. Tot el que són diners va en NUMERIC.
  • Text: CHAR(n) gairebé mai; VARCHAR(n) només quan n surt d'una norma externa (ISBN, ISSN, codi postal); TEXT per a tota la resta, amb CHECK de longitud si el negoci vol un topall.
  • Dates: TIMESTAMPTZ per defecte per als instants de fets, perquè desa el moment absolut i sobreviu als fusos horaris i al canvi d'hora; DATE quan l'hora no existeix en el domini; INTERVAL per calcular venciments.
  • BOOLEAN en lloc de 'S'/'N', i NOT NULL quan el tercer valor no significa res.
  • UUID nomé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, CHECK si els gestiona el desenvolupador, ENUM només si són immutables de debò.
  • JSONB i 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-icu amb unaccent per cercar.
  • SQLite fa servir afinitat de tipus: accepta text en una columna INTEGER. Amb STRICT, PRAGMA foreign_keys = ON i els imports en cèntims enters, és una eina excel·lent; sense això, és una trampa.

Sobre les restriccions:

  • NOT NULL per 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ú.
  • DEFAULT viu a la base perquè el respectin totes les vies d'escriptura, no només l'aplicació web. No rescata un NULL explícit.
  • UNIQUE sobre columnes anul·lables no garanteix el que sembla: diversos NULL hi conviuen, i això va obrir un forat real a uq_multes_prestec_motiu que es va tancar amb NULLS NOT DISTINCT.
  • CHECK accepta la fila quan l'expressió dóna TRUE o NULL; només la rebutja amb FALSE. 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.

© Copyright 2026. Tots els drets reservats