You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
senzapaura_es/scripts/db/comprobar-guardas.mjs

224 lines
8.3 KiB

Fase 2: el esquema entero en PostgreSQL 52 tablas nuevas, una vista y las guardas que Drizzle no sabe expresar. El sitio sigue leyendo de los archivos: esto solo crea el sitio donde caben. El esquema pasa de archivo a carpeta —con el catálogo dentro pasaría de las dos mil líneas—, y el corte se hizo por rangos de línea para no reescribir los comentarios que explican decisiones. Se comprobó que era un no-op: «No schema changes, nothing to migrate». Lo que Drizzle no genera va escrito a mano al final de la migración, y es justo lo que impide que el modelo mienta: la clave foránea de las versiones contra su propia tabla, dos columnas generadas y una clave compuesta que impiden a la vez la versión de una versión y los ciclos, la vista `cancion_principal`, el disparador que exige que el reparto de autoría sume cien, el que impide ciclos en el linaje de géneros, y los índices parciales del estilo principal y de la arista principal. Y las guardas se comprueban intentando lo que deben rechazar, en `scripts/db/comprobar-guardas.mjs`. Que una restricción exista en `pg_constraint` no significa que impida nada. Tres cosas que salieron de hacerlo y no de suponerlo: - `creado_en` era obligatorio SIN valor por defecto en la base: lo ponía JavaScript al insertar con Drizzle, así que cualquier INSERT escrito a mano fallaba y la invariante la sostenía la aplicación, no la tabla. Ahora es `defaultNow()`. - `drizzle-kit migrate` se atasca con los cuerpos `$$` de plpgsql. La migración se aplicó con `scripts/db/probar-migracion.mjs`, que además dice en qué sentencia falla, y se anotó en su registro. - Dos pruebas pasaban por el motivo equivocado: una por clave duplicada en vez de por la suma, y otra porque un disparador aplazado no salta dentro de un savepoint que nunca se cierra. Las dos corregidas; una guarda dada por buena sin ejecutarse es peor que no tenerla. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 weeks ago
/**
* Comprueba que las guardas del esquema hacen lo que dicen.
*
* Que una restricción exista en `pg_constraint` no significa que impida nada:
* se prueba intentando lo que tiene que rechazar. Todo va dentro de una
* transacción que se deshace, así que no deja rastro.
*/
import fs from 'node:fs';
import pg from 'pg';
const DIARIO = '.diario-guardas.txt';
fs.writeFileSync(DIARIO, '');
const apuntar = (l) => fs.appendFileSync(DIARIO, l + '\n');
const c = new pg.Client({ connectionString: process.env.DATABASE_URL, query_timeout: 15_000 });
await c.connect();
await c.query('BEGIN');
const resumen = await c.query(`
select
(select count(*)::int from information_schema.tables
where table_schema = 'public' and table_type = 'BASE TABLE') as tablas,
(select count(*)::int from information_schema.views
where table_schema = 'public') as vistas,
(select count(*)::int from information_schema.table_constraints
where constraint_schema = 'public' and constraint_type = 'FOREIGN KEY') as fks,
(select count(*)::int from pg_trigger where not tgisinternal) as disparadores`);
const r = resumen.rows[0];
apuntar(
`tablas ${r.tablas} · vistas ${r.vistas} · claves foráneas ${r.fks} · disparadores ${r.disparadores}\n`
);
/** Prueba que algo se rechaza. Si pasa, es que la guarda no guarda. */
async function rechaza(nombre, sql, valores = []) {
await c.query('SAVEPOINT prueba');
try {
await c.query(sql, valores);
await c.query('ROLLBACK TO SAVEPOINT prueba');
apuntar(` ✗ ${nombre} — SE PERMITIÓ, y no debería`);
return false;
} catch (fallo) {
await c.query('ROLLBACK TO SAVEPOINT prueba');
const motivo = fallo.message.split('\n')[0].slice(0, 70);
apuntar(` ✓ ${nombre} — rechazado: ${motivo}`);
return true;
}
}
/**
* Como `rechaza`, pero forzando la comprobación de las restricciones
* aplazadas.
*
* El disparador del reparto de autoría es `DEFERRABLE INITIALLY DEFERRED`, y
* con razón: al insertar tres autores, los estados intermedios no suman cien y
* comprobarlo fila a fila haría imposible cualquier inserción. Pero eso
* significa que solo salta al cerrar la transacción, y una prueba que nunca
* cierra no lo despierta: la primera versión de esto daba por buena una guarda
* que no había llegado a ejecutarse.
*/
async function rechazaAlCerrar(nombre, preparar, sql) {
/*
* Conexión propia y transacción propia. Dentro de un savepoint,
* `SET CONSTRAINTS ALL IMMEDIATE` no despertaba los eventos aplazados y la
* prueba daba por bueno lo que el disparador sí rechaza —comprobado
* interrogándolo aparte—. Una guarda que solo se puede comprobar al cerrar
* necesita una transacción que se pueda cerrar.
*/
const otro = new pg.Client({ connectionString: process.env.DATABASE_URL, query_timeout: 15_000 });
await otro.connect();
await otro.query('BEGIN');
try {
for (const linea of preparar) await otro.query(linea);
await otro.query(sql);
await otro.query('SET CONSTRAINTS ALL IMMEDIATE');
apuntar(` ✗ ${nombre} — SE PERMITIÓ, y no debería`);
return false;
} catch (fallo) {
apuntar(
` ✓ ${nombre} — rechazado: ${fallo.message.split(String.fromCharCode(10))[0].slice(0, 70)}`
);
return true;
} finally {
await otro.query('ROLLBACK').catch(() => {});
await otro.end();
}
}
/** Y que lo legítimo sí pasa: una guarda que lo bloquea todo tampoco sirve. */
async function permite(nombre, sql, valores = []) {
await c.query('SAVEPOINT prueba');
try {
await c.query(sql, valores);
await c.query('RELEASE SAVEPOINT prueba');
apuntar(` ✓ ${nombre} — permitido, como debe`);
return true;
} catch (fallo) {
await c.query('ROLLBACK TO SAVEPOINT prueba');
apuntar(` ✗ ${nombre} — SE RECHAZÓ: ${fallo.message.split('\n')[0].slice(0, 70)}`);
return false;
}
}
/* --- Datos mínimos para poder probar ---------------------------------- */
await c.query(`insert into pais (codigo, nombre) values ('CU', 'Cuba')`);
await c.query(
`insert into estilo (id, slug, nombre, principal) values ('e1', 'baladas', 'Baladas', true)`
);
await c.query(
`insert into cancion (id, slug, titulo, estilo_id) values ('c1', 'como-si-nada', 'Como si nada', 'e1')`
);
await c.query(
`insert into cancion (id, slug, titulo, estilo_id, version_de_id, version_nombre)
values ('c2', 'como-si-nada-maqueta', 'Como si nada', 'e1', 'c1', 'Maqueta 2018')`
);
await c.query(`insert into persona (id, slug, nombre) values ('p1', 'juan', 'Juan')`);
await c.query(`insert into genero (id, slug, nombre) values ('g1', 'bolero', 'Bolero')`);
await c.query(`insert into genero (id, slug, nombre) values ('g2', 'son', 'Son cubano')`);
await c.query(`insert into genero (id, slug, nombre) values ('g3', 'bolero-son', 'Bolero-son')`);
apuntar('LAS VERSIONES');
await permite(
'una canción puede tener versiones',
`insert into cancion (id, slug, titulo, estilo_id, version_de_id)
values ('c3', 'orquestal', 'Como si nada', 'e1', 'c1')`
);
await rechaza(
'una versión no puede tener versiones',
`insert into cancion (id, slug, titulo, estilo_id, version_de_id)
values ('c4', 'x', 'X', 'e1', 'c2')`
);
await rechaza(
'una canción no es versión de sí misma',
`update cancion set version_de_id = 'c1' where id = 'c1'`
);
apuntar('\nLA VISTA DE PRINCIPALES');
const vista = await c.query('select count(*)::int n from cancion_principal');
const todas = await c.query('select count(*)::int n from cancion');
const principales = await c.query(
'select count(*)::int n from cancion where version_de_id is null'
);
const bien = vista.rows[0].n === principales.rows[0].n && vista.rows[0].n < todas.rows[0].n;
apuntar(
` ${bien ? '✓' : '✗'} cancion tiene ${todas.rows[0].n} filas y cancion_principal ${vista.rows[0].n}, que son las principales`
);
apuntar('\nEL LINAJE DE GÉNEROS');
await permite(
'el bolero-son deriva de dos géneros',
`insert into genero_origen (genero_id, origen_id, tipo, principal) values
('g3', 'g1', 'fusiona', true), ('g3', 'g2', 'fusiona', false)`
);
await rechaza(
'un género no deriva de sí mismo',
`insert into genero_origen (genero_id, origen_id, tipo) values ('g1', 'g1', 'deriva')`
);
await rechaza(
'no se admiten ciclos en el linaje',
`insert into genero_origen (genero_id, origen_id, tipo) values ('g1', 'g3', 'deriva')`
);
await rechaza(
'solo una arista principal por género',
`update genero_origen set principal = true where genero_id = 'g3' and origen_id = 'g2'`
);
apuntar('\nEL REPARTO DE AUTORÍA');
await permite(
'un reparto que suma cien pasa',
`insert into autoria (cancion_id, persona_id, rol, porcentaje)
values ('c1', 'p1', 'letra', 60), ('c1', 'p1', 'musica', 40)`
);
/*
* Sobre otra canción y con un rol libre, para que el rechazo venga de la suma y
* no de la clave primaria. La primera versión de esta prueba pasaba por el
* motivo equivocado —clave duplicada— y dejaba el disparador sin comprobar, que
* es peor que no tener prueba.
*/
await rechazaAlCerrar(
'un reparto que suma noventa, no',
[
`insert into estilo (id, slug, nombre) values ('z1', 'z', 'Z')`,
`insert into cancion (id, slug, titulo, estilo_id) values ('z2', 'z', 'Z', 'z1')`,
`insert into persona (id, slug, nombre) values ('z3', 'z', 'Z')`
],
`insert into autoria (cancion_id, persona_id, rol, porcentaje)
values ('z2', 'z3', 'letra', 90)`
);
await permite(
'y el de c3 sí, si llega a cien',
`insert into autoria (cancion_id, persona_id, rol, porcentaje)
values ('c3', 'p1', 'letra', 90), ('c3', 'p1', 'musica', 10)`
);
apuntar('\nEL ESTILO PRINCIPAL');
await rechaza(
'no puede haber dos estilos principales',
`insert into estilo (id, slug, nombre, principal) values ('e2', 'rock', 'Rock', true)`
);
await permite(
'los no principales, los que hagan falta',
`insert into estilo (id, slug, nombre) values ('e3', 'clasica', 'Clásica')`
);
apuntar('\nLAS FACTURAS');
await rechaza(
'una rectificativa tiene que decir a cuál rectifica',
`insert into factura (id, pedido_id, serie, numero, tipo, base_centimos, iva_tipo,
iva_cuota_centimos, total_centimos, datos_congelados, pais_codigo)
values ('f1', 'inexistente', 'A', 1, 'rectificativa', 100, 21, 21, 121, '{}', 'CU')`
);
apuntar('\nLA VALORACIÓN');
await rechaza(
'no se puede puntuar con un seis',
`insert into valoracion (usuario_id, cancion_slug, puntuacion)
values ('inexistente', 'x', 6)`
);
await c.query('ROLLBACK');
apuntar('\n(todo deshecho: la base queda como estaba)');
await c.end();

Powered by TurnKey Linux.