Last active
October 31, 2025 19:04
-
-
Save brandonbryant12/7a8804cb77e6cb88d7dfe4b9b8b1617b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Absolutely—there’s a clean, idiomatic way to (a) **re‑use start/end date validation across many DTOs** and (b) **run unit + e2e in one Jest invocation with a single coverage report**. | |
| --- | |
| ## A. Re‑usable date‑range validation for query DTOs | |
| **Goal:** many endpoints accept `start` / `end` query params; we want: | |
| * parse strings → `Date` | |
| * validate each value | |
| * validate the **relationship** (`end >= start`, optional max range, etc.) | |
| * re‑use across DTOs | |
| ### 1) Create small, reusable cross‑field validators | |
| ```ts | |
| // src/common/validation/is-after.decorator.ts | |
| import { | |
| registerDecorator, | |
| ValidationOptions, | |
| ValidationArguments, | |
| ValidatorConstraint, | |
| ValidatorConstraintInterface, | |
| } from 'class-validator'; | |
| type Options = { allowEqual?: boolean }; | |
| @ValidatorConstraint({ name: 'IsAfter', async: false }) | |
| export class IsAfterConstraint implements ValidatorConstraintInterface { | |
| validate(value: unknown, args: ValidationArguments) { | |
| const [relatedProp, opts] = args.constraints as [string, Options?]; | |
| const other = (args.object as any)?.[relatedProp]; | |
| if (!value || !other) return true; // let @IsDate/@IsOptional handle presence/type | |
| const right = new Date(value as any).getTime(); | |
| const left = new Date(other as any).getTime(); | |
| if (Number.isNaN(left) || Number.isNaN(right)) return false; | |
| return opts?.allowEqual ? right >= left : right > left; | |
| } | |
| defaultMessage(args: ValidationArguments) { | |
| const [relatedProp, opts] = args.constraints as [string, Options?]; | |
| return `${args.property} must be ${opts?.allowEqual ? 'on or after' : 'after'} ${relatedProp}`; | |
| } | |
| } | |
| export function IsAfter(relatedProp: string, options?: Options & ValidationOptions) { | |
| return function (object: Object, propertyName: string) { | |
| registerDecorator({ | |
| target: object.constructor, | |
| propertyName, | |
| options, | |
| constraints: [relatedProp, options], | |
| validator: IsAfterConstraint, | |
| }); | |
| }; | |
| } | |
| ``` | |
| Optional: cap the range length (handy for analytics queries): | |
| ```ts | |
| // src/common/validation/max-range-days.decorator.ts | |
| import { | |
| registerDecorator, ValidationOptions, ValidationArguments, | |
| ValidatorConstraint, ValidatorConstraintInterface, | |
| } from 'class-validator'; | |
| @ValidatorConstraint({ name: 'MaxRangeDays', async: false }) | |
| export class MaxRangeDaysConstraint implements ValidatorConstraintInterface { | |
| validate(_: unknown, args: ValidationArguments) { | |
| const [days] = args.constraints as [number]; | |
| const obj = args.object as any; | |
| if (!obj?.start || !obj?.end) return true; | |
| const ms = new Date(obj.end).getTime() - new Date(obj.start).getTime(); | |
| if (Number.isNaN(ms)) return false; | |
| return ms <= days * 24 * 60 * 60 * 1000; | |
| } | |
| defaultMessage(args: ValidationArguments) { | |
| const [days] = args.constraints as [number]; | |
| return `date range must be ${days} days or less`; | |
| } | |
| } | |
| export function MaxRangeDays(days: number, options?: ValidationOptions) { | |
| return (object: Object, propertyName: string) => { | |
| registerDecorator({ | |
| target: object.constructor, | |
| propertyName, | |
| constraints: [days], | |
| options, | |
| validator: MaxRangeDaysConstraint, | |
| }); | |
| }; | |
| } | |
| ``` | |
| > This uses **class‑validator**’s custom decorators and constraints. It’s the standard way to express cross‑property rules (the validator receives the whole object via `args.object`). ([GitHub][1]) | |
| ### 2) A shared `DateRangeQueryDto` you can compose everywhere | |
| ```ts | |
| // src/common/dto/date-range.query.dto.ts | |
| import { IsDate, IsOptional } from 'class-validator'; | |
| import { Type } from 'class-transformer'; | |
| import { IsAfter } from '../validation/is-after.decorator'; | |
| import { MaxRangeDays } from '../validation/max-range-days.decorator'; | |
| export class DateRangeQueryDto { | |
| @IsOptional() | |
| @Type(() => Date) // transform before validation | |
| @IsDate() // validate the transformed value | |
| start?: Date; | |
| @IsOptional() | |
| @Type(() => Date) | |
| @IsDate() | |
| @IsAfter('start', { allowEqual: true, message: 'end must be on/after start' }) | |
| @MaxRangeDays(90, { message: 'date range cannot exceed 90 days' }) | |
| end?: Date; | |
| } | |
| ``` | |
| > With `ValidationPipe({ transform: true })`, `class-transformer` runs **before** validation, so you should validate as `@IsDate()` (not `@IsDateString()`), because by the time validation runs these are `Date` objects. If you really need to enforce ISO‑8601 **strings**, validate the raw string first or write a small transformer that rejects non‑ISO values. ([NestJS Documentation][2]) | |
| ### 3) Compose it into endpoint‑specific DTOs (no duplication) | |
| Use Nest’s *mapped types* to **compose** query DTOs: | |
| ```ts | |
| // src/orders/dto/orders.query.dto.ts | |
| import { IntersectionType } from '@nestjs/mapped-types'; | |
| import { DateRangeQueryDto } from '../../common/dto/date-range.query.dto'; | |
| import { PaginationQueryDto } from '../../common/dto/pagination.query.dto'; // example | |
| export class OrdersQueryDto extends IntersectionType( | |
| DateRangeQueryDto, | |
| PaginationQueryDto, | |
| ) {} | |
| ``` | |
| You can also `extends DateRangeQueryDto` if that fits better. `IntersectionType` is handy when you need to combine multiple DTOs without creating inheritance chains. ([NestJS Documentation][2]) | |
| > This pattern gives you one place to maintain the date logic and lets every controller share it via composition. | |
| ### 4) (Optional) DI inside validators | |
| If a validator needs a service (e.g., to check “date must be within an account’s billing window”), enable DI in class‑validator: | |
| ```ts | |
| // in main.ts after creating the app | |
| import { useContainer } from 'class-validator'; | |
| useContainer(app.select(AppModule), { fallbackOnErrors: true }); | |
| ``` | |
| Then mark your constraint with `@Injectable()` and inject services as usual. ([GitHub][1]) | |
| --- | |
| ## B. One Jest run, unified coverage for unit **and** e2e | |
| You don’t need two separate CLI runs. Use **Jest’s multi‑project runner**: define a **root** config that lists your unit and e2e configs in `projects`, then run Jest once with coverage enabled. | |
| ### 1) Root config (aggregator) | |
| ```ts | |
| // jest.config.ts | |
| import type { Config } from 'jest'; | |
| const config: Config = { | |
| // Run both suites in one go | |
| projects: [ | |
| '<rootDir>/jest.unit.config.ts', | |
| '<rootDir>/jest.e2e.config.ts', | |
| ], | |
| // Put coverage settings at the *root* so the report is unified | |
| collectCoverage: true, | |
| collectCoverageFrom: [ | |
| 'src/**/*.{ts,js}', | |
| '!src/main.ts', | |
| '!src/**/*.module.ts', | |
| ], | |
| coverageDirectory: '<rootDir>/coverage', | |
| coverageReporters: ['text', 'lcov', 'html', 'cobertura'], | |
| // Ensure both projects use the same provider to avoid mismatches | |
| coverageProvider: 'v8', // or 'babel' — pick one and use it in both child configs | |
| }; | |
| export default config; | |
| ``` | |
| Jest’s config supports a `projects` array for this exact use case; the CLI also has `--projects` to run multiple configs at once. Putting `collectCoverage*` at the top ensures a **single** coverage directory/output for the combined run. ([jestjs.io][3]) | |
| ### 2) Unit project | |
| ```ts | |
| // jest.unit.config.ts | |
| import type { Config } from 'jest'; | |
| const config: Config = { | |
| displayName: 'unit', | |
| testMatch: ['<rootDir>/src/**/*.spec.ts'], | |
| preset: 'ts-jest', | |
| testEnvironment: 'node', | |
| // Keep coverage flags here OFF; inherited from root | |
| // If you transform TS, align coverageProvider with root ('v8' or 'babel') | |
| // globals: { 'ts-jest': { tsconfig: '<rootDir>/tsconfig.spec.json' } }, | |
| }; | |
| export default config; | |
| ``` | |
| ### 3) E2E project | |
| ```ts | |
| // jest.e2e.config.ts | |
| import type { Config } from 'jest'; | |
| const config: Config = { | |
| displayName: 'e2e', | |
| testMatch: ['<rootDir>/test/**/*.e2e-spec.ts'], | |
| preset: 'ts-jest', | |
| testEnvironment: 'node', | |
| maxWorkers: 1, // avoid port collisions while spinning up Nest apps | |
| testTimeout: 30000, // e2e often needs more time | |
| // Same note as above on coverageProvider & ts-jest config if needed | |
| }; | |
| export default config; | |
| ``` | |
| ### 4) Scripts | |
| ```json | |
| { | |
| "scripts": { | |
| "test": "jest --config jest.config.ts", | |
| "test:cov": "jest --config jest.config.ts --coverage", | |
| "test:unit": "jest --config jest.unit.config.ts", | |
| "test:e2e": "jest --config jest.e2e.config.ts" | |
| } | |
| } | |
| ``` | |
| > Running `npm run test:cov` now executes **both** suites and produces a **single coverage report** in `coverage/`. ([jestjs.io][3]) | |
| #### If you must keep totally separate runs | |
| If for CI/scaling reasons you **must** execute unit and e2e in separate steps, output **JSON** coverage from each, then merge them with **nyc**: | |
| ```bash | |
| # run separately (each produces coverage/*coverage-final.json) | |
| jest -c jest.unit.config.ts --coverage --coverageReporters=json | |
| jest -c jest.e2e.config.ts --coverage --coverageReporters=json | |
| # merge → create one report | |
| npx nyc merge coverage coverage/coverage-merged.json | |
| # Tell nyc to generate HTML/LCOV from the merged JSON | |
| npx nyc report --temp-dir=coverage --reporter=lcov --reporter=html | |
| ``` | |
| `nyc merge` is the Istanbul‑JS supported way to combine coverage JSONs from multiple runs into a single report. ([GitHub][4]) | |
| --- | |
| ## Extra tips & gotchas | |
| * **Unknown query params**: keep your global `ValidationPipe` with `{ whitelist: true, forbidNonWhitelisted: true, transform: true }` so extraneous params 400, and date strings become `Date`. ([NestJS Documentation][2]) | |
| * **Date parsing**: `@Type(() => Date)` converts *before* validation; validate with `@IsDate()`. If you need strict ISO‑8601, either (a) validate the raw string first, or (b) implement a transform that rejects non‑ISO (using `validator.js`’s `isISO8601`) then returns a `Date`. ([GitHub][1]) | |
| * **Composing DTOs**: prefer `IntersectionType` from `@nestjs/mapped-types` to keep DTOs DRY and explicit. ([NestJS Documentation][2]) | |
| * **DI in validators**: enable `useContainer()` if a constraint needs services. ([GitHub][1]) | |
| --- | |
| ### TL;DR | |
| * Put `start`/`end` (and cross‑field rules) in a **shared DateRange DTO** + **custom decorators** (`@IsAfter`, `@MaxRangeDays`). Compose it into each endpoint’s DTO with `IntersectionType`. ([GitHub][1]) | |
| * Use a **single Jest run** with a **root `projects` config** and **root coverage settings** to get one unified coverage report. If you can’t run once, **merge** separate JSON coverages with `nyc merge`. ([jestjs.io][3]) | |
| If you paste a snippet of your current DTOs/Jest configs, I’ll tailor the exact code to your repo structure. | |
| [1]: https://github.com/typestack/class-validator "GitHub - typestack/class-validator: Decorator-based property validation for classes." | |
| [2]: https://docs.nestjs.com/techniques/validation "Validation | NestJS - A progressive Node.js framework" | |
| [3]: https://jestjs.io/docs/configuration "Configuring Jest · Jest" | |
| [4]: https://github.com/istanbuljs/nyc?utm_source=chatgpt.com "istanbuljs/nyc: the Istanbul command line interface" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Great twist—this is a perfect fit for a **reusable, configurable date‑range DTO** that (a) applies smart defaults and (b) still enforces the rules you spelled out. | |
| Below I show a drop‑in pattern that: | |
| * Reuses the same date‑range logic across many endpoints | |
| * Lets each endpoint **pass functions** to compute default `start`/`end` | |
| * Handles your truth table: | |
| * **no `start` & no `end`** → use `defaultStart()` and `defaultEnd()` | |
| * **`start` provided, `end` missing** → default `end` to **today** | |
| * **`end` provided, `start` missing** → **fail validation** | |
| * **both provided** → validate normally | |
| * Still keeps your **`ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })`** flow | |
| * Plays nicely with tests (E2E and “unit‑ish” with `pipe.transform`) | |
| --- | |
| ## 0) What we’ll build | |
| We’ll create: | |
| 1. Two tiny reusable validators: | |
| * `@IsAfter('start', { allowEqual: true })` → checks `end >= start` | |
| * `@IsValidDate()` → ensures it’s a `Date` and not `Invalid Date` | |
| 2. A **DTO factory (mixin)** you call with default functions: | |
| ```ts | |
| const Last30DaysRangeDto = buildDateRangeQueryDto({ | |
| defaultStart: () => subDays(new Date(), 30), | |
| defaultEnd: () => new Date(), | |
| }); | |
| ``` | |
| You then compose this DTO into your per‑route DTOs (or use it directly). | |
| --- | |
| ## 1) Reusable validators | |
| ```ts | |
| // src/common/validation/is-valid-date.decorator.ts | |
| import { registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator'; | |
| export function IsValidDate(options?: ValidationOptions) { | |
| return function (object: Object, propertyName: string) { | |
| registerDecorator({ | |
| name: 'IsValidDate', | |
| target: object.constructor, | |
| propertyName, | |
| options, | |
| validator: { | |
| validate(value: any) { | |
| if (value == null) return true; | |
| return value instanceof Date && !Number.isNaN(value.getTime()); | |
| }, | |
| defaultMessage(args: ValidationArguments) { | |
| return `${args.property} must be a valid date`; | |
| }, | |
| }, | |
| }); | |
| }; | |
| } | |
| ``` | |
| ```ts | |
| // src/common/validation/is-after.decorator.ts | |
| import { | |
| registerDecorator, ValidationOptions, ValidationArguments, | |
| ValidatorConstraint, ValidatorConstraintInterface, | |
| } from 'class-validator'; | |
| type Options = { allowEqual?: boolean }; | |
| @ValidatorConstraint({ name: 'IsAfter', async: false }) | |
| export class IsAfterConstraint implements ValidatorConstraintInterface { | |
| validate(value: unknown, args: ValidationArguments) { | |
| const [relatedProp, opts] = args.constraints as [string, Options?]; | |
| const other = (args.object as any)?.[relatedProp]; | |
| if (value == null || other == null) return true; | |
| const right = new Date(value as any).getTime(); | |
| const left = new Date(other as any).getTime(); | |
| if (Number.isNaN(left) || Number.isNaN(right)) return false; | |
| return opts?.allowEqual ? right >= left : right > left; | |
| } | |
| defaultMessage(args: ValidationArguments) { | |
| const [relatedProp, opts] = args.constraints as [string, Options?]; | |
| return `${args.property} must be ${opts?.allowEqual ? 'on or after' : 'after'} ${relatedProp}`; | |
| } | |
| } | |
| export function IsAfter(relatedProp: string, options?: Options & ValidationOptions) { | |
| return (object: Object, propertyName: string) => { | |
| registerDecorator({ | |
| target: object.constructor, | |
| propertyName, | |
| options, | |
| constraints: [relatedProp, options], | |
| validator: IsAfterConstraint, | |
| }); | |
| }; | |
| } | |
| ``` | |
| --- | |
| ## 2) The DTO factory with defaults | |
| > **Why a factory/mixin?** | |
| > It lets each endpoint **pass functions** for defaults (e.g., last 7 days vs last 30 days), keeps the logic DRY, and still works with Nest’s `ValidationPipe` transformation. | |
| ```ts | |
| // src/common/dto/date-range.mixin.ts | |
| import { Transform } from 'class-transformer'; | |
| import { | |
| IsDate, IsDefined, IsOptional, ValidateIf, | |
| } from 'class-validator'; | |
| import { IsAfter } from '../validation/is-after.decorator'; | |
| import { IsValidDate } from '../validation/is-valid-date.decorator'; | |
| export type DateRangeDefaults = { | |
| /** Default start date when both start & end are absent */ | |
| defaultStart: () => Date; | |
| /** Default end date when both are absent, or when only start is present */ | |
| defaultEnd?: (ctx: { start?: Date }) => Date; // defaults to () => new Date() | |
| }; | |
| function toDate(value: any): Date | undefined { | |
| if (value == null || value === '') return undefined; | |
| if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value; | |
| const d = new Date(value); | |
| return Number.isNaN(d.getTime()) ? undefined : d; | |
| } | |
| /** | |
| * Build a DTO class that: | |
| * - Uses defaults when neither param is provided | |
| * - Uses today as end when start is provided without end (via defaultEnd) | |
| * - Fails if end is provided without start | |
| * - Validates end >= start | |
| */ | |
| export function buildDateRangeQueryDto(defaults: DateRangeDefaults) { | |
| const defaultEnd = defaults.defaultEnd ?? (() => new Date()); | |
| class DateRangeQueryDto { | |
| // START | |
| @Transform(({ value, obj }) => { | |
| const start = toDate(value); | |
| const endRaw = toDate(obj?.end); | |
| // Case 1: both absent -> default start | |
| if (!start && !endRaw) return defaults.defaultStart(); | |
| // Case 2: end present but start missing -> leave undefined to FAIL via @IsDefined | |
| if (!start && endRaw) return undefined; | |
| // Case 3: start present -> keep it | |
| return start; | |
| }, { toClassOnly: true }) | |
| @IsOptional() // it's optional unless end is present (see ValidateIf+IsDefined below) | |
| @IsDate() | |
| @IsValidDate() | |
| @ValidateIf((o: DateRangeQueryDto) => o.end !== undefined) | |
| @IsDefined({ message: 'start is required when end is provided' }) | |
| start?: Date; | |
| // END | |
| @Transform(({ value, obj }) => { | |
| const end = toDate(value); | |
| const startRaw = toDate(obj?.start); | |
| // Case A: explicit end -> keep it | |
| if (end) return end; | |
| // Case B: start present and end missing -> default end (today by default) | |
| if (startRaw) return defaultEnd({ start: startRaw }); | |
| // Case C: both missing -> default end alongside default start | |
| return defaultEnd({ start: defaults.defaultStart() }); | |
| }, { toClassOnly: true }) | |
| @IsOptional() | |
| @IsDate() | |
| @IsValidDate() | |
| @IsAfter('start', { allowEqual: true, message: 'end must be on or after start' }) | |
| end?: Date; | |
| } | |
| return DateRangeQueryDto; | |
| } | |
| ``` | |
| > **Note on “today”:** `defaultEnd` defaults to `() => new Date()` (current moment). If you want *end of day*, you can pass `() => endOfDay(new Date())`. Likewise for *start of day* in `defaultStart`. | |
| --- | |
| ## 3) Using it in controllers (per‑route defaults) | |
| Pick the default window you want per endpoint: | |
| ```ts | |
| // src/orders/dto/orders.query.dto.ts | |
| import { IntersectionType } from '@nestjs/mapped-types'; | |
| import { buildDateRangeQueryDto } from '../../common/dto/date-range.mixin'; | |
| // import { PaginationQueryDto } from '../../common/dto/pagination.query.dto'; // example | |
| // Example: "last 30 days" through now | |
| const Last30DaysRangeDto = buildDateRangeQueryDto({ | |
| defaultStart: () => { | |
| const d = new Date(); | |
| d.setDate(d.getDate() - 30); | |
| return d; | |
| }, | |
| defaultEnd: () => new Date(), // or endOfDay(new Date()) | |
| }); | |
| export class OrdersQueryDto extends IntersectionType( | |
| Last30DaysRangeDto, | |
| // PaginationQueryDto, | |
| ) {} | |
| ``` | |
| Controller: | |
| ```ts | |
| // src/orders/orders.controller.ts | |
| import { Controller, Get, Query } from '@nestjs/common'; | |
| import { OrdersQueryDto } from './dto/orders.query.dto'; | |
| @Controller('orders') | |
| export class OrdersController { | |
| @Get() | |
| list(@Query() query: OrdersQueryDto) { | |
| // query.start and query.end are Dates with defaults applied & validated | |
| return { start: query.start, end: query.end }; | |
| } | |
| } | |
| ``` | |
| You can create multiple “preset” DTOs: | |
| ```ts | |
| const Last7DaysRangeDto = buildDateRangeQueryDto({ | |
| defaultStart: () => { | |
| const d = new Date(); | |
| d.setDate(d.getDate() - 7); | |
| return d; | |
| }, | |
| defaultEnd: () => new Date(), | |
| }); | |
| ``` | |
| --- | |
| ## 4) Behavior, spelled out | |
| * **No `start`, no `end`** | |
| → `start = defaultStart()`, `end = defaultEnd({ start })` | |
| * **`start` given, `end` missing** | |
| → `end = defaultEnd({ start })` (today by default) | |
| * **`end` given, `start` missing** | |
| → `start` remains `undefined` → `@ValidateIf(o => o.end !== undefined) @IsDefined()` | |
| → **400 Bad Request** (“start is required when end is provided”) | |
| * **Both given** | |
| → Validated as dates, plus `end >= start` via `@IsAfter('start', { allowEqual: true })` | |
| --- | |
| ## 5) Tests (quick examples) | |
| ### E2E (pipes run; defaults applied; failure on end‑only) | |
| ```ts | |
| // test/orders.e2e-spec.ts | |
| import { INestApplication, ValidationPipe } from '@nestjs/common'; | |
| import { Test } from '@nestjs/testing'; | |
| import * as request from 'supertest'; | |
| import { AppModule } from '../src/app.module'; | |
| describe('OrdersController (e2e)', () => { | |
| let app: INestApplication; | |
| beforeAll(async () => { | |
| const modRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); | |
| app = modRef.createNestApplication(); | |
| app.useGlobalPipes( | |
| new ValidationPipe({ | |
| transform: true, | |
| whitelist: true, | |
| forbidNonWhitelisted: true, | |
| transformOptions: { enableImplicitConversion: true }, | |
| }), | |
| ); | |
| await app.init(); | |
| }); | |
| afterAll(async () => app.close()); | |
| it('defaults both when absent', async () => { | |
| const res = await request(app.getHttpServer()).get('/orders').expect(200); | |
| // Expect ISO strings; assert they parse and that end is >= start. | |
| expect(new Date(res.body.start).getTime()).toBeLessThanOrEqual(new Date(res.body.end).getTime()); | |
| }); | |
| it('defaults end to today when only start is provided', async () => { | |
| const res = await request(app.getHttpServer()) | |
| .get('/orders?start=2025-10-01') | |
| .expect(200); | |
| expect(res.body.start).toBe('2025-10-01T00:00:00.000Z'); // if you normalize to startOfDay; otherwise just parse | |
| expect(new Date(res.body.end).getTime()).toBeGreaterThanOrEqual(new Date('2025-10-01').getTime()); | |
| }); | |
| it('fails when end is provided without start', async () => { | |
| const res = await request(app.getHttpServer()) | |
| .get('/orders?end=2025-10-30') | |
| .expect(400); | |
| expect(res.body.message).toEqual(expect.arrayContaining(['start is required when end is provided'])); | |
| }); | |
| }); | |
| ``` | |
| ### “Unit‑ish” (call the pipe manually) | |
| ```ts | |
| // test/date-range.pipe.spec.ts | |
| import { ValidationPipe, ArgumentMetadata } from '@nestjs/common'; | |
| import { buildDateRangeQueryDto } from '../src/common/dto/date-range.mixin'; | |
| describe('DateRange defaults via DTO transforms', () => { | |
| const DateRangeDto = buildDateRangeQueryDto({ | |
| defaultStart: () => { | |
| const d = new Date(); d.setDate(d.getDate() - 30); return d; | |
| }, | |
| defaultEnd: () => new Date(), | |
| }); | |
| const pipe = new ValidationPipe({ | |
| transform: true, | |
| whitelist: true, | |
| forbidNonWhitelisted: true, | |
| }); | |
| const meta: ArgumentMetadata = { type: 'query', metatype: DateRangeDto, data: '' }; | |
| it('applies both defaults when none provided', async () => { | |
| const dto = await pipe.transform({}, meta); | |
| expect(dto.start).toBeInstanceOf(Date); | |
| expect(dto.end).toBeInstanceOf(Date); | |
| expect(dto.end.getTime()).toBeGreaterThanOrEqual(dto.start.getTime()); | |
| }); | |
| it('defaults end when only start provided', async () => { | |
| const dto = await pipe.transform({ start: '2025-10-01' }, meta); | |
| expect(dto.end).toBeInstanceOf(Date); | |
| }); | |
| it('throws when end provided without start', async () => { | |
| await expect(pipe.transform({ end: '2025-10-30' }, meta)).rejects.toThrow(); | |
| }); | |
| }); | |
| ``` | |
| --- | |
| ## 6) Notes & tweaks | |
| * **Normalization**: If you want *start of day* / *end of day* behavior, encode that in `defaultStart()` / `defaultEnd()` (e.g., set hours to `00:00:00.000` and `23:59:59.999`). | |
| * **Max window**: If you also want “range must be ≤ N days”, add the earlier `@MaxRangeDays(N)` cross‑field validator to the end property. | |
| * **DI in validators**: If any cross‑field check needs a service, enable `useContainer(app.select(AppModule), { fallbackOnErrors: true })` in `main.ts` and mark your constraint `@Injectable()`. | |
| --- | |
| ### TL;DR | |
| Use a **DTO factory (mixin)** that sets defaults via `@Transform` and validates with small, reusable decorators. Endpoints pass **functions** to define `defaultStart()` and `defaultEnd()`. The rules you asked for are enforced, the logic is shared, and your existing `ValidationPipe` keeps doing the heavy lifting. | |
| If you share one of your actual controllers/DTOs, I can plug this in and tailor the default window & tests to your codebase. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Great twist—this is a perfect fit for a **reusable, configurable date‑range DTO** that (a) applies smart defaults and (b) still enforces the rules you spelled out. | |
| Below I show a drop‑in pattern that: | |
| * Reuses the same date‑range logic across many endpoints | |
| * Lets each endpoint **pass functions** to compute default `start`/`end` | |
| * Handles your truth table: | |
| * **no `start` & no `end`** → use `defaultStart()` and `defaultEnd()` | |
| * **`start` provided, `end` missing** → default `end` to **today** | |
| * **`end` provided, `start` missing** → **fail validation** | |
| * **both provided** → validate normally | |
| * Still keeps your **`ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })`** flow | |
| * Plays nicely with tests (E2E and “unit‑ish” with `pipe.transform`) | |
| --- | |
| ## 0) What we’ll build | |
| We’ll create: | |
| 1. Two tiny reusable validators: | |
| * `@IsAfter('start', { allowEqual: true })` → checks `end >= start` | |
| * `@IsValidDate()` → ensures it’s a `Date` and not `Invalid Date` | |
| 2. A **DTO factory (mixin)** you call with default functions: | |
| ```ts | |
| const Last30DaysRangeDto = buildDateRangeQueryDto({ | |
| defaultStart: () => subDays(new Date(), 30), | |
| defaultEnd: () => new Date(), | |
| }); | |
| ``` | |
| You then compose this DTO into your per‑route DTOs (or use it directly). | |
| --- | |
| ## 1) Reusable validators | |
| ```ts | |
| // src/common/validation/is-valid-date.decorator.ts | |
| import { registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator'; | |
| export function IsValidDate(options?: ValidationOptions) { | |
| return function (object: Object, propertyName: string) { | |
| registerDecorator({ | |
| name: 'IsValidDate', | |
| target: object.constructor, | |
| propertyName, | |
| options, | |
| validator: { | |
| validate(value: any) { | |
| if (value == null) return true; | |
| return value instanceof Date && !Number.isNaN(value.getTime()); | |
| }, | |
| defaultMessage(args: ValidationArguments) { | |
| return `${args.property} must be a valid date`; | |
| }, | |
| }, | |
| }); | |
| }; | |
| } | |
| ``` | |
| ```ts | |
| // src/common/validation/is-after.decorator.ts | |
| import { | |
| registerDecorator, ValidationOptions, ValidationArguments, | |
| ValidatorConstraint, ValidatorConstraintInterface, | |
| } from 'class-validator'; | |
| type Options = { allowEqual?: boolean }; | |
| @ValidatorConstraint({ name: 'IsAfter', async: false }) | |
| export class IsAfterConstraint implements ValidatorConstraintInterface { | |
| validate(value: unknown, args: ValidationArguments) { | |
| const [relatedProp, opts] = args.constraints as [string, Options?]; | |
| const other = (args.object as any)?.[relatedProp]; | |
| if (value == null || other == null) return true; | |
| const right = new Date(value as any).getTime(); | |
| const left = new Date(other as any).getTime(); | |
| if (Number.isNaN(left) || Number.isNaN(right)) return false; | |
| return opts?.allowEqual ? right >= left : right > left; | |
| } | |
| defaultMessage(args: ValidationArguments) { | |
| const [relatedProp, opts] = args.constraints as [string, Options?]; | |
| return `${args.property} must be ${opts?.allowEqual ? 'on or after' : 'after'} ${relatedProp}`; | |
| } | |
| } | |
| export function IsAfter(relatedProp: string, options?: Options & ValidationOptions) { | |
| return (object: Object, propertyName: string) => { | |
| registerDecorator({ | |
| target: object.constructor, | |
| propertyName, | |
| options, | |
| constraints: [relatedProp, options], | |
| validator: IsAfterConstraint, | |
| }); | |
| }; | |
| } | |
| ``` | |
| --- | |
| ## 2) The DTO factory with defaults | |
| > **Why a factory/mixin?** | |
| > It lets each endpoint **pass functions** for defaults (e.g., last 7 days vs last 30 days), keeps the logic DRY, and still works with Nest’s `ValidationPipe` transformation. | |
| ```ts | |
| // src/common/dto/date-range.mixin.ts | |
| import { Transform } from 'class-transformer'; | |
| import { | |
| IsDate, IsDefined, IsOptional, ValidateIf, | |
| } from 'class-validator'; | |
| import { IsAfter } from '../validation/is-after.decorator'; | |
| import { IsValidDate } from '../validation/is-valid-date.decorator'; | |
| export type DateRangeDefaults = { | |
| /** Default start date when both start & end are absent */ | |
| defaultStart: () => Date; | |
| /** Default end date when both are absent, or when only start is present */ | |
| defaultEnd?: (ctx: { start?: Date }) => Date; // defaults to () => new Date() | |
| }; | |
| function toDate(value: any): Date | undefined { | |
| if (value == null || value === '') return undefined; | |
| if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value; | |
| const d = new Date(value); | |
| return Number.isNaN(d.getTime()) ? undefined : d; | |
| } | |
| /** | |
| * Build a DTO class that: | |
| * - Uses defaults when neither param is provided | |
| * - Uses today as end when start is provided without end (via defaultEnd) | |
| * - Fails if end is provided without start | |
| * - Validates end >= start | |
| */ | |
| export function buildDateRangeQueryDto(defaults: DateRangeDefaults) { | |
| const defaultEnd = defaults.defaultEnd ?? (() => new Date()); | |
| class DateRangeQueryDto { | |
| // START | |
| @Transform(({ value, obj }) => { | |
| const start = toDate(value); | |
| const endRaw = toDate(obj?.end); | |
| // Case 1: both absent -> default start | |
| if (!start && !endRaw) return defaults.defaultStart(); | |
| // Case 2: end present but start missing -> leave undefined to FAIL via @IsDefined | |
| if (!start && endRaw) return undefined; | |
| // Case 3: start present -> keep it | |
| return start; | |
| }, { toClassOnly: true }) | |
| @IsOptional() // it's optional unless end is present (see ValidateIf+IsDefined below) | |
| @IsDate() | |
| @IsValidDate() | |
| @ValidateIf((o: DateRangeQueryDto) => o.end !== undefined) | |
| @IsDefined({ message: 'start is required when end is provided' }) | |
| start?: Date; | |
| // END | |
| @Transform(({ value, obj }) => { | |
| const end = toDate(value); | |
| const startRaw = toDate(obj?.start); | |
| // Case A: explicit end -> keep it | |
| if (end) return end; | |
| // Case B: start present and end missing -> default end (today by default) | |
| if (startRaw) return defaultEnd({ start: startRaw }); | |
| // Case C: both missing -> default end alongside default start | |
| return defaultEnd({ start: defaults.defaultStart() }); | |
| }, { toClassOnly: true }) | |
| @IsOptional() | |
| @IsDate() | |
| @IsValidDate() | |
| @IsAfter('start', { allowEqual: true, message: 'end must be on or after start' }) | |
| end?: Date; | |
| } | |
| return DateRangeQueryDto; | |
| } | |
| ``` | |
| > **Note on “today”:** `defaultEnd` defaults to `() => new Date()` (current moment). If you want *end of day*, you can pass `() => endOfDay(new Date())`. Likewise for *start of day* in `defaultStart`. | |
| --- | |
| ## 3) Using it in controllers (per‑route defaults) | |
| Pick the default window you want per endpoint: | |
| ```ts | |
| // src/orders/dto/orders.query.dto.ts | |
| import { IntersectionType } from '@nestjs/mapped-types'; | |
| import { buildDateRangeQueryDto } from '../../common/dto/date-range.mixin'; | |
| // import { PaginationQueryDto } from '../../common/dto/pagination.query.dto'; // example | |
| // Example: "last 30 days" through now | |
| const Last30DaysRangeDto = buildDateRangeQueryDto({ | |
| defaultStart: () => { | |
| const d = new Date(); | |
| d.setDate(d.getDate() - 30); | |
| return d; | |
| }, | |
| defaultEnd: () => new Date(), // or endOfDay(new Date()) | |
| }); | |
| export class OrdersQueryDto extends IntersectionType( | |
| Last30DaysRangeDto, | |
| // PaginationQueryDto, | |
| ) {} | |
| ``` | |
| Controller: | |
| ```ts | |
| // src/orders/orders.controller.ts | |
| import { Controller, Get, Query } from '@nestjs/common'; | |
| import { OrdersQueryDto } from './dto/orders.query.dto'; | |
| @Controller('orders') | |
| export class OrdersController { | |
| @Get() | |
| list(@Query() query: OrdersQueryDto) { | |
| // query.start and query.end are Dates with defaults applied & validated | |
| return { start: query.start, end: query.end }; | |
| } | |
| } | |
| ``` | |
| You can create multiple “preset” DTOs: | |
| ```ts | |
| const Last7DaysRangeDto = buildDateRangeQueryDto({ | |
| defaultStart: () => { | |
| const d = new Date(); | |
| d.setDate(d.getDate() - 7); | |
| return d; | |
| }, | |
| defaultEnd: () => new Date(), | |
| }); | |
| ``` | |
| --- | |
| ## 4) Behavior, spelled out | |
| * **No `start`, no `end`** | |
| → `start = defaultStart()`, `end = defaultEnd({ start })` | |
| * **`start` given, `end` missing** | |
| → `end = defaultEnd({ start })` (today by default) | |
| * **`end` given, `start` missing** | |
| → `start` remains `undefined` → `@ValidateIf(o => o.end !== undefined) @IsDefined()` | |
| → **400 Bad Request** (“start is required when end is provided”) | |
| * **Both given** | |
| → Validated as dates, plus `end >= start` via `@IsAfter('start', { allowEqual: true })` | |
| --- | |
| ## 5) Tests (quick examples) | |
| ### E2E (pipes run; defaults applied; failure on end‑only) | |
| ```ts | |
| // test/orders.e2e-spec.ts | |
| import { INestApplication, ValidationPipe } from '@nestjs/common'; | |
| import { Test } from '@nestjs/testing'; | |
| import * as request from 'supertest'; | |
| import { AppModule } from '../src/app.module'; | |
| describe('OrdersController (e2e)', () => { | |
| let app: INestApplication; | |
| beforeAll(async () => { | |
| const modRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); | |
| app = modRef.createNestApplication(); | |
| app.useGlobalPipes( | |
| new ValidationPipe({ | |
| transform: true, | |
| whitelist: true, | |
| forbidNonWhitelisted: true, | |
| transformOptions: { enableImplicitConversion: true }, | |
| }), | |
| ); | |
| await app.init(); | |
| }); | |
| afterAll(async () => app.close()); | |
| it('defaults both when absent', async () => { | |
| const res = await request(app.getHttpServer()).get('/orders').expect(200); | |
| // Expect ISO strings; assert they parse and that end is >= start. | |
| expect(new Date(res.body.start).getTime()).toBeLessThanOrEqual(new Date(res.body.end).getTime()); | |
| }); | |
| it('defaults end to today when only start is provided', async () => { | |
| const res = await request(app.getHttpServer()) | |
| .get('/orders?start=2025-10-01') | |
| .expect(200); | |
| expect(res.body.start).toBe('2025-10-01T00:00:00.000Z'); // if you normalize to startOfDay; otherwise just parse | |
| expect(new Date(res.body.end).getTime()).toBeGreaterThanOrEqual(new Date('2025-10-01').getTime()); | |
| }); | |
| it('fails when end is provided without start', async () => { | |
| const res = await request(app.getHttpServer()) | |
| .get('/orders?end=2025-10-30') | |
| .expect(400); | |
| expect(res.body.message).toEqual(expect.arrayContaining(['start is required when end is provided'])); | |
| }); | |
| }); | |
| ``` | |
| ### “Unit‑ish” (call the pipe manually) | |
| ```ts | |
| // test/date-range.pipe.spec.ts | |
| import { ValidationPipe, ArgumentMetadata } from '@nestjs/common'; | |
| import { buildDateRangeQueryDto } from '../src/common/dto/date-range.mixin'; | |
| describe('DateRange defaults via DTO transforms', () => { | |
| const DateRangeDto = buildDateRangeQueryDto({ | |
| defaultStart: () => { | |
| const d = new Date(); d.setDate(d.getDate() - 30); return d; | |
| }, | |
| defaultEnd: () => new Date(), | |
| }); | |
| const pipe = new ValidationPipe({ | |
| transform: true, | |
| whitelist: true, | |
| forbidNonWhitelisted: true, | |
| }); | |
| const meta: ArgumentMetadata = { type: 'query', metatype: DateRangeDto, data: '' }; | |
| it('applies both defaults when none provided', async () => { | |
| const dto = await pipe.transform({}, meta); | |
| expect(dto.start).toBeInstanceOf(Date); | |
| expect(dto.end).toBeInstanceOf(Date); | |
| expect(dto.end.getTime()).toBeGreaterThanOrEqual(dto.start.getTime()); | |
| }); | |
| it('defaults end when only start provided', async () => { | |
| const dto = await pipe.transform({ start: '2025-10-01' }, meta); | |
| expect(dto.end).toBeInstanceOf(Date); | |
| }); | |
| it('throws when end provided without start', async () => { | |
| await expect(pipe.transform({ end: '2025-10-30' }, meta)).rejects.toThrow(); | |
| }); | |
| }); | |
| ``` | |
| --- | |
| ## 6) Notes & tweaks | |
| * **Normalization**: If you want *start of day* / *end of day* behavior, encode that in `defaultStart()` / `defaultEnd()` (e.g., set hours to `00:00:00.000` and `23:59:59.999`). | |
| * **Max window**: If you also want “range must be ≤ N days”, add the earlier `@MaxRangeDays(N)` cross‑field validator to the end property. | |
| * **DI in validators**: If any cross‑field check needs a service, enable `useContainer(app.select(AppModule), { fallbackOnErrors: true })` in `main.ts` and mark your constraint `@Injectable()`. | |
| --- | |
| ### TL;DR | |
| Use a **DTO factory (mixin)** that sets defaults via `@Transform` and validates with small, reusable decorators. Endpoints pass **functions** to define `defaultStart()` and `defaultEnd()`. The rules you asked for are enforced, the logic is shared, and your existing `ValidationPipe` keeps doing the heavy lifting. | |
| If you share one of your actual controllers/DTOs, I can plug this in and tailor the default window & tests to your codebase. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Great twist—this is a perfect fit for a **reusable, configurable date‑range DTO** that (a) applies smart defaults and (b) still enforces the rules you spelled out. | |
| Below I show a drop‑in pattern that: | |
| * Reuses the same date‑range logic across many endpoints | |
| * Lets each endpoint **pass functions** to compute default `start`/`end` | |
| * Handles your truth table: | |
| * **no `start` & no `end`** → use `defaultStart()` and `defaultEnd()` | |
| * **`start` provided, `end` missing** → default `end` to **today** | |
| * **`end` provided, `start` missing** → **fail validation** | |
| * **both provided** → validate normally | |
| * Still keeps your **`ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })`** flow | |
| * Plays nicely with tests (E2E and “unit‑ish” with `pipe.transform`) | |
| --- | |
| ## 0) What we’ll build | |
| We’ll create: | |
| 1. Two tiny reusable validators: | |
| * `@IsAfter('start', { allowEqual: true })` → checks `end >= start` | |
| * `@IsValidDate()` → ensures it’s a `Date` and not `Invalid Date` | |
| 2. A **DTO factory (mixin)** you call with default functions: | |
| ```ts | |
| const Last30DaysRangeDto = buildDateRangeQueryDto({ | |
| defaultStart: () => subDays(new Date(), 30), | |
| defaultEnd: () => new Date(), | |
| }); | |
| ``` | |
| You then compose this DTO into your per‑route DTOs (or use it directly). | |
| --- | |
| ## 1) Reusable validators | |
| ```ts | |
| // src/common/validation/is-valid-date.decorator.ts | |
| import { registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator'; | |
| export function IsValidDate(options?: ValidationOptions) { | |
| return function (object: Object, propertyName: string) { | |
| registerDecorator({ | |
| name: 'IsValidDate', | |
| target: object.constructor, | |
| propertyName, | |
| options, | |
| validator: { | |
| validate(value: any) { | |
| if (value == null) return true; | |
| return value instanceof Date && !Number.isNaN(value.getTime()); | |
| }, | |
| defaultMessage(args: ValidationArguments) { | |
| return `${args.property} must be a valid date`; | |
| }, | |
| }, | |
| }); | |
| }; | |
| } | |
| ``` | |
| ```ts | |
| // src/common/validation/is-after.decorator.ts | |
| import { | |
| registerDecorator, ValidationOptions, ValidationArguments, | |
| ValidatorConstraint, ValidatorConstraintInterface, | |
| } from 'class-validator'; | |
| type Options = { allowEqual?: boolean }; | |
| @ValidatorConstraint({ name: 'IsAfter', async: false }) | |
| export class IsAfterConstraint implements ValidatorConstraintInterface { | |
| validate(value: unknown, args: ValidationArguments) { | |
| const [relatedProp, opts] = args.constraints as [string, Options?]; | |
| const other = (args.object as any)?.[relatedProp]; | |
| if (value == null || other == null) return true; | |
| const right = new Date(value as any).getTime(); | |
| const left = new Date(other as any).getTime(); | |
| if (Number.isNaN(left) || Number.isNaN(right)) return false; | |
| return opts?.allowEqual ? right >= left : right > left; | |
| } | |
| defaultMessage(args: ValidationArguments) { | |
| const [relatedProp, opts] = args.constraints as [string, Options?]; | |
| return `${args.property} must be ${opts?.allowEqual ? 'on or after' : 'after'} ${relatedProp}`; | |
| } | |
| } | |
| export function IsAfter(relatedProp: string, options?: Options & ValidationOptions) { | |
| return (object: Object, propertyName: string) => { | |
| registerDecorator({ | |
| target: object.constructor, | |
| propertyName, | |
| options, | |
| constraints: [relatedProp, options], | |
| validator: IsAfterConstraint, | |
| }); | |
| }; | |
| } | |
| ``` | |
| --- | |
| ## 2) The DTO factory with defaults | |
| > **Why a factory/mixin?** | |
| > It lets each endpoint **pass functions** for defaults (e.g., last 7 days vs last 30 days), keeps the logic DRY, and still works with Nest’s `ValidationPipe` transformation. | |
| ```ts | |
| // src/common/dto/date-range.mixin.ts | |
| import { Transform } from 'class-transformer'; | |
| import { | |
| IsDate, IsDefined, IsOptional, ValidateIf, | |
| } from 'class-validator'; | |
| import { IsAfter } from '../validation/is-after.decorator'; | |
| import { IsValidDate } from '../validation/is-valid-date.decorator'; | |
| export type DateRangeDefaults = { | |
| /** Default start date when both start & end are absent */ | |
| defaultStart: () => Date; | |
| /** Default end date when both are absent, or when only start is present */ | |
| defaultEnd?: (ctx: { start?: Date }) => Date; // defaults to () => new Date() | |
| }; | |
| function toDate(value: any): Date | undefined { | |
| if (value == null || value === '') return undefined; | |
| if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value; | |
| const d = new Date(value); | |
| return Number.isNaN(d.getTime()) ? undefined : d; | |
| } | |
| /** | |
| * Build a DTO class that: | |
| * - Uses defaults when neither param is provided | |
| * - Uses today as end when start is provided without end (via defaultEnd) | |
| * - Fails if end is provided without start | |
| * - Validates end >= start | |
| */ | |
| export function buildDateRangeQueryDto(defaults: DateRangeDefaults) { | |
| const defaultEnd = defaults.defaultEnd ?? (() => new Date()); | |
| class DateRangeQueryDto { | |
| // START | |
| @Transform(({ value, obj }) => { | |
| const start = toDate(value); | |
| const endRaw = toDate(obj?.end); | |
| // Case 1: both absent -> default start | |
| if (!start && !endRaw) return defaults.defaultStart(); | |
| // Case 2: end present but start missing -> leave undefined to FAIL via @IsDefined | |
| if (!start && endRaw) return undefined; | |
| // Case 3: start present -> keep it | |
| return start; | |
| }, { toClassOnly: true }) | |
| @IsOptional() // it's optional unless end is present (see ValidateIf+IsDefined below) | |
| @IsDate() | |
| @IsValidDate() | |
| @ValidateIf((o: DateRangeQueryDto) => o.end !== undefined) | |
| @IsDefined({ message: 'start is required when end is provided' }) | |
| start?: Date; | |
| // END | |
| @Transform(({ value, obj }) => { | |
| const end = toDate(value); | |
| const startRaw = toDate(obj?.start); | |
| // Case A: explicit end -> keep it | |
| if (end) return end; | |
| // Case B: start present and end missing -> default end (today by default) | |
| if (startRaw) return defaultEnd({ start: startRaw }); | |
| // Case C: both missing -> default end alongside default start | |
| return defaultEnd({ start: defaults.defaultStart() }); | |
| }, { toClassOnly: true }) | |
| @IsOptional() | |
| @IsDate() | |
| @IsValidDate() | |
| @IsAfter('start', { allowEqual: true, message: 'end must be on or after start' }) | |
| end?: Date; | |
| } | |
| return DateRangeQueryDto; | |
| } | |
| ``` | |
| > **Note on “today”:** `defaultEnd` defaults to `() => new Date()` (current moment). If you want *end of day*, you can pass `() => endOfDay(new Date())`. Likewise for *start of day* in `defaultStart`. | |
| --- | |
| ## 3) Using it in controllers (per‑route defaults) | |
| Pick the default window you want per endpoint: | |
| ```ts | |
| // src/orders/dto/orders.query.dto.ts | |
| import { IntersectionType } from '@nestjs/mapped-types'; | |
| import { buildDateRangeQueryDto } from '../../common/dto/date-range.mixin'; | |
| // import { PaginationQueryDto } from '../../common/dto/pagination.query.dto'; // example | |
| // Example: "last 30 days" through now | |
| const Last30DaysRangeDto = buildDateRangeQueryDto({ | |
| defaultStart: () => { | |
| const d = new Date(); | |
| d.setDate(d.getDate() - 30); | |
| return d; | |
| }, | |
| defaultEnd: () => new Date(), // or endOfDay(new Date()) | |
| }); | |
| export class OrdersQueryDto extends IntersectionType( | |
| Last30DaysRangeDto, | |
| // PaginationQueryDto, | |
| ) {} | |
| ``` | |
| Controller: | |
| ```ts | |
| // src/orders/orders.controller.ts | |
| import { Controller, Get, Query } from '@nestjs/common'; | |
| import { OrdersQueryDto } from './dto/orders.query.dto'; | |
| @Controller('orders') | |
| export class OrdersController { | |
| @Get() | |
| list(@Query() query: OrdersQueryDto) { | |
| // query.start and query.end are Dates with defaults applied & validated | |
| return { start: query.start, end: query.end }; | |
| } | |
| } | |
| ``` | |
| You can create multiple “preset” DTOs: | |
| ```ts | |
| const Last7DaysRangeDto = buildDateRangeQueryDto({ | |
| defaultStart: () => { | |
| const d = new Date(); | |
| d.setDate(d.getDate() - 7); | |
| return d; | |
| }, | |
| defaultEnd: () => new Date(), | |
| }); | |
| ``` | |
| --- | |
| ## 4) Behavior, spelled out | |
| * **No `start`, no `end`** | |
| → `start = defaultStart()`, `end = defaultEnd({ start })` | |
| * **`start` given, `end` missing** | |
| → `end = defaultEnd({ start })` (today by default) | |
| * **`end` given, `start` missing** | |
| → `start` remains `undefined` → `@ValidateIf(o => o.end !== undefined) @IsDefined()` | |
| → **400 Bad Request** (“start is required when end is provided”) | |
| * **Both given** | |
| → Validated as dates, plus `end >= start` via `@IsAfter('start', { allowEqual: true })` | |
| --- | |
| ## 5) Tests (quick examples) | |
| ### E2E (pipes run; defaults applied; failure on end‑only) | |
| ```ts | |
| // test/orders.e2e-spec.ts | |
| import { INestApplication, ValidationPipe } from '@nestjs/common'; | |
| import { Test } from '@nestjs/testing'; | |
| import * as request from 'supertest'; | |
| import { AppModule } from '../src/app.module'; | |
| describe('OrdersController (e2e)', () => { | |
| let app: INestApplication; | |
| beforeAll(async () => { | |
| const modRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); | |
| app = modRef.createNestApplication(); | |
| app.useGlobalPipes( | |
| new ValidationPipe({ | |
| transform: true, | |
| whitelist: true, | |
| forbidNonWhitelisted: true, | |
| transformOptions: { enableImplicitConversion: true }, | |
| }), | |
| ); | |
| await app.init(); | |
| }); | |
| afterAll(async () => app.close()); | |
| it('defaults both when absent', async () => { | |
| const res = await request(app.getHttpServer()).get('/orders').expect(200); | |
| // Expect ISO strings; assert they parse and that end is >= start. | |
| expect(new Date(res.body.start).getTime()).toBeLessThanOrEqual(new Date(res.body.end).getTime()); | |
| }); | |
| it('defaults end to today when only start is provided', async () => { | |
| const res = await request(app.getHttpServer()) | |
| .get('/orders?start=2025-10-01') | |
| .expect(200); | |
| expect(res.body.start).toBe('2025-10-01T00:00:00.000Z'); // if you normalize to startOfDay; otherwise just parse | |
| expect(new Date(res.body.end).getTime()).toBeGreaterThanOrEqual(new Date('2025-10-01').getTime()); | |
| }); | |
| it('fails when end is provided without start', async () => { | |
| const res = await request(app.getHttpServer()) | |
| .get('/orders?end=2025-10-30') | |
| .expect(400); | |
| expect(res.body.message).toEqual(expect.arrayContaining(['start is required when end is provided'])); | |
| }); | |
| }); | |
| ``` | |
| ### “Unit‑ish” (call the pipe manually) | |
| ```ts | |
| // test/date-range.pipe.spec.ts | |
| import { ValidationPipe, ArgumentMetadata } from '@nestjs/common'; | |
| import { buildDateRangeQueryDto } from '../src/common/dto/date-range.mixin'; | |
| describe('DateRange defaults via DTO transforms', () => { | |
| const DateRangeDto = buildDateRangeQueryDto({ | |
| defaultStart: () => { | |
| const d = new Date(); d.setDate(d.getDate() - 30); return d; | |
| }, | |
| defaultEnd: () => new Date(), | |
| }); | |
| const pipe = new ValidationPipe({ | |
| transform: true, | |
| whitelist: true, | |
| forbidNonWhitelisted: true, | |
| }); | |
| const meta: ArgumentMetadata = { type: 'query', metatype: DateRangeDto, data: '' }; | |
| it('applies both defaults when none provided', async () => { | |
| const dto = await pipe.transform({}, meta); | |
| expect(dto.start).toBeInstanceOf(Date); | |
| expect(dto.end).toBeInstanceOf(Date); | |
| expect(dto.end.getTime()).toBeGreaterThanOrEqual(dto.start.getTime()); | |
| }); | |
| it('defaults end when only start provided', async () => { | |
| const dto = await pipe.transform({ start: '2025-10-01' }, meta); | |
| expect(dto.end).toBeInstanceOf(Date); | |
| }); | |
| it('throws when end provided without start', async () => { | |
| await expect(pipe.transform({ end: '2025-10-30' }, meta)).rejects.toThrow(); | |
| }); | |
| }); | |
| ``` | |
| --- | |
| ## 6) Notes & tweaks | |
| * **Normalization**: If you want *start of day* / *end of day* behavior, encode that in `defaultStart()` / `defaultEnd()` (e.g., set hours to `00:00:00.000` and `23:59:59.999`). | |
| * **Max window**: If you also want “range must be ≤ N days”, add the earlier `@MaxRangeDays(N)` cross‑field validator to the end property. | |
| * **DI in validators**: If any cross‑field check needs a service, enable `useContainer(app.select(AppModule), { fallbackOnErrors: true })` in `main.ts` and mark your constraint `@Injectable()`. | |
| --- | |
| ### TL;DR | |
| Use a **DTO factory (mixin)** that sets defaults via `@Transform` and validates with small, reusable decorators. Endpoints pass **functions** to define `defaultStart()` and `defaultEnd()`. The rules you asked for are enforced, the logic is shared, and your existing `ValidationPipe` keeps doing the heavy lifting. | |
| If you share one of your actual controllers/DTOs, I can plug this in and tailor the default window & tests to your codebase. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment