mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 12:28:21 +00:00
feat(api): accept several yards on each end of a route filter
Bookings, contracts and train schedules all validated originYardId / destinationYardId (originStationId / destinationStationId) as a single @IsUUID and matched with `=`, so a list could be narrowed to exactly one lane. The filter bar can now ask for several stations per end, and each end independently, which needs the same on the server. @IdListParam() is the shared transform: one id, a comma-separated list, or a repeated query param, always landing as a string[]. It yields undefined rather than [] when nothing usable is left — a repository that branches on `?.length` can then never hand TypeORM an empty array, which compiles to the syntax error IN (). It stays backwards compatible with the single-value form, so existing deep links and saved views are unaffected. Matching moves to IN (:...ids) — for contracts inside the two existing EXISTS subqueries, which keeps meaning "has a route from one of these origins" AND "has a route to one of these destinations", not necessarily the same route. All three statements were EXPLAIN-validated against edr_dev.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateSync } from 'class-validator';
|
||||
|
||||
import { FilterBookingDto } from '../../modules/bookings/dto/filter-booking.dto';
|
||||
import { ListTrainSchedulesQueryDto } from '../../modules/train-scheduling/dto/list-train-schedules-query.dto';
|
||||
|
||||
/**
|
||||
* The route filters carry one id, `a,b`, or a repeated param, and the
|
||||
* repositories then branch on `?.length` before emitting `IN (:...ids)`.
|
||||
* Two things have to hold or that breaks at runtime, not compile time:
|
||||
* the value must always arrive as an array (a bare string would make
|
||||
* `.length` count characters), and an absent/blank param must arrive as
|
||||
* `undefined`, never `[]` — TypeORM turns `[]` into the syntax error `IN ()`.
|
||||
*/
|
||||
// Real-shaped v4s: the variant nibble must be 8/9/a/b, so `1111…` is NOT a
|
||||
// valid UUID and would fail `@IsUUID` for reasons that have nothing to do
|
||||
// with the list transform under test.
|
||||
const A = '0a5d4b1e-1b2c-4d3e-8f90-1234567890ab';
|
||||
const B = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||
|
||||
const parse = <T>(cls: new () => T, query: Record<string, unknown>): T =>
|
||||
plainToInstance(cls, query);
|
||||
|
||||
describe('route id-list query params', () => {
|
||||
it('accepts a single id, still as an array', () => {
|
||||
const dto = parse(FilterBookingDto, { originYardId: A });
|
||||
expect(dto.originYardId).toEqual([A]);
|
||||
expect(validateSync(dto)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('splits a comma-separated list', () => {
|
||||
const dto = parse(FilterBookingDto, { originYardId: `${A}, ${B}` });
|
||||
expect(dto.originYardId).toEqual([A, B]);
|
||||
expect(validateSync(dto)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts the repeated-param form', () => {
|
||||
const dto = parse(ListTrainSchedulesQueryDto, { destinationStationId: [A, B] });
|
||||
expect(dto.destinationStationId).toEqual([A, B]);
|
||||
expect(validateSync(dto)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each([undefined, '', ','])('yields undefined, never [], for %p', (raw) => {
|
||||
expect(parse(FilterBookingDto, { originYardId: raw }).originYardId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves the two ends independent — one side set, the other absent', () => {
|
||||
const dto = parse(FilterBookingDto, { originYardId: A });
|
||||
expect(dto.originYardId).toEqual([A]);
|
||||
expect(dto.destinationYardId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still rejects a non-uuid inside the list', () => {
|
||||
const dto = parse(FilterBookingDto, { originYardId: `${A},not-a-uuid` });
|
||||
expect(validateSync(dto)).not.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
29
apps/edr-freight-api/src/common/dto/id-list.transform.ts
Normal file
29
apps/edr-freight-api/src/common/dto/id-list.transform.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
/**
|
||||
* A query param that carries one id, a comma-separated list (`a,b,c`), or the
|
||||
* same key repeated — and always lands on the DTO as a `string[]`.
|
||||
*
|
||||
* Two details matter:
|
||||
*
|
||||
* - It yields `undefined`, never `[]`, when nothing usable is left. `@IsOptional`
|
||||
* then short-circuits, and — more importantly — a repository that does
|
||||
* `if (ids?.length)` can never be handed an empty array, which TypeORM turns
|
||||
* into the syntax error `IN ()`.
|
||||
* - It is backwards compatible with the single-value form these params used to
|
||||
* take, so existing deep links and saved views keep working unchanged.
|
||||
*
|
||||
* Pair it with `@IsUUID(undefined, { each: true })` (or the relevant `each`
|
||||
* validator) — this only reshapes the value, it does not validate it.
|
||||
*/
|
||||
export const IdListParam = () =>
|
||||
Transform(({ value }: { value: unknown }) => {
|
||||
const raw = Array.isArray(value) ? value : [value];
|
||||
const ids = raw
|
||||
.flatMap((entry) =>
|
||||
entry === undefined || entry === null ? [] : String(entry).split(','),
|
||||
)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return ids.length ? ids : undefined;
|
||||
});
|
||||
Reference in New Issue
Block a user