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
- El món relacional en deu minuts, i per què aquí les sessions sí que són una taula
- L'esquema SQL d'Escena Viva
- Instal·lar Sequelize, connectar i per què
sync()està prohibit - Definir models i tipus de dades
- Validacions de model davant de restriccions de base de dades
- Associacions i consultes amb Sequelize
- SQL parametritzat i la injecció
- El repositori alternatiu sobre Sequelize
- 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
idautoincremental o unUUID. - 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_idinexistent, 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
JOINsense ell recorre la taula sencera. - Fer servir
FLOATper a diners, o concatenar entrada d'usuari asequelize.query: enters en cèntims ireplacements, sempre. - Confiar només en les validacions del model. Se salten amb
bulkCreateo SQL cru. Les restriccions del motor no. - Carregar relacions dins d'un bucle amb
getSessions(): és l'N+1 amb un altre nom. Fes servirinclude. - Consell: activa
logging: console.logen desenvolupament i llegeix el SQL que genera Sequelize —és la millor manera d'aprendre SQL i de detectar consultes absurdes—, i aprènEXPLAIN ANALYZEde 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
- Què és Node.js?
- Instal·lació i Configuració de l'Entorn
- El Teu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Modern per a Node.js
- El Projecte del Curs: la Plataforma Escena Viva
Mòdul 2: Conceptes Bàsics
- Arquitectura de Node.js
- El Bucle d'Esdeveniments (Event Loop)
- Callbacks i Programació Asíncrona
- Promeses i async/await
- Esdeveniments i EventEmitter
- Mòduls CommonJS i require()
- Mòduls ES i Interoperabilitat
Mòdul 3: Sistema de Fitxers i E/S
- Lectura i Escriptura de Fitxers
- El Mòdul fs a Fons
- Rutes Multiplataforma amb el Mòdul path
- Treballant amb Streams
- Streams de Transformació i pipeline
- Buffers i Dades Binàries
Mòdul 4: HTTP i Servidors Web
- Creant un Servidor HTTP Simple
- Gestió de Sol·licituds i Respostes
- Enrutament Manual
- Servint Fitxers Estàtics
- Rebent Dades: Cossos de Petició i JSON
- Consumint APIs Externes des de Node.js
Mòdul 5: NPM i Gestió de Paquets
- Introducció a NPM i package.json
- Instal·lació i Ús de Paquets
- Versionat Semàntic i package-lock
- Scripts d'npm i Automatització del Projecte
- Creació i Publicació de Paquets
- Seguretat i Manteniment de Dependències
Mòdul 6: Framework Express.js
- Introducció a Express.js
- Configuració d'una Aplicació Express
- Enrutament a Express
- Middleware
- Middleware de Tercers Essencials
- Validació de Dades d'Entrada
- Gestió d'Errors
Mòdul 7: Bases de Dades i ORMs
- Introducció a les Bases de Dades
- Usant MongoDB amb Mongoose
- Operacions CRUD
- Relacions, Poblat i Consultes Avançades
- Usant Bases de Dades SQL amb Sequelize
- Migracions, Transaccions i Dades de Prova
Mòdul 8: Autenticació i Autorització
- Introducció a l'Autenticació
- Registre d'Usuaris i Hash de Contrasenyes
- Sessions i Galetes amb Passport.js
- Autenticació amb JWT
- Control d'Accés Basat en Rols
- Bones Pràctiques de Seguretat en APIs
Mòdul 9: Proves i Depuració
- Introducció a les Proves
- Proves Unitàries amb Mocha i Chai
- Dobles de Prova amb Sinon
- Proves d'Integració
- Cobertura i Automatització de les Proves
- Depuració d'Aplicacions Node.js
Mòdul 10: Temes Avançats
- El Mòdul Cluster
- Fils de Treball (Worker Threads)
- Memòria Cau i Cues de Treball amb Redis
- Optimització del Rendiment
- Construcció d'APIs RESTful
- GraphQL amb Node.js
Mòdul 11: Desplegament i DevOps
- Configuració i Variables d'Entorn
- Registre i Monitoratge en Producció
- Usant PM2 per a la Gestió de Processos
- Empaquetatge amb Docker
- Desplegant a Heroku i Altres PaaS
- Integració i Desplegament Continus
