Escena Viva funciona sobre MongoDB i funciona bé. Aquesta lliçó no ve a desmuntar-ho: ve a mostrar l'altre món, el relacional, modelant exactament el mateix domini a PostgreSQL perquè vegis amb els teus propis ulls què canvia. Un desenvolupador de Node que només coneix un dels dos models té la meitat de les eines. I hi ha un motiu addicional molt concret: al món relacional apareixen dues peces que resolen d'arrel problemes que arrosseguem. Una és la restricció CHECK, que converteix venudes <= aforament en una llei del motor i no en una bona intenció nostra. L'altra són les transaccions, que arriben a la lliçó següent. Al final tindràs una segona implementació de src/repositoris/esdeveniments.js sobre Sequelize, intercanviable amb la de Mongoose sense tocar controladors ni domini.

Contingut

  1. El món relacional en deu minuts, i per què aquí les sessions sí que són una taula
  2. L'esquema SQL d'Escena Viva
  3. Instal·lar Sequelize, connectar i per què sync() està prohibit
  4. Definir models i tipus de dades
  5. Validacions de model davant de restriccions de base de dades
  6. Associacions i consultes amb Sequelize
  7. SQL parametritzat i la injecció
  8. El repositori alternatiu sobre Sequelize
  9. Mongoose davant de Sequelize: el criteri

El món relacional en deu minuts

Una base de dades relacional desa taules: conjunts de files amb les mateixes columnes, cadascuna d'un tipus declarat. Sobre aquesta estructura tan simple es construeix tot:

  • Clau primària (PK). La columna que identifica unívocament cada fila; normalment un id autoincremental o un UUID.
  • Clau forana (FK). Una columna que apunta a la PK d'una altra taula. El motor garanteix que el destí existeix: no pots inserir una comanda amb un usuari_id inexistent, ni esborrar un usuari que tingui comandes llevat que defineixis què fer. És la integritat referencial, i MongoDB no la té.
  • NOT NULL, UNIQUE, DEFAULT. La columna ha de tenir valor, no es repeteix, o pren un valor per omissió.
  • CHECK. Una condició arbitrària que tota fila ha de complir. És la més infravalorada i la que més ens interessa.

La diferència cultural profunda: aquí l'esquema és obligatori i el fa complir el motor. No pots inserir una fila amb un camp extra ni amb un tipus equivocat. Això incomoda al principi i salva projectes al cap de tres anys.

Normalització: per què aquí les sessions sí que són una taula

Normalitzar és organitzar les dades perquè cada fet es desi una sola vegada. Les formes normals són un cos teòric ampli, però a la pràctica es redueixen a tres regles: res de valors múltiples en una cel·la (si hi ha diverses sessions, es crea una taula); cada taula descriu una sola cosa; i res derivat d'alguna cosa que no sigui la clau. A MongoDB incrustem les sessions dins de l'esdeveniment perquè el model documental ho permet i el patró d'accés ho afavoria. A SQL, les sessions són una taula pròpia, i no és un caprici: és l'única forma natural de tenir una fila per sessió amb la seva pròpia clau primària, les seves pròpies restriccions —CHECK (venudes <= aforament) s'aplica per fila, és a dir, per sessió— i les seves pròpies claus foranes des d'entrades i comandes. Perdem la lectura d'un sol cop (ara un esdeveniment amb les seves sessions són dues taules i un JOIN) i guanyem integritat garantida pel motor. És exactament el compromís que descrivia la taula de la lliçó 07-01, ara amb noms propis.

L'esquema SQL d'Escena Viva

-- Usuaris. Sense contrasenya: l'autenticacio es el modul 8.
CREATE TABLE usuaris (
  id     SERIAL PRIMARY KEY,
  correu VARCHAR(160) NOT NULL UNIQUE,
  nom    VARCHAR(120) NOT NULL,
  rol    VARCHAR(20)  NOT NULL DEFAULT 'assistent'
         CHECK (rol IN ('assistent', 'organitzador', 'administrador')),
  creat_el TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Esdeveniments. esdeveniment_id es l'identificador de negoci (evt-001), estable i public.
CREATE TABLE esdeveniments (
  id              SERIAL PRIMARY KEY,
  esdeveniment_id VARCHAR(20)  NOT NULL UNIQUE,
  titol           VARCHAR(160) NOT NULL,
  sala            VARCHAR(120) NOT NULL,
  organitzador_id VARCHAR(40)  NOT NULL,
  categoria       VARCHAR(60)  NOT NULL,
  duracio_minuts  INTEGER      NOT NULL CHECK (duracio_minuts BETWEEN 1 AND 600),
  estat           VARCHAR(20)  NOT NULL DEFAULT 'esborrany'
                  CHECK (estat IN ('esborrany', 'publicat', 'finalitzat'))
);

-- Sessions: aqui SI que son una taula, amb la seva propia integritat per fila.
CREATE TABLE sessions (
  id                  SERIAL PRIMARY KEY,
  sessio_id           VARCHAR(20) NOT NULL UNIQUE,
  -- Si s'esborra l'esdeveniment, s'esborren les seves sessions. Ho garanteix el motor.
  esdeveniment_id_ref INTEGER     NOT NULL REFERENCES esdeveniments(id) ON DELETE CASCADE,
  data_hora           TIMESTAMPTZ NOT NULL,
  aforament           INTEGER     NOT NULL CHECK (aforament > 0),
  venudes             INTEGER     NOT NULL DEFAULT 0 CHECK (venudes >= 0),
  preu_centims        INTEGER     NOT NULL CHECK (preu_centims >= 0),
  -- LA restriccio del curs: el motor rebutja fisicament la sobrevenda.
  CONSTRAINT aforament_no_superat CHECK (venudes <= aforament)
);

CREATE TABLE comandes (
  id            SERIAL PRIMARY KEY,
  usuari_id     INTEGER     NOT NULL REFERENCES usuaris(id) ON DELETE RESTRICT,
  sessio_id_ref INTEGER     NOT NULL REFERENCES sessions(id) ON DELETE RESTRICT,
  quantitat     INTEGER     NOT NULL CHECK (quantitat BETWEEN 1 AND 6),
  total_centims INTEGER     NOT NULL CHECK (total_centims >= 0),
  canal         VARCHAR(20) NOT NULL CHECK (canal IN ('web', 'taquilla', 'telefon')),
  estat         VARCHAR(20) NOT NULL DEFAULT 'pendent'
                CHECK (estat IN ('pendent', 'pagat', 'emes', 'anullat')),
  creat_el      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE entrades (
  id            SERIAL PRIMARY KEY,
  codi          VARCHAR(20) NOT NULL UNIQUE CHECK (codi ~ '^EV-\d{4}-\d{6}$'),
  comanda_id    INTEGER     NOT NULL REFERENCES comandes(id) ON DELETE CASCADE,
  sessio_id_ref INTEGER     NOT NULL REFERENCES sessions(id) ON DELETE RESTRICT,
  estat         VARCHAR(20) NOT NULL DEFAULT 'valida'
                CHECK (estat IN ('valida', 'usada', 'anullada')),
  usada_el      TIMESTAMPTZ,
  -- Coherencia interna: si esta usada ha de tenir data d'us, i si no, no.
  CONSTRAINT us_coherent CHECK ((estat = 'usada') = (usada_el IS NOT NULL))
);

-- Indexs de les consultes frequents: a Postgres les FK NO s'indexen soles.
CREATE INDEX idx_esdeveniments_estat_sala ON esdeveniments (estat, sala);
CREATE INDEX idx_sessions_esdeveniment    ON sessions (esdeveniment_id_ref);
CREATE INDEX idx_comandes_usuari          ON comandes (usuari_id, creat_el DESC);

Atura't a CONSTRAINT aforament_no_superat CHECK (venudes <= aforament). Aquesta línia és la resposta relacional al problema del curs. A MongoDB tenim un validador que Mongoose executa de vegades —recorda: no en updateOne sense runValidators, i sense accés a altres camps en actualitzacions parcials—. Aquí és el motor qui la comprova a cada INSERT i cada UPDATE, sense excepció, vingui de la teva aplicació, d'un script, d'algú amb psql o d'una migració mal escrita. És integritat que MongoDB, senzillament, no et dóna. I observa ON DELETE RESTRICT davant d'ON DELETE CASCADE: el primer impedeix esborrar un usuari amb comandes; el segon esborra les entrades en esborrar la seva comanda. Res d'entrades òrfenes: el motor no ho permet.

Instal·lar Sequelize, connectar i per què sync() està prohibit

# ORM + controlador de PostgreSQL, i la CLI per a migracions (llico 07-06).
npm install sequelize pg pg-hstore && npm install --save-dev sequelize-cli
# Postgres en local amb Docker; al modul 11 ho formalitzem amb compose.
docker run -d --name postgres-escena-viva -e POSTGRES_PASSWORD=desenvolupament \
  -e POSTGRES_DB=escena_viva -p 5432:5432 postgres:16
// src/db/sequelize.js — l'URL entra, com sempre, per src/config/index.js.
'use strict';

const { Sequelize } = require('sequelize');
const { configuracio } = require('../config/index.js');

const sequelize = new Sequelize(configuracio.postgresUrl, {
  dialect: 'postgres',
  // Registre de SQL: utilissim en desenvolupament, sorollos en produccio.
  logging: configuracio.entorn === 'desenvolupament' ? console.log : false,
  pool: { max: 10, min: 1, idle: 10_000, acquire: 30_000 },
  define: { underscored: true, timestamps: true, createdAt: 'creat_el', updatedAt: 'actualitzat_el' },
});

// authenticate() llanca un SELECT 1: comprova credencials i xarxa en arrencar.
const connectar = () => sequelize.authenticate().then(() => sequelize);

module.exports = { sequelize, connectar, desconnectar: () => sequelize.close() };

Sequelize pot crear les taules des dels teus models amb sequelize.sync(), sync({ alter: true }) o sync({ force: true }). És còmode a la primera hora d'un projecte i en producció està prohibit, per quatre raons que convé entendre i no només obeir. No hi ha historial: ningú no sap què va canviar, quan ni per què, i no es pot revisar en una pull request ni revertir. alter: true és impredictible: per canviar el tipus d'una columna la pot esborrar i recrear, perdent les dades, i no detecta reanomenaments. No preserva dades: afegir una columna NOT NULL a una taula amb un milió de files exigeix decidir quin valor tenen aquestes files, i sync no ho pregunta. I force: true a l'entorn equivocat és la forma més ràpida coneguda d'esborrar una base de dades de producció. L'alternativa són les migracions, primera meitat de la lliçó següent.

Definir models i tipus de dades

// src/models-sql/sessio.js
'use strict';

const { Model, DataTypes } = require('sequelize');
const { sequelize } = require('../db/sequelize.js');

class Sessio extends Model {
  // Els getters del domini es reprodueixen com a propietats de la classe.
  get lliures() { return this.aforament - this.venudes; }
  get exhaurida() { return this.venudes >= this.aforament; }
}
Sessio.init(
  {
    id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
    // 'field' declara el nom real de la columna quan difereix de l'atribut.
    sessioId: { type: DataTypes.STRING(20), allowNull: false, unique: true,
      field: 'sessio_id', validate: { is: /^ses-\d{3}-\d+$/ } },
    dataHora: { type: DataTypes.DATE, allowNull: false, field: 'data_hora' },
    aforament: { type: DataTypes.INTEGER, allowNull: false, validate: { min: 1 } },
    venudes: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, validate: { min: 0 } },
    preuCentims: { type: DataTypes.INTEGER, allowNull: false, field: 'preu_centims' },
  },
  {
    sequelize, modelName: 'Sessio', tableName: 'sessions', timestamps: false,
    validate: {
      // Validacio de model: a diferencia de la de camp, veu TOTA la fila.
      aforamentCoherent() {
        if (this.venudes > this.aforament) throw new Error("Venudes no pot superar l'aforament");
      },
    },
  },
);

module.exports = { Sessio };

Esdeveniment, Comanda, Entrada i Usuari es defineixen igual, amb ENUM per a estat, canal i rol.

Tipus Sequelize Tipus SQL (Postgres) Ús a Escena Viva
INTEGER / BIGINT integer / bigint aforament, venudes, preuCentims
STRING(n) / TEXT varchar(n) / text titol, codi / descripcions llargues
DECIMAL(p,s) numeric Decimal exacte — no el fem servir
FLOAT / DOUBLE real / double Mai per a diners
DATE / DATEONLY timestamptz / date dataHora, marques de temps
BOOLEAN / ENUM(...) boolean / tipus enum Indicadors / estat, canal, rol
JSONB / UUID jsonb / uuid Metadades flexibles / claus opaques

Sobre DECIMAL davant d'enters en cèntims: DECIMAL és exacte i correcte per a diners, però a Node arriba com a cadena —un number no pot representar tots els decimals de 128 bits— i obliga a fer servir aritmètica decimal a cada operació. La nostra convenció evita el problema d'arrel: els enters de JavaScript són exactes fins a 2⁵³, més que suficient per a qualsevol recaptació. FLOAT i DOUBLE estan descartats sense discussió: 0.1 + 0.2 !== 0.3. I JSONB mereix una nota: PostgreSQL desa JSON binari, indexable i consultable amb operadors propis, així que pots tenir camps documentals dins d'una base relacional; la frontera entre els dos mons és menys nítida del que suggereixen els debats.

Validacions de model davant de restriccions de base de dades

És la mateixa pregunta que a la 07-02 amb zod i Mongoose, amb la mateixa resposta: calen totes dues, perquè protegeixen de coses diferents.

Validació de Sequelize Restricció del motor
On s'executa Al teu procés, abans de l'INSERT A PostgreSQL, sempre
A qui protegeix A la teva aplicació A tothom: scripts, psql, altres aplicacions
Missatge Bo, en català, camp a camp Tècnic: violates check constraint "aforament_no_superat"
Cost Zero viatges a la base Un viatge que acaba en error
Se salta amb validate: false, bulkCreate, SQL cru Res. És infranquejable

L'estratègia professional: validació al model per donar bons missatges i estalviar viatges; restricció a la base com a xarxa de seguretat definitiva. Si només en poguessis triar una, tria la restricció: és l'única que no es pot burlar. Un CHECK sobreviu al teu codi, al teu ORM i al teu equip.

Associacions

// src/models-sql/associacions.js
// Un esdeveniment te moltes sessions; la FK viu a la taula 'sessions'.
Esdeveniment.hasMany(Sessio, { as: 'sessions', foreignKey: 'esdeveniment_id_ref', onDelete: 'CASCADE' });
Sessio.belongsTo(Esdeveniment, { as: 'esdeveniment', foreignKey: 'esdeveniment_id_ref' });
Usuari.hasMany(Comanda, { as: 'comandes', foreignKey: 'usuari_id' });
Comanda.belongsTo(Usuari, { as: 'usuari', foreignKey: 'usuari_id' });
Sessio.hasMany(Comanda, { as: 'comandes', foreignKey: 'sessio_id_ref' });
Comanda.hasMany(Entrada, { as: 'entrades', foreignKey: 'comanda_id', onDelete: 'CASCADE' });
Entrada.belongsTo(Comanda, { as: 'comanda', foreignKey: 'comanda_id' });

// Molts a molts: Sequelize fa servir (o crea) la taula intermedia indicada a through.
Esdeveniment.belongsToMany(Etiqueta, { through: 'esdeveniments_etiquetes', foreignKey: 'esdeveniment_id' });
Associació Clau forana Mètodes que afegeix
A.hasOne(B) / A.belongsTo(B) A B / a A a.getB(), a.setB(), a.createB()
A.hasMany(B) A B a.getBs(), a.addB(), a.removeB(), a.countBs()
A.belongsToMany(B) A la taula intermèdia a.getBs(), a.addB(), a.setBs(), a.hasB()
const esdeveniment = await Esdeveniment.findOne({ where: { esdevenimentId: 'evt-001' } });
// Consulta ADDICIONAL: equival a Sessio.findAll({ where: { esdeveniment_id_ref: esdeveniment.id } }).
const sessions = await esdeveniment.getSessions();
// createSessio insereix amb la FK ja emplenada. Molt convenient.
await esdeveniment.createSessio({ sessioId: 'ses-001-3', aforament: 400, venudes: 0, preuCentims: 2500 });

getSessions() és una altra consulta: si la crides dins d'un bucle sobre esdeveniments, acabes de reinventar l'N+1 de la lliçó anterior. La solució a Sequelize és include.

Consultes amb Sequelize

const { Op } = require('sequelize');

const publicats = await Esdeveniment.findAll({
  where: { estat: 'publicat', sala: 'Teatro Almendra' },
  attributes: ['esdevenimentId', 'titol', 'sala'],   // l'equivalent de la projeccio
  order: [['titol', 'ASC']], limit: 20,
});
const properes = await Sessio.findAll({
  where: {
    dataHora: { [Op.between]: [new Date('2026-03-01'), new Date('2026-03-16')] },
    // Comparar dues COLUMNES entre si: aqui es trivial, a Mongo calia $expr.
    venudes: { [Op.lt]: sequelize.col('aforament') },
  },
});

// findAndCountAll retorna files i total en una crida: ideal per paginar.
const { rows, count } = await Comanda.findAndCountAll({
  where: { usuariId: 7, estat: { [Op.ne]: 'anullat' } }, limit: 20, offset: 0,
});

Els operadors viuen a Op i es corresponen gairebé un a un amb els de MongoDB: Op.eq/Op.ne i les comparacions (gt, gte, lt, lte) són $eq, $ne, $gt…; Op.between, Op.in i Op.notIn són $gte+$lte, $in i $nin; Op.like/Op.iLike (que ignora majúscules) fan el paper de $regex; i Op.and, Op.or, Op.not són els lògics.

I ara la diferència clau davant de populate:

// include genera un JOIN de veritat: UNA SOLA consulta al motor.
const cataleg = await Esdeveniment.findAll({
  where: { estat: 'publicat' },
  include: [{
    association: 'sessions',
    // Es pot filtrar el PARE per columnes del fill: required converteix el
    // LEFT JOIN en INNER JOIN, descartant esdeveniments sense sessions lliures.
    where: { venudes: { [Op.lt]: sequelize.col('sessions.aforament') } },
    required: true,
    attributes: ['sessioId', 'dataHora', 'aforament', 'venudes', 'preuCentims'],
  }],
  order: [['titol', 'ASC']],
});

// Agregacions amb fn, col i literal: la recaptacio per sala de la 07-04.
// raw: true retorna objectes plans sense instanciar models: el lean() de Sequelize.
const perSala = await Sessio.findAll({
  attributes: [[col('esdeveniment.sala'), 'sala'],
    [fn('SUM', literal('venudes * preu_centims')), 'recaptacioCentims']],
  include: [{ association: 'esdeveniment', attributes: [], where: { estat: 'publicat' } }],
  group: [col('esdeveniment.sala')], raw: true,
});

Això és el que populate no pot fer. Amb include: una consulta, el motor uneix, filtra i ordena amb els seus índexs i el seu optimitzador, i pots filtrar l'esdeveniment per una condició sobre les seves sessions; amb populate eren dues consultes cosides a Node i filtrar el pare era impossible. A canvi, un include mal plantejat sobre tres relacions pot generar un producte cartesià enorme; per a això existeix separate: true.

SQL parametritzat i la injecció

Al mòdul 6 vam prometre tornar sobre la injecció. S'entén en dos blocs.

// MAI. Concatenar entrada de l'usuari en SQL.
const [files] = await sequelize.query(`SELECT * FROM esdeveniments WHERE sala = '${req.query.sala}'`);

Si el client envia sala=x' OR '1'='1, la sentència executada és SELECT * FROM esdeveniments WHERE sala = 'x' OR '1'='1' i retorna la taula sencera. Amb sala=x'; DROP TABLE entrades; -- el motor rep dues sentències i la segona destrueix dades. La fallada conceptual és que la dada de l'usuari s'ha convertit en codi SQL: el motor rep una cadena i no pot distingir quina part vas escriure tu i quina part l'atacant.

// BE. Parametritzat: la dada viatja SEPARADA de la sentencia.
const files = await sequelize.query(
  'SELECT * FROM esdeveniments WHERE sala = :sala AND estat = :estat',
  { replacements: { sala: req.query.sala, estat: 'publicat' }, type: sequelize.QueryTypes.SELECT },
);

Per què és immune, i no és "perquè escapa les cometes": amb paràmetres, el controlador envia la plantilla de la sentència per un costat i els valors per un altre. PostgreSQL analitza i planifica la sentència abans de conèixer els valors; quan arriben, l'arbre sintàctic ja està fixat i un valor només pot ocupar el forat d'un literal. x' OR '1'='1 es busca com el text literal d'una sala anomenada així, no com a lògica. És una separació estructural, no un filtratge de caràcters, i per això funciona fins i tot amb entrades que ningú no va preveure. Tot l'ORM parametritza per defecte: cada where, cada create, cada update genera SQL amb marcadors, així que mentre facis servir la seva API estàs protegit sense pensar-hi. El risc apareix en tres llocs: sequelize.query sense replacements, sequelize.literal amb dades d'usuari a dins, i qualsevol SQL construït amb plantilles de cadena. Un matís important: els paràmetres substitueixen valors, no identificadors, així que un ORDER BY dinàmic exigeix llista blanca.

// Ordenacio dinamica segura: validem contra una llista tancada.
const COLUMNES = new Set(['titol', 'sala', 'creat_el']);
const columna = COLUMNES.has(req.query.ordre) ? req.query.ordre : 'titol';
const esdeveniments = await Esdeveniment.findAll({ order: [[columna, req.query.dir === 'desc' ? 'DESC' : 'ASC']] });

L'ORM cobreix gairebé tot, però hi ha consultes —CTE recursives, funcions de finestra, INSERT ... ON CONFLICT afinats— que s'escriuen millor a mà. Baixar a SQL cru amb sequelize.query i substitucions no és un fracàs: és fer servir l'eina adequada. Això sí, encapsula-ho dins del repositori: que un controlador no vegi mai una cadena SQL.

El repositori alternatiu sobre Sequelize

Aquí l'arquitectura de la lliçó 07-01 cobra el premi: mateixa signatura pública, mateixa sortida —instàncies del domini—, un altre motor a sota.

// src/repositoris/esdeveniments-sql.js
'use strict';

const { Op } = require('sequelize');
const { Esdeveniment: ModelEsdeveniment } = require('../models-sql/esdeveniment.js');
const { Esdeveniment } = require('../domini/esdeveniment.js');

/** Tradueix una fila (amb les seves sessions) a la instancia del domini de sempre. */
function aDomini({ esdevenimentId, sessions = [], ...resta }) {
  return Esdeveniment.desDeJSON({ ...resta, id: esdevenimentId,
    sessions: sessions.map(({ sessioId, dataHora, ...dades }) => ({
      ...dades, id: sessioId, dataHora: new Date(dataHora).toISOString(),
    })),
  });
}

async function obtenirCataleg() {
  const files = await ModelEsdeveniment.findAll({
    where: { estat: { [Op.ne]: 'esborrany' } },
    include: [{ association: 'sessions' }],   // un JOIN, una sola consulta
    order: [['titol', 'ASC']],
  });
  return files.map((fila) => aDomini(fila.get({ plain: true })));
}

async function obtenirEsdevenimentPerId(esdevenimentId) {
  const fila = await ModelEsdeveniment.findOne({ where: { esdevenimentId }, include: ['sessions'] });
  return fila ? aDomini(fila.get({ plain: true })) : null;
}

module.exports = { obtenirCataleg, obtenirEsdevenimentPerId };
// src/repositoris/index.js — un unic interruptor per canviar de motor.
const repositoriEsdeveniments = configuracio.motorDades === 'postgres'
  ? require('./esdeveniments-sql.js')
  : require('./esdeveniments.js');
module.exports = { repositoriEsdeveniments };

Aquest interruptor és tota la prova que necessitàvem: els quatre controladors d'esdeveniments, les rutes, el middleware de validació, la jerarquia d'errors i el domini sencer funcionen igual amb MongoDB o amb PostgreSQL. L'abstracció que semblava burocràcia a la lliçó 07-01 acaba d'estalviar una reescriptura completa.

Mongoose davant de Sequelize: el criteri

Aspecte Mongoose (MongoDB) Sequelize (SQL)
Esquema De l'ODM; el motor no l'exigeix Del motor; obligatori i verificat
Dades imbricades Naturals (subdocuments) Taula a part, o JSONB
Relacions populate (consultes extra) o $lookup include = JOIN real, una consulta
Integritat referencial Teva Del motor, amb claus foranes
Restriccions tipus CHECK No existeixen Sí, infranquejables
Migracions Externes (migrate-mongo) sequelize-cli, integrades
Transaccions Multidocument, amb rèpliques Natives, de sèrie, madures
Agregació Canonada aggregate SQL: GROUP BY, finestres, CTE
Escalat / aprenentatge Sharding integrat; corba suau des de JS Rèpliques i particionat manual; cal aprendre SQL

El criteri, sense dogmes. Tria documental quan les teves dades són agregats autocontinguts, l'esquema evoluciona ràpid, el volum de lectura és alt i la forma varia entre elements. Tria relacional quan hi ha diners, invariants dures, moltes relacions creuades i informes complexos, i quan la correcció importa més que la flexibilitat. Escena Viva, honestament, és un cas de llibre per al model relacional: aforaments, pagaments, entrades nominals, comptabilitat. Ho hem fet a MongoDB perquè funciona i perquè cal saber-ho fer, i a la lliçó següent veurem les dues solucions a la sobrevenda, una per motor. Altres opcions que convé conèixer: Prisma, l'ORM modern més popular a Node, amb el seu propi llenguatge d'esquema, tipatge excel·lent i migracions molt curades, encara que s'allunya del SQL i les consultes complexes se li entravessen; i Knex, que no és un ORM sinó un constructor de consultes amb API encadenable, sense models ni màgia. Molts equips combinen un ORM per al CRUD i Knex o SQL cru per al que és difícil.

Errors Comuns i Consells

  • Cridar sync({ alter: true }) a l'arrencada. Un dia alterarà alguna cosa que no volies, en producció, sense registre. Migracions.
  • No indexar les claus foranes. PostgreSQL crea l'índex de la PK, però no el de les columnes FK: un JOIN sense ell recorre la taula sencera.
  • Fer servir FLOAT per a diners, o concatenar entrada d'usuari a sequelize.query: enters en cèntims i replacements, sempre.
  • Confiar només en les validacions del model. Se salten amb bulkCreate o SQL cru. Les restriccions del motor no.
  • Carregar relacions dins d'un bucle amb getSessions(): és l'N+1 amb un altre nom. Fes servir include.
  • Consell: activa logging: console.log en desenvolupament i llegeix el SQL que genera Sequelize —és la millor manera d'aprendre SQL i de detectar consultes absurdes—, i aprèn EXPLAIN ANALYZE de PostgreSQL: és l'explain() de la lliçó anterior amb més informació.

Exercicis

Exercici 1: modelar l'entrada

Escriu src/models-sql/entrada.js amb Model.init: codi (únic, amb validate.is per al format EV-<any>-<6 dígits>), comandaId, sessioIdRef, estat (ENUM, per defecte valida) i usadaEl (DATE, nul). Afegeix les associacions i digues quin mètode generat et donaria les entrades d'una comanda.

Exercici 2: arreglar una injecció

Aquest endpoint és vulnerable. Explica l'atac concret i reescriu-lo de forma segura, sabent que ordre pot valer titol o sala.

const { terme, ordre } = req.query;
const files = await sequelize.query(
  `SELECT * FROM esdeveniments WHERE titol LIKE '%${terme}%' ORDER BY ${ordre}`,
);

Solucions

Exercici 1.

Entrada.init({
  id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
  codi: { type: DataTypes.STRING(20), allowNull: false, unique: true,
    validate: { is: /^EV-\d{4}-\d{6}$/ } },
  comandaId: { type: DataTypes.INTEGER, allowNull: false, field: 'comanda_id' },
  sessioIdRef: { type: DataTypes.INTEGER, allowNull: false, field: 'sessio_id_ref' },
  estat: { type: DataTypes.ENUM('valida', 'usada', 'anullada'), allowNull: false,
    defaultValue: 'valida' },
  usadaEl: { type: DataTypes.DATE, field: 'usada_el' },
}, { sequelize, modelName: 'Entrada', tableName: 'entrades', updatedAt: false });

Amb Comanda.hasMany(Entrada, { as: 'entrades', foreignKey: 'comanda_id' }), el mètode és comanda.getEntrades(). Per a diverses comandes alhora, include en lloc del mètode, per no caure en N+1.

Exercici 2. Hi ha dues vulnerabilitats. A terme, un valor com x%' UNION SELECT id, correu, nom, rol, null, null FROM usuaris -- filtra la taula d'usuaris sencera. A ordre, titol; DROP TABLE entrades; -- injecta una segona sentència. I ordre no es pot parametritzar perquè és un identificador, no un valor: cal validar-lo contra una llista blanca.

const COLUMNES = new Set(['titol', 'sala']);
const columna = COLUMNES.has(req.query.ordre) ? req.query.ordre : 'titol';

const files = await sequelize.query(
  `SELECT esdeveniment_id, titol, sala FROM esdeveniments WHERE titol ILIKE :patro ORDER BY ${columna} ASC`,
  { replacements: { patro: `%${req.query.terme ?? ''}%` }, type: sequelize.QueryTypes.SELECT },
);

El % va dins del valor del paràmetre, no de la plantilla: així continua sent dada. I columna s'interpola només després d'haver estat validada contra un conjunt tancat.

Conclusió

Has vist el mateix domini dues vegades, i aquesta és la lliçó. Saps llegir i escriure un esquema relacional amb claus primàries i foranes, NOT NULL, UNIQUE i aquell CHECK (venudes <= aforament) que converteix una invariant de negoci en una llei del motor. Saps connectar Sequelize a PostgreSQL des de la configuració, per què sync() no pot trepitjar producció, com definir models amb tipus adequats —enters en cèntims, mai coma flotant—, per què conviuen les validacions del model i les restriccions de la base, com declarar associacions i quins mètodes generen, i com consultar amb where, include —un JOIN real d'una sola consulta, a diferència de populate—, agregacions i SQL cru quan cal. Has tancat la promesa del mòdul 6: la injecció s'evita separant la sentència de les dades, no escapant cometes. I sobretot, el patró repositori ha fet el que prometia: src/repositoris/esdeveniments-sql.js substitueix src/repositoris/esdeveniments.js amb un interruptor de configuració, i ni un sol controlador ni una sola classe del domini se n'ha assabentat.

Queda l'última lliçó del mòdul, i és la que esperaves des del mòdul 4. Migracions per versionar l'esquema com es versiona el codi, llavors per carregar per fi els 3 esdeveniments i les 7 sessions de dades/esdeveniments.json i jubilar el fitxer per sempre, i transaccions: què és ACID a la pràctica, com dos compradors concurrents exhaureixen l'última entrada, per què ni $inc ni una comprovació prèvia no basten, nivells d'aïllament, blocatge pessimista davant d'optimista, i la implementació definitiva de comprarEntrades que reserva aforament, crea la comanda i emet les entrades o no fa absolutament res.

Curs de Node.js: De Principiant a Avançat

Mòdul 1: Introducció a Node.js

Mòdul 2: Conceptes Bàsics

Mòdul 3: Sistema de Fitxers i E/S

Mòdul 4: HTTP i Servidors Web

Mòdul 5: NPM i Gestió de Paquets

Mòdul 6: Framework Express.js

Mòdul 7: Bases de Dades i ORMs

Mòdul 8: Autenticació i Autorització

Mòdul 9: Proves i Depuració

Mòdul 10: Temes Avançats

Mòdul 11: Desplegament i DevOps

Mòdul 12: Projectes del Món Real

© Copyright 2026. Tots els drets reservats