/** * 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();