Files
edr-platform/apps/edr-freight-api/src/modules/exports/export-query.builder.spec.ts
Nathnael 62f7b91315 feat(exports): dataset-driven table export, starting with bookings
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.
2026-08-20 05:29:04 +00:00

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([]);
});
});