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.
93 lines
3.3 KiB
TypeScript
93 lines
3.3 KiB
TypeScript
import { resolveJoins } from './export-query.builder';
|
|
import { ExportDataset, ExportField } from './export.types';
|
|
|
|
const field = (key: string, requires?: string[]): ExportField => ({
|
|
key,
|
|
label: key,
|
|
type: 'string',
|
|
group: 'g',
|
|
select: `x.${key}`,
|
|
requires,
|
|
});
|
|
|
|
/** Entities are never dereferenced by resolveJoins — only the alias graph matters. */
|
|
const entity = {} as ExportDataset['joins'][number]['entity'];
|
|
|
|
const dataset = (
|
|
joins: ExportDataset['joins'],
|
|
alwaysJoin?: string[],
|
|
): ExportDataset =>
|
|
({
|
|
key: 'test',
|
|
joins,
|
|
alwaysJoin,
|
|
fields: [],
|
|
}) as unknown as ExportDataset;
|
|
|
|
describe('resolveJoins', () => {
|
|
it('pulls in only the joins the selected fields ask for', () => {
|
|
const ds = dataset([
|
|
{ alias: 'a', entity, on: 'a.id = b.a_id' },
|
|
{ alias: 'z', entity, on: 'z.id = b.z_id' },
|
|
]);
|
|
expect(resolveJoins(ds, [field('one', ['a'])]).map((j) => j.alias)).toEqual(['a']);
|
|
});
|
|
|
|
it('selecting nothing still applies alwaysJoin — the count query relies on this', () => {
|
|
const ds = dataset(
|
|
[
|
|
{ alias: 'a', entity, on: 'a.id = b.a_id' },
|
|
{ alias: 'z', entity, on: 'z.id = b.z_id' },
|
|
],
|
|
['a'],
|
|
);
|
|
expect(resolveJoins(ds, []).map((j) => j.alias)).toEqual(['a']);
|
|
});
|
|
|
|
it('resolves a transitive dependency, dependency first', () => {
|
|
const ds = dataset([
|
|
{ alias: 'ct', entity, on: 'ct.id = b.contract_id' },
|
|
{ alias: 'ctc', entity, on: 'ctc.id = ct.company_id', requires: ['ct'] },
|
|
]);
|
|
expect(resolveJoins(ds, [field('x', ['ctc'])]).map((j) => j.alias)).toEqual(['ct', 'ctc']);
|
|
});
|
|
|
|
it('resolves a multi-hop chain in order', () => {
|
|
const ds = dataset([
|
|
{ alias: 'a', entity, on: 'a.id = b.a_id' },
|
|
{ alias: 'bb', entity, on: 'bb.id = a.b_id', requires: ['a'] },
|
|
{ alias: 'cc', entity, on: 'cc.id = bb.c_id', requires: ['bb'] },
|
|
]);
|
|
expect(resolveJoins(ds, [field('x', ['cc'])]).map((j) => j.alias)).toEqual(['a', 'bb', 'cc']);
|
|
});
|
|
|
|
it('emits a shared join once, not per field that needs it', () => {
|
|
const ds = dataset([{ alias: 'a', entity, on: 'a.id = b.a_id' }]);
|
|
const joins = resolveJoins(ds, [field('one', ['a']), field('two', ['a'])]);
|
|
expect(joins.map((j) => j.alias)).toEqual(['a']);
|
|
});
|
|
|
|
it('does not duplicate a join already pulled in by alwaysJoin', () => {
|
|
const ds = dataset([{ alias: 'a', entity, on: 'a.id = b.a_id' }], ['a']);
|
|
expect(resolveJoins(ds, [field('one', ['a'])]).map((j) => j.alias)).toEqual(['a']);
|
|
});
|
|
|
|
it('throws on a cycle rather than looping forever', () => {
|
|
const ds = dataset([
|
|
{ alias: 'a', entity, on: 'a.id = bb.a_id', requires: ['bb'] },
|
|
{ alias: 'bb', entity, on: 'bb.id = a.b_id', requires: ['a'] },
|
|
]);
|
|
expect(() => resolveJoins(ds, [field('x', ['a'])])).toThrow(/join cycle/);
|
|
});
|
|
|
|
it('throws on an undeclared alias — a typo must fail loudly, not silently 42P01', () => {
|
|
const ds = dataset([{ alias: 'a', entity, on: 'a.id = b.a_id' }]);
|
|
expect(() => resolveJoins(ds, [field('x', ['ghost'])])).toThrow(/unknown join alias "ghost"/);
|
|
});
|
|
|
|
it('a field with no requires pulls in no joins at all', () => {
|
|
const ds = dataset([{ alias: 'a', entity, on: 'a.id = b.a_id' }]);
|
|
expect(resolveJoins(ds, [field('plain')])).toEqual([]);
|
|
});
|
|
});
|