mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Adds a parallel export system the reports module can also draw on. A dataset describes a table's exportable fields — including related-entity detail the list page never shows — and the engine assembles a query from whichever fields the caller picked. GET /exports catalog (metadata only; select/requires never ship) GET /exports/:key/count exact row count + per-format caps GET /exports/:key/download csv | xlsx | pdf Two invariants carry the design: - Every lazy join is a LEFT join, and ExportJoin has no 'kind' field to make anything else expressible. An inner join added because a checkbox was ticked would change the rowset, so two exports of the same filters would disagree on their row count. - Because of that, the count cannot depend on field selection, so /count runs base + alwaysJoin only and is exact rather than an estimate. Verified: count and the delivered file both report 223 rows. One-to-many relations (a booking's containers) aggregate in a correlated subquery rather than joining, so a row can never multiply. Export rides each dataset's existing view permission — no new permission keys and no seeder change. Sensitive columns are simply never declared as fields: raw gateway payloads, signature blobs, error dumps, raw jsonb snapshots, internal user UUIDs and review notes are all absent by construction. bookings ships 77 fields across 10 groups. scripts/validate-export-datasets.ts EXPLAINs every dataset's widest query, its count query, and each field on its own against the real database — the per-field pass is what catches a field referencing a join it forgot to declare, which otherwise only fails when that one field is picked alone.
84 lines
3.1 KiB
TypeScript
84 lines
3.1 KiB
TypeScript
/**
|
|
* EXPLAIN-validates every export dataset against the real database.
|
|
*
|
|
* CLAUDE.md hard rule: raw SQL must be validated against a real DB before it
|
|
* ships. Every dataset is hand-written SQL expressions over wide tables where
|
|
* column drift is documented history, so a typo is a runtime 500 no type-check
|
|
* can catch. This builds each dataset's WIDEST query (all fields selected, so
|
|
* every join and every subquery is exercised) plus its count query, and runs
|
|
* both through EXPLAIN.
|
|
*
|
|
* npx ts-node -r tsconfig-paths/register src/scripts/validate-export-datasets.ts
|
|
*/
|
|
import 'dotenv/config';
|
|
|
|
import AppDataSource from '../data-source';
|
|
import { buildExportCountQuery, buildExportQuery } from '../modules/exports/export-query.builder';
|
|
import { DATASETS } from '../modules/exports/export.registry';
|
|
|
|
async function main(): Promise<void> {
|
|
await AppDataSource.initialize();
|
|
let failed = 0;
|
|
|
|
for (const dataset of DATASETS) {
|
|
const ctx = { ds: AppDataSource, params: {}, directions: null };
|
|
|
|
const cases: [string, () => { sql: string; params: unknown[] }][] = [
|
|
[
|
|
`${dataset.key} (all ${dataset.fields.length} fields)`,
|
|
() => {
|
|
const qb = buildExportQuery(dataset, dataset.fields, ctx);
|
|
return { sql: qb.getQuery(), params: qb.getParameters() as unknown as unknown[] };
|
|
},
|
|
],
|
|
[
|
|
`${dataset.key} (count)`,
|
|
() => {
|
|
const qb = buildExportCountQuery(dataset, ctx);
|
|
return { sql: qb.getQuery(), params: qb.getParameters() as unknown as unknown[] };
|
|
},
|
|
],
|
|
];
|
|
|
|
// Each field ALONE. The all-fields query above cannot catch a field that
|
|
// references an alias it forgot to declare in `requires` — some other
|
|
// field's `requires` pulls that join in, so it only 42P01s when that one
|
|
// checkbox is ticked on its own. This is the check that finds it.
|
|
for (const field of dataset.fields) {
|
|
cases.push([
|
|
`${dataset.key}.${field.key}`,
|
|
() => {
|
|
const qb = buildExportQuery(dataset, [field], ctx);
|
|
return { sql: qb.getQuery(), params: qb.getParameters() as unknown as unknown[] };
|
|
},
|
|
]);
|
|
}
|
|
|
|
let fieldFailures = 0;
|
|
for (const [label, build] of cases) {
|
|
const isPerField = label.startsWith(`${dataset.key}.`);
|
|
try {
|
|
const { sql } = build();
|
|
// Parameters are all optional filters and unset here, so the generated
|
|
// SQL carries no placeholders — EXPLAIN it directly.
|
|
await AppDataSource.query(`EXPLAIN ${sql}`);
|
|
if (!isPerField) console.log(` ok ${label}`);
|
|
} catch (error) {
|
|
failed += 1;
|
|
if (isPerField) fieldFailures += 1;
|
|
console.error(` FAIL ${label}`);
|
|
console.error(` ${(error as Error).message.split('\n')[0]}`);
|
|
}
|
|
}
|
|
if (!fieldFailures) {
|
|
console.log(` ok ${dataset.key} (each of ${dataset.fields.length} fields alone)`);
|
|
}
|
|
}
|
|
|
|
await AppDataSource.destroy();
|
|
console.log(failed ? `\n${failed} query/queries failed.` : '\nAll export dataset SQL validated.');
|
|
process.exit(failed ? 1 : 0);
|
|
}
|
|
|
|
void main();
|