adorn-api 1.1.4 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -353,7 +353,8 @@ export const {
353
353
  optionsDto: UserOptionsDto,
354
354
  errors: UserErrors,
355
355
  filterMappings: USER_FILTER_MAPPINGS,
356
- sortableColumns: USER_SORTABLE_COLUMNS
356
+ sortableColumns: USER_SORTABLE_COLUMNS,
357
+ listConfig: USER_LIST_CONFIG
357
358
  } = createMetalCrudDtoClasses(User, {
358
359
  mutationExclude: ["id", "createdAt"],
359
360
  query: {
@@ -530,6 +531,82 @@ Breaking changes summary:
530
531
  - Generated outputs now include `queryDto`, `optionsQueryDto`, `pagedResponseDto`, `optionDto`, `optionsDto`, `errors`, `filterMappings`, and `sortableColumns`.
531
532
  - Consumers no longer need internal `dist/...` imports for query/filter metadata types; all relevant types/utilities are publicly exported from `adorn-api`.
532
533
 
534
+ ### Using `listConfig` (Zero-Duplication Service Layer)
535
+
536
+ `createMetalCrudDtoClasses` now exposes a `listConfig` object that bundles all filter/sort/pagination config needed by your service layer. No more re-declaring mappings in your repository:
537
+
538
+ ```typescript
539
+ // user.controller.ts — using listConfig directly
540
+ import {
541
+ Controller, Get, Query, Returns,
542
+ parseFilter, parsePagination, parseSort,
543
+ type RequestContext
544
+ } from "adorn-api";
545
+ import { applyFilter, toPagedResponse } from "metal-orm";
546
+ import { createSession } from "./db";
547
+ import { User } from "./user.entity";
548
+ import {
549
+ UserQueryDto,
550
+ UserPagedResponseDto,
551
+ USER_LIST_CONFIG
552
+ } from "./user.dtos";
553
+
554
+ @Controller("/users")
555
+ export class UserController {
556
+ @Get("/")
557
+ @Query(UserQueryDto)
558
+ @Returns(UserPagedResponseDto)
559
+ async list(ctx: RequestContext<unknown, UserQueryDto>) {
560
+ const query = (ctx.query ?? {}) as Record<string, unknown>;
561
+ const { page, pageSize } = parsePagination(query, USER_LIST_CONFIG);
562
+ const filters = parseFilter(query, USER_LIST_CONFIG.filterMappings);
563
+ const sort = parseSort(query, USER_LIST_CONFIG.sortableColumns, {
564
+ defaultSortBy: USER_LIST_CONFIG.defaultSortBy,
565
+ defaultSortDirection: USER_LIST_CONFIG.defaultSortDirection
566
+ });
567
+ const direction = sort?.sortDirection === "desc" ? "DESC" : "ASC";
568
+
569
+ const session = createSession();
570
+ try {
571
+ const ormQuery = applyFilter(
572
+ User.select().orderBy(User.id, direction),
573
+ User,
574
+ filters
575
+ );
576
+ const paged = await ormQuery.executePaged(session, { page, pageSize });
577
+ return toPagedResponse(paged);
578
+ } finally {
579
+ await session.dispose();
580
+ }
581
+ }
582
+ }
583
+ ```
584
+
585
+ The `listConfig` object contains: `filterMappings`, `sortableColumns`, `defaultSortBy`, `defaultSortDirection`, `defaultPageSize`, `maxPageSize`, `sortByKey`, and `sortDirectionKey`.
586
+
587
+ ### Sort Order Compatibility (`sortOrder` / `sortDirection`)
588
+
589
+ `parseSort` now accepts both `sortDirection` (lowercase `asc`/`desc`) and `sortOrder` (uppercase `ASC`/`DESC`). This avoids the need for a custom helper when integrating with clients that send uppercase sort orders.
590
+
591
+ **Precedence**: `sortDirection` > `sortOrder` > default. Direction values are case-insensitive.
592
+
593
+ ```typescript
594
+ // Client sends: ?sortBy=name&sortOrder=DESC
595
+ const sort = parseSort(query, sortableColumns);
596
+ // → { sortBy: "name", sortDirection: "desc", field: "name" }
597
+
598
+ // Client sends both: ?sortBy=name&sortDirection=asc&sortOrder=DESC
599
+ const sort2 = parseSort(query, sortableColumns);
600
+ // → { sortBy: "name", sortDirection: "asc", field: "name" } (sortDirection wins)
601
+
602
+ // Custom sortOrder key:
603
+ const sort3 = parseSort({
604
+ query,
605
+ sortableColumns,
606
+ sortOrderKey: "order" // reads from query.order instead of query.sortOrder
607
+ });
608
+ ```
609
+
533
610
  ### Deep Relation Filters
534
611
 
535
612
  `parseFilter` now accepts nested relation paths, so you can filter deep chains like Alpha → Bravo → Charlie → Delta. If
@@ -161,6 +161,16 @@ function createMetalCrudDtoClasses(target, options = {}) {
161
161
  description: `${entityName} options response.`
162
162
  });
163
163
  const errors = buildStandardCrudErrors(entityName, errorOptions);
164
+ const listConfig = {
165
+ filterMappings,
166
+ sortableColumns,
167
+ defaultSortBy: queryOptions.defaultSortBy,
168
+ defaultSortDirection: queryOptions.defaultSortDirection ?? "asc",
169
+ defaultPageSize,
170
+ maxPageSize,
171
+ sortByKey,
172
+ sortDirectionKey
173
+ };
164
174
  return {
165
175
  response: crudClasses.response,
166
176
  create: crudClasses.create,
@@ -174,7 +184,8 @@ function createMetalCrudDtoClasses(target, options = {}) {
174
184
  optionsDto,
175
185
  errors,
176
186
  filterMappings,
177
- sortableColumns
187
+ sortableColumns,
188
+ listConfig
178
189
  };
179
190
  }
180
191
  function createNestedCreateDtoClass(target, overrides, options) {
@@ -9,4 +9,4 @@ export { createMetalDtoOverrides, type CreateMetalDtoOverridesOptions } from "./
9
9
  export { createErrorDtoClass, StandardErrorDto, SimpleErrorDto, BasicErrorDto } from "./error-dtos";
10
10
  export { withSession, parseIdOrThrow, compactUpdates, applyInput, getEntityOrThrow } from "./utils";
11
11
  export { validateEntityMetadata, hasValidEntityMetadata } from "./field-builder";
12
- export type { MetalDtoMode, MetalDtoOptions, MetalDtoTarget, PaginationConfig, PaginationOptions, ParsedPagination, Filter, FilterMapping, FilterFieldMapping, FilterFieldPath, FilterFieldPathArray, FilterFieldInput, RelationQuantifier, ParseFilterOptions, ParseSortOptions, ParsedSort, SortDirection, PagedQueryDtoOptions, PagedResponseDtoOptions, PagedFilterQueryDtoOptions, FilterFieldDef, MetalCrudQueryFilterDef, MetalCrudSortableColumns, MetalCrudOptionsQueryOptions, MetalCrudQueryOptions, MetalCrudStandardErrorsOptions, MetalCrudDtoOptions, MetalCrudDtoClassOptions, MetalCrudDtoDecorators, MetalCrudDtoClasses, MetalCrudDtoClassNameKey, MetalCrudDtoClassNames, RouteErrorsDecorator, NestedCreateDtoOptions, MetalTreeDtoClassOptions, MetalTreeDtoClasses, MetalTreeDtoClassNames, MetalTreeListEntryOptions, ErrorDtoOptions, CreateSessionFn } from "./types";
12
+ export type { MetalDtoMode, MetalDtoOptions, MetalDtoTarget, PaginationConfig, PaginationOptions, ParsedPagination, Filter, FilterMapping, FilterFieldMapping, FilterFieldPath, FilterFieldPathArray, FilterFieldInput, RelationQuantifier, ParseFilterOptions, ParseSortOptions, ParsedSort, SortDirection, ListConfig, PagedQueryDtoOptions, PagedResponseDtoOptions, PagedFilterQueryDtoOptions, FilterFieldDef, MetalCrudQueryFilterDef, MetalCrudSortableColumns, MetalCrudOptionsQueryOptions, MetalCrudQueryOptions, MetalCrudStandardErrorsOptions, MetalCrudDtoOptions, MetalCrudDtoClassOptions, MetalCrudDtoDecorators, MetalCrudDtoClasses, MetalCrudDtoClassNameKey, MetalCrudDtoClassNames, RouteErrorsDecorator, NestedCreateDtoOptions, MetalTreeDtoClassOptions, MetalTreeDtoClasses, MetalTreeDtoClassNames, MetalTreeListEntryOptions, ErrorDtoOptions, CreateSessionFn } from "./types";
@@ -16,6 +16,7 @@ function parseSort(queryOrOptions, sortableColumns, options) {
16
16
  }
17
17
  const sortByKey = resolved.sortByKey ?? "sortBy";
18
18
  const sortDirectionKey = resolved.sortDirectionKey ?? "sortDirection";
19
+ const sortOrderKey = resolved.sortOrderKey ?? "sortOrder";
19
20
  const defaultSortBy = resolved.defaultSortBy;
20
21
  const defaultDirection = resolved.defaultSortDirection ?? "asc";
21
22
  const requestedSortBy = toTrimmedString(query[sortByKey]);
@@ -23,7 +24,8 @@ function parseSort(queryOrOptions, sortableColumns, options) {
23
24
  if (!selectedSortBy) {
24
25
  return undefined;
25
26
  }
26
- const requestedDirection = toTrimmedString(query[sortDirectionKey]);
27
+ const requestedDirection = toTrimmedString(query[sortDirectionKey])
28
+ ?? toTrimmedString(query[sortOrderKey]);
27
29
  const sortDirection = normalizeDirection(requestedDirection, defaultDirection);
28
30
  return {
29
31
  sortBy: selectedSortBy,
@@ -44,10 +46,11 @@ function selectSortBy(requestedSortBy, defaultSortBy, allowed) {
44
46
  return undefined;
45
47
  }
46
48
  function normalizeDirection(raw, fallback) {
47
- if (raw === "desc") {
49
+ const lower = raw?.toLowerCase();
50
+ if (lower === "desc") {
48
51
  return "desc";
49
52
  }
50
- if (raw === "asc") {
53
+ if (lower === "asc") {
51
54
  return "asc";
52
55
  }
53
56
  return fallback;
@@ -119,6 +119,8 @@ export interface ParseSortOptions<T = Record<string, unknown>> {
119
119
  sortByKey?: string;
120
120
  /** Sort direction query key */
121
121
  sortDirectionKey?: string;
122
+ /** Query key for legacy sort order param (default: "sortOrder") */
123
+ sortOrderKey?: string;
122
124
  /** Default sort field */
123
125
  defaultSortBy?: string;
124
126
  /** Default sort direction */
@@ -135,6 +137,29 @@ export interface ParsedSort<T = Record<string, unknown>> {
135
137
  /** Resolved entity field */
136
138
  field?: FilterFieldInput<T>;
137
139
  }
140
+ /**
141
+ * Ready-to-use list/query configuration extracted from CRUD DTO class generation.
142
+ * Eliminates the need for consumers to reassemble filter/sort/pagination config
143
+ * in their service or repository layer.
144
+ */
145
+ export interface ListConfig<T = Record<string, unknown>> {
146
+ /** Execution-ready filter mappings for parseFilter */
147
+ filterMappings: Record<string, FilterMapping<T>>;
148
+ /** Execution-ready sortable column mappings for parseSort */
149
+ sortableColumns: MetalCrudSortableColumns<T>;
150
+ /** Default sort field key */
151
+ defaultSortBy?: string;
152
+ /** Default sort direction */
153
+ defaultSortDirection: SortDirection;
154
+ /** Default page size */
155
+ defaultPageSize: number;
156
+ /** Maximum page size */
157
+ maxPageSize: number;
158
+ /** Sort field query key */
159
+ sortByKey: string;
160
+ /** Sort direction query key */
161
+ sortDirectionKey: string;
162
+ }
138
163
  /**
139
164
  * Filter operator.
140
165
  */
@@ -364,6 +389,8 @@ export interface MetalCrudDtoClasses<T = Record<string, unknown>> {
364
389
  filterMappings: Record<string, FilterMapping<T>>;
365
390
  /** Execution-ready sortable column mappings */
366
391
  sortableColumns: MetalCrudSortableColumns<T>;
392
+ /** Ready-to-use config for list/query endpoints (combines filters, sort, pagination defaults) */
393
+ listConfig: ListConfig<T>;
367
394
  }
368
395
  /**
369
396
  * Metal Tree DTO class names.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adorn-api",
3
- "version": "1.1.4",
3
+ "version": "1.1.5",
4
4
  "description": "Decorator-first web framework with OpenAPI 3.1 schema generation.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -166,6 +166,17 @@ export function createMetalCrudDtoClasses<TEntity extends Record<string, unknown
166
166
 
167
167
  const errors = buildStandardCrudErrors(entityName, errorOptions);
168
168
 
169
+ const listConfig = {
170
+ filterMappings,
171
+ sortableColumns,
172
+ defaultSortBy: queryOptions.defaultSortBy,
173
+ defaultSortDirection: queryOptions.defaultSortDirection ?? "asc" as SortDirection,
174
+ defaultPageSize,
175
+ maxPageSize,
176
+ sortByKey,
177
+ sortDirectionKey
178
+ };
179
+
169
180
  return {
170
181
  response: crudClasses.response as DtoConstructor,
171
182
  create: crudClasses.create as DtoConstructor,
@@ -179,7 +190,8 @@ export function createMetalCrudDtoClasses<TEntity extends Record<string, unknown
179
190
  optionsDto,
180
191
  errors,
181
192
  filterMappings,
182
- sortableColumns
193
+ sortableColumns,
194
+ listConfig
183
195
  };
184
196
  }
185
197
 
@@ -1,17 +1,17 @@
1
- // Ensure standard decorator metadata is available for metal-orm transformers.
2
- const symbolMetadata = (Symbol as { metadata?: symbol }).metadata;
3
- if (!symbolMetadata) {
4
- (Symbol as { metadata?: symbol }).metadata = Symbol("Symbol.metadata");
5
- }
6
-
7
- export {
8
- MetalDto
9
- } from "./dto";
10
-
11
- export {
12
- parsePagination
13
- } from "./pagination";
14
-
1
+ // Ensure standard decorator metadata is available for metal-orm transformers.
2
+ const symbolMetadata = (Symbol as { metadata?: symbol }).metadata;
3
+ if (!symbolMetadata) {
4
+ (Symbol as { metadata?: symbol }).metadata = Symbol("Symbol.metadata");
5
+ }
6
+
7
+ export {
8
+ MetalDto
9
+ } from "./dto";
10
+
11
+ export {
12
+ parsePagination
13
+ } from "./pagination";
14
+
15
15
  export {
16
16
  parseFilter,
17
17
  createFilterMappings
@@ -20,13 +20,13 @@ export {
20
20
  export {
21
21
  parseSort
22
22
  } from "./sort";
23
-
24
- export {
25
- createPagedQueryDtoClass,
26
- createPagedResponseDtoClass,
27
- createPagedFilterQueryDtoClass
28
- } from "./paged-dtos";
29
-
23
+
24
+ export {
25
+ createPagedQueryDtoClass,
26
+ createPagedResponseDtoClass,
27
+ createPagedFilterQueryDtoClass
28
+ } from "./paged-dtos";
29
+
30
30
  export {
31
31
  createMetalCrudDtos,
32
32
  createMetalCrudDtoClasses,
@@ -37,38 +37,38 @@ export {
37
37
  createMetalTreeDtoClasses
38
38
  } from "./tree-dtos";
39
39
 
40
- export {
41
- createMetalDtoOverrides,
42
- type CreateMetalDtoOverridesOptions
43
- } from "./convention-overrides";
44
-
45
- export {
46
- createErrorDtoClass,
47
- StandardErrorDto,
48
- SimpleErrorDto,
49
- BasicErrorDto
50
- } from "./error-dtos";
51
-
52
- export {
53
- withSession,
54
- parseIdOrThrow,
55
- compactUpdates,
56
- applyInput,
57
- getEntityOrThrow
58
- } from "./utils";
59
-
60
- export {
61
- validateEntityMetadata,
62
- hasValidEntityMetadata
63
- } from "./field-builder";
64
-
65
- export type {
66
- MetalDtoMode,
67
- MetalDtoOptions,
68
- MetalDtoTarget,
69
- PaginationConfig,
70
- PaginationOptions,
71
- ParsedPagination,
40
+ export {
41
+ createMetalDtoOverrides,
42
+ type CreateMetalDtoOverridesOptions
43
+ } from "./convention-overrides";
44
+
45
+ export {
46
+ createErrorDtoClass,
47
+ StandardErrorDto,
48
+ SimpleErrorDto,
49
+ BasicErrorDto
50
+ } from "./error-dtos";
51
+
52
+ export {
53
+ withSession,
54
+ parseIdOrThrow,
55
+ compactUpdates,
56
+ applyInput,
57
+ getEntityOrThrow
58
+ } from "./utils";
59
+
60
+ export {
61
+ validateEntityMetadata,
62
+ hasValidEntityMetadata
63
+ } from "./field-builder";
64
+
65
+ export type {
66
+ MetalDtoMode,
67
+ MetalDtoOptions,
68
+ MetalDtoTarget,
69
+ PaginationConfig,
70
+ PaginationOptions,
71
+ ParsedPagination,
72
72
  Filter,
73
73
  FilterMapping,
74
74
  FilterFieldMapping,
@@ -80,6 +80,7 @@ export type {
80
80
  ParseSortOptions,
81
81
  ParsedSort,
82
82
  SortDirection,
83
+ ListConfig,
83
84
  PagedQueryDtoOptions,
84
85
  PagedResponseDtoOptions,
85
86
  PagedFilterQueryDtoOptions,
@@ -31,6 +31,7 @@ export function parseSort<T>(
31
31
 
32
32
  const sortByKey = resolved.sortByKey ?? "sortBy";
33
33
  const sortDirectionKey = resolved.sortDirectionKey ?? "sortDirection";
34
+ const sortOrderKey = resolved.sortOrderKey ?? "sortOrder";
34
35
  const defaultSortBy = resolved.defaultSortBy;
35
36
  const defaultDirection = resolved.defaultSortDirection ?? "asc";
36
37
 
@@ -40,7 +41,8 @@ export function parseSort<T>(
40
41
  return undefined;
41
42
  }
42
43
 
43
- const requestedDirection = toTrimmedString(query[sortDirectionKey]);
44
+ const requestedDirection = toTrimmedString(query[sortDirectionKey])
45
+ ?? toTrimmedString(query[sortOrderKey]);
44
46
  const sortDirection = normalizeDirection(requestedDirection, defaultDirection);
45
47
 
46
48
  return {
@@ -72,10 +74,11 @@ function normalizeDirection(
72
74
  raw: string | undefined,
73
75
  fallback: SortDirection
74
76
  ): SortDirection {
75
- if (raw === "desc") {
77
+ const lower = raw?.toLowerCase();
78
+ if (lower === "desc") {
76
79
  return "desc";
77
80
  }
78
- if (raw === "asc") {
81
+ if (lower === "asc") {
79
82
  return "asc";
80
83
  }
81
84
  return fallback;
@@ -1,14 +1,14 @@
1
-
1
+
2
2
  import type { BelongsToReference, HasManyCollection, HasOneReference, ManyToManyCollection } from "metal-orm";
3
3
  import type { DtoOptions, ErrorResponseOptions, FieldOverride } from "../../core/decorators";
4
4
  import type { SchemaNode } from "../../core/schema";
5
5
  import type { DtoConstructor } from "../../core/types";
6
-
7
- /**
8
- * Metal ORM DTO modes.
9
- */
10
- export type MetalDtoMode = "response" | "create" | "update";
11
-
6
+
7
+ /**
8
+ * Metal ORM DTO modes.
9
+ */
10
+ export type MetalDtoMode = "response" | "create" | "update";
11
+
12
12
  /**
13
13
  * Options for Metal ORM DTOs.
14
14
  * @extends DtoOptions
@@ -25,47 +25,47 @@ export interface MetalDtoOptions extends DtoOptions {
25
25
  /** Whether to throw errors instead of warnings for invalid metadata (default: false) */
26
26
  strict?: boolean;
27
27
  }
28
-
29
- /**
30
- * Metal ORM DTO target type.
31
- */
32
- export type MetalDtoTarget = Parameters<typeof import("metal-orm").getColumnMap>[0];
33
-
34
- /**
35
- * Pagination configuration.
36
- */
37
- export interface PaginationConfig {
38
- /** Default page size */
39
- defaultPageSize?: number;
40
- /** Maximum page size */
41
- maxPageSize?: number;
42
- }
43
-
44
- /**
45
- * Pagination parsing options.
46
- */
47
- export interface PaginationOptions {
48
- /** Minimum value */
49
- min?: number;
50
- /** Maximum value */
51
- max?: number;
52
- /** Whether to clamp values */
53
- clamp?: boolean;
54
- }
55
-
56
- /**
57
- * Parsed pagination result.
58
- */
59
- export interface ParsedPagination {
60
- /** Page number */
61
- page: number;
62
- /** Page size */
63
- pageSize: number;
64
- }
65
-
66
- /**
67
- * Filter field mapping.
68
- */
28
+
29
+ /**
30
+ * Metal ORM DTO target type.
31
+ */
32
+ export type MetalDtoTarget = Parameters<typeof import("metal-orm").getColumnMap>[0];
33
+
34
+ /**
35
+ * Pagination configuration.
36
+ */
37
+ export interface PaginationConfig {
38
+ /** Default page size */
39
+ defaultPageSize?: number;
40
+ /** Maximum page size */
41
+ maxPageSize?: number;
42
+ }
43
+
44
+ /**
45
+ * Pagination parsing options.
46
+ */
47
+ export interface PaginationOptions {
48
+ /** Minimum value */
49
+ min?: number;
50
+ /** Maximum value */
51
+ max?: number;
52
+ /** Whether to clamp values */
53
+ clamp?: boolean;
54
+ }
55
+
56
+ /**
57
+ * Parsed pagination result.
58
+ */
59
+ export interface ParsedPagination {
60
+ /** Page number */
61
+ page: number;
62
+ /** Page size */
63
+ pageSize: number;
64
+ }
65
+
66
+ /**
67
+ * Filter field mapping.
68
+ */
69
69
  export interface FilterFieldMapping {
70
70
  /** Maps query keys to field names */
71
71
  [queryKey: string]: string;
@@ -118,20 +118,20 @@ export type FilterFieldPathArray<T> = FilterFieldPathSegments<T>;
118
118
  * Supported filter field input types.
119
119
  */
120
120
  export type FilterFieldInput<T> = FilterFieldPath<T> | FilterFieldPathArray<T>;
121
-
122
- /**
123
- * Filter mapping configuration.
124
- */
121
+
122
+ /**
123
+ * Filter mapping configuration.
124
+ */
125
125
  export interface FilterMapping<T = Record<string, unknown>> {
126
126
  /** Field name */
127
127
  field: FilterFieldInput<T>;
128
128
  /** Filter operator */
129
129
  operator: FilterOperator;
130
130
  }
131
-
132
- /**
133
- * Options for parsing filters.
134
- */
131
+
132
+ /**
133
+ * Options for parsing filters.
134
+ */
135
135
  export interface ParseFilterOptions<T = Record<string, unknown>> {
136
136
  /** Query parameters */
137
137
  query?: Record<string, unknown>;
@@ -156,6 +156,8 @@ export interface ParseSortOptions<T = Record<string, unknown>> {
156
156
  sortByKey?: string;
157
157
  /** Sort direction query key */
158
158
  sortDirectionKey?: string;
159
+ /** Query key for legacy sort order param (default: "sortOrder") */
160
+ sortOrderKey?: string;
159
161
  /** Default sort field */
160
162
  defaultSortBy?: string;
161
163
  /** Default sort direction */
@@ -174,6 +176,30 @@ export interface ParsedSort<T = Record<string, unknown>> {
174
176
  field?: FilterFieldInput<T>;
175
177
  }
176
178
 
179
+ /**
180
+ * Ready-to-use list/query configuration extracted from CRUD DTO class generation.
181
+ * Eliminates the need for consumers to reassemble filter/sort/pagination config
182
+ * in their service or repository layer.
183
+ */
184
+ export interface ListConfig<T = Record<string, unknown>> {
185
+ /** Execution-ready filter mappings for parseFilter */
186
+ filterMappings: Record<string, FilterMapping<T>>;
187
+ /** Execution-ready sortable column mappings for parseSort */
188
+ sortableColumns: MetalCrudSortableColumns<T>;
189
+ /** Default sort field key */
190
+ defaultSortBy?: string;
191
+ /** Default sort direction */
192
+ defaultSortDirection: SortDirection;
193
+ /** Default page size */
194
+ defaultPageSize: number;
195
+ /** Maximum page size */
196
+ maxPageSize: number;
197
+ /** Sort field query key */
198
+ sortByKey: string;
199
+ /** Sort direction query key */
200
+ sortDirectionKey: string;
201
+ }
202
+
177
203
  /**
178
204
  * Filter operator.
179
205
  */
@@ -211,34 +237,34 @@ export type Filter<T, K extends keyof T> = {
211
237
  mode?: "default" | "insensitive";
212
238
  };
213
239
  };
214
-
215
- /**
216
- * Options for paged query DTOs.
217
- */
218
- export interface PagedQueryDtoOptions {
219
- /** Default page size */
220
- defaultPageSize?: number;
221
- /** Maximum page size */
222
- maxPageSize?: number;
223
- /** DTO name */
224
- name?: string;
225
- }
226
-
227
- /**
228
- * Options for paged response DTOs.
229
- */
230
- export interface PagedResponseDtoOptions {
231
- /** Item DTO constructor */
232
- itemDto: DtoConstructor;
233
- /** DTO description */
234
- description?: string;
235
- /** DTO name */
236
- name?: string;
237
- }
238
-
239
- /**
240
- * Filter field definition.
241
- */
240
+
241
+ /**
242
+ * Options for paged query DTOs.
243
+ */
244
+ export interface PagedQueryDtoOptions {
245
+ /** Default page size */
246
+ defaultPageSize?: number;
247
+ /** Maximum page size */
248
+ maxPageSize?: number;
249
+ /** DTO name */
250
+ name?: string;
251
+ }
252
+
253
+ /**
254
+ * Options for paged response DTOs.
255
+ */
256
+ export interface PagedResponseDtoOptions {
257
+ /** Item DTO constructor */
258
+ itemDto: DtoConstructor;
259
+ /** DTO description */
260
+ description?: string;
261
+ /** DTO name */
262
+ name?: string;
263
+ }
264
+
265
+ /**
266
+ * Filter field definition.
267
+ */
242
268
  export interface FilterFieldDef {
243
269
  /** Field schema */
244
270
  schema?: SchemaNode;
@@ -320,18 +346,18 @@ export interface MetalCrudStandardErrorsOptions {
320
346
  /** 404 not found error config (set false to disable) */
321
347
  notFound?: false | ErrorResponseOptions;
322
348
  }
323
-
324
- /**
325
- * Options for paged filter query DTOs.
326
- * @extends PagedQueryDtoOptions
327
- */
328
- export interface PagedFilterQueryDtoOptions extends PagedQueryDtoOptions {
329
- /** Filter definitions */
330
- filters: Record<string, FilterFieldDef>;
331
- /** DTO name */
332
- name?: string;
333
- }
334
-
349
+
350
+ /**
351
+ * Options for paged filter query DTOs.
352
+ * @extends PagedQueryDtoOptions
353
+ */
354
+ export interface PagedFilterQueryDtoOptions extends PagedQueryDtoOptions {
355
+ /** Filter definitions */
356
+ filters: Record<string, FilterFieldDef>;
357
+ /** DTO name */
358
+ name?: string;
359
+ }
360
+
335
361
  /**
336
362
  * Options for Metal CRUD DTOs.
337
363
  */
@@ -361,26 +387,26 @@ export interface MetalCrudDtoOptions<T = Record<string, unknown>> {
361
387
  /** Whether to throw errors instead of warnings for invalid metadata (default: false) */
362
388
  strict?: boolean;
363
389
  }
364
-
365
- /**
366
- * Metal CRUD DTO decorators.
367
- */
368
- export interface MetalCrudDtoDecorators {
369
- /** Response DTO decorator */
370
- response: (target: DtoConstructor) => void;
371
- /** Create DTO decorator */
372
- create: (target: DtoConstructor) => void;
373
- /** Replace DTO decorator */
374
- replace: (target: DtoConstructor) => void;
375
- /** Update DTO decorator */
376
- update: (target: DtoConstructor) => void;
377
- /** Params DTO decorator */
378
- params: (target: DtoConstructor) => void;
379
- }
380
-
381
- /**
382
- * Metal CRUD DTO class names.
383
- */
390
+
391
+ /**
392
+ * Metal CRUD DTO decorators.
393
+ */
394
+ export interface MetalCrudDtoDecorators {
395
+ /** Response DTO decorator */
396
+ response: (target: DtoConstructor) => void;
397
+ /** Create DTO decorator */
398
+ create: (target: DtoConstructor) => void;
399
+ /** Replace DTO decorator */
400
+ replace: (target: DtoConstructor) => void;
401
+ /** Update DTO decorator */
402
+ update: (target: DtoConstructor) => void;
403
+ /** Params DTO decorator */
404
+ params: (target: DtoConstructor) => void;
405
+ }
406
+
407
+ /**
408
+ * Metal CRUD DTO class names.
409
+ */
384
410
  export type RouteErrorsDecorator = (
385
411
  value: unknown,
386
412
  context: ClassMethodDecoratorContext
@@ -401,7 +427,7 @@ export type MetalCrudDtoClassNameKey =
401
427
  * Metal CRUD DTO class names.
402
428
  */
403
429
  export type MetalCrudDtoClassNames = Partial<Record<MetalCrudDtoClassNameKey, string>>;
404
-
430
+
405
431
  /**
406
432
  * Options for Metal CRUD DTO classes.
407
433
  * @extends MetalCrudDtoOptions
@@ -414,19 +440,19 @@ export interface MetalCrudDtoClassOptions<T = Record<string, unknown>> extends M
414
440
  /** Whether to throw errors instead of warnings for invalid metadata (default: false) */
415
441
  strict?: boolean;
416
442
  }
417
-
418
- /**
419
- * Metal CRUD DTO classes.
420
- */
443
+
444
+ /**
445
+ * Metal CRUD DTO classes.
446
+ */
421
447
  export interface MetalCrudDtoClasses<T = Record<string, unknown>> {
422
448
  /** Response DTO class */
423
449
  response: DtoConstructor;
424
450
  /** Create DTO class */
425
451
  create: DtoConstructor;
426
- /** Replace DTO class */
427
- replace: DtoConstructor;
428
- /** Update DTO class */
429
- update: DtoConstructor;
452
+ /** Replace DTO class */
453
+ replace: DtoConstructor;
454
+ /** Update DTO class */
455
+ update: DtoConstructor;
430
456
  /** Params DTO class */
431
457
  params: DtoConstructor;
432
458
  /** Query DTO class (paged + filters + sort) */
@@ -445,6 +471,8 @@ export interface MetalCrudDtoClasses<T = Record<string, unknown>> {
445
471
  filterMappings: Record<string, FilterMapping<T>>;
446
472
  /** Execution-ready sortable column mappings */
447
473
  sortableColumns: MetalCrudSortableColumns<T>;
474
+ /** Ready-to-use config for list/query endpoints (combines filters, sort, pagination defaults) */
475
+ listConfig: ListConfig<T>;
448
476
  }
449
477
 
450
478
  /**
@@ -518,25 +546,25 @@ export interface MetalTreeDtoClasses {
518
546
  * @extends MetalDtoOptions
519
547
  */
520
548
  export interface NestedCreateDtoOptions extends MetalDtoOptions {
521
- /** Additional fields to exclude */
522
- additionalExclude?: string[];
523
- /** DTO name */
524
- name: string;
525
- /** Parent entity name */
526
- parentEntity?: string;
527
- }
528
-
529
- /**
530
- * Options for error DTOs.
531
- */
532
- export interface ErrorDtoOptions {
533
- /** Whether to include error details */
534
- withDetails?: boolean;
535
- /** Whether to include trace ID */
536
- includeTraceId?: boolean;
537
- }
538
-
539
- /**
540
- * Function type for creating ORM sessions.
541
- */
542
- export type CreateSessionFn = () => import("metal-orm").OrmSession;
549
+ /** Additional fields to exclude */
550
+ additionalExclude?: string[];
551
+ /** DTO name */
552
+ name: string;
553
+ /** Parent entity name */
554
+ parentEntity?: string;
555
+ }
556
+
557
+ /**
558
+ * Options for error DTOs.
559
+ */
560
+ export interface ErrorDtoOptions {
561
+ /** Whether to include error details */
562
+ withDetails?: boolean;
563
+ /** Whether to include trace ID */
564
+ includeTraceId?: boolean;
565
+ }
566
+
567
+ /**
568
+ * Function type for creating ORM sessions.
569
+ */
570
+ export type CreateSessionFn = () => import("metal-orm").OrmSession;
@@ -7,98 +7,98 @@ import type { FilterMapping } from "../../src/adapter/metal-orm/index";
7
7
  import { getDtoMeta } from "../../src/core/metadata";
8
8
  import { t } from "../../src/core/schema";
9
9
  import { Alphanumeric, Column, Email, Entity, Length, Pattern, PrimaryKey, col } from "metal-orm";
10
-
11
- describe("createMetalCrudDtos", () => {
12
- @Entity({ tableName: "crud_dto_entities" })
13
- class CrudDtoEntity {
14
- @PrimaryKey(col.autoIncrement(col.int()))
15
- id!: number;
16
-
17
- @Column(col.notNull(col.text()))
18
- name!: string;
19
-
20
- @Column(col.text())
21
- nickname?: string | null;
22
- }
23
-
24
- it("creates CRUD DTO decorators with defaults", () => {
25
- const crud = createMetalCrudDtos(CrudDtoEntity, {
26
- mutationExclude: ["id"]
27
- });
28
-
29
- @crud.response
30
- class CrudDto {}
31
-
32
- @crud.create
33
- class CreateCrudDto {}
34
-
35
- @crud.update
36
- class UpdateCrudDto {}
37
-
38
- @crud.params
39
- class CrudParamsDto {}
40
-
41
- const responseMeta = getDtoMeta(CrudDto);
42
- const createMeta = getDtoMeta(CreateCrudDto);
43
- const updateMeta = getDtoMeta(UpdateCrudDto);
44
- const paramsMeta = getDtoMeta(CrudParamsDto);
45
-
46
- expect(responseMeta?.fields.id).toBeDefined();
47
- expect(createMeta?.fields.id).toBeUndefined();
48
- expect(updateMeta?.fields.name?.optional).toBe(true);
49
- expect(Object.keys(paramsMeta?.fields ?? {})).toEqual(["id"]);
50
- });
51
-
52
- @Entity({ tableName: "transformer_entities" })
53
- class TransformerEntity {
54
- @PrimaryKey(col.autoIncrement(col.int()))
55
- id!: number;
56
-
57
- @Column(col.varchar(50))
58
- @Length({ min: 2, max: 10 })
59
- name!: string;
60
-
61
- @Column(col.text())
62
- @Pattern({ pattern: /^[A-Z]+$/ })
63
- code!: string;
64
-
65
- @Column(col.text())
66
- @Email()
67
- email!: string;
68
-
69
- @Column(col.text())
70
- @Alphanumeric({ allowHyphens: true })
71
- slug!: string;
72
- }
73
-
74
- it("maps transformer validators into string schemas", () => {
75
- const crud = createMetalCrudDtos(TransformerEntity);
76
-
77
- @crud.create
78
- class CreateTransformerDto {}
79
-
80
- const meta = getDtoMeta(CreateTransformerDto);
81
- expect((meta?.fields.email?.schema as any).format).toBe("email");
82
- expect((meta?.fields.name?.schema as any).minLength).toBe(2);
83
- expect((meta?.fields.name?.schema as any).maxLength).toBe(10);
84
- expect((meta?.fields.code?.schema as any).pattern).toBe("^[A-Z]+$");
85
- expect((meta?.fields.slug?.schema as any).pattern).toBe("^[a-zA-Z0-9-]*$");
86
- });
87
- });
88
-
89
- describe("createMetalCrudDtoClasses", () => {
90
- @Entity({ tableName: "crud_dto_class_entities" })
91
- class CrudDtoClassEntity {
92
- @PrimaryKey(col.autoIncrement(col.int()))
93
- id!: number;
94
-
95
- @Column(col.notNull(col.text()))
96
- name!: string;
97
-
98
- @Column(col.text())
99
- nickname?: string | null;
100
- }
101
-
10
+
11
+ describe("createMetalCrudDtos", () => {
12
+ @Entity({ tableName: "crud_dto_entities" })
13
+ class CrudDtoEntity {
14
+ @PrimaryKey(col.autoIncrement(col.int()))
15
+ id!: number;
16
+
17
+ @Column(col.notNull(col.text()))
18
+ name!: string;
19
+
20
+ @Column(col.text())
21
+ nickname?: string | null;
22
+ }
23
+
24
+ it("creates CRUD DTO decorators with defaults", () => {
25
+ const crud = createMetalCrudDtos(CrudDtoEntity, {
26
+ mutationExclude: ["id"]
27
+ });
28
+
29
+ @crud.response
30
+ class CrudDto {}
31
+
32
+ @crud.create
33
+ class CreateCrudDto {}
34
+
35
+ @crud.update
36
+ class UpdateCrudDto {}
37
+
38
+ @crud.params
39
+ class CrudParamsDto {}
40
+
41
+ const responseMeta = getDtoMeta(CrudDto);
42
+ const createMeta = getDtoMeta(CreateCrudDto);
43
+ const updateMeta = getDtoMeta(UpdateCrudDto);
44
+ const paramsMeta = getDtoMeta(CrudParamsDto);
45
+
46
+ expect(responseMeta?.fields.id).toBeDefined();
47
+ expect(createMeta?.fields.id).toBeUndefined();
48
+ expect(updateMeta?.fields.name?.optional).toBe(true);
49
+ expect(Object.keys(paramsMeta?.fields ?? {})).toEqual(["id"]);
50
+ });
51
+
52
+ @Entity({ tableName: "transformer_entities" })
53
+ class TransformerEntity {
54
+ @PrimaryKey(col.autoIncrement(col.int()))
55
+ id!: number;
56
+
57
+ @Column(col.varchar(50))
58
+ @Length({ min: 2, max: 10 })
59
+ name!: string;
60
+
61
+ @Column(col.text())
62
+ @Pattern({ pattern: /^[A-Z]+$/ })
63
+ code!: string;
64
+
65
+ @Column(col.text())
66
+ @Email()
67
+ email!: string;
68
+
69
+ @Column(col.text())
70
+ @Alphanumeric({ allowHyphens: true })
71
+ slug!: string;
72
+ }
73
+
74
+ it("maps transformer validators into string schemas", () => {
75
+ const crud = createMetalCrudDtos(TransformerEntity);
76
+
77
+ @crud.create
78
+ class CreateTransformerDto {}
79
+
80
+ const meta = getDtoMeta(CreateTransformerDto);
81
+ expect((meta?.fields.email?.schema as any).format).toBe("email");
82
+ expect((meta?.fields.name?.schema as any).minLength).toBe(2);
83
+ expect((meta?.fields.name?.schema as any).maxLength).toBe(10);
84
+ expect((meta?.fields.code?.schema as any).pattern).toBe("^[A-Z]+$");
85
+ expect((meta?.fields.slug?.schema as any).pattern).toBe("^[a-zA-Z0-9-]*$");
86
+ });
87
+ });
88
+
89
+ describe("createMetalCrudDtoClasses", () => {
90
+ @Entity({ tableName: "crud_dto_class_entities" })
91
+ class CrudDtoClassEntity {
92
+ @PrimaryKey(col.autoIncrement(col.int()))
93
+ id!: number;
94
+
95
+ @Column(col.notNull(col.text()))
96
+ name!: string;
97
+
98
+ @Column(col.text())
99
+ nickname?: string | null;
100
+ }
101
+
102
102
  it("builds ready-to-export DTO classes", () => {
103
103
  const classes = createMetalCrudDtoClasses(CrudDtoClassEntity, {
104
104
  mutationExclude: ["id"]
@@ -127,16 +127,16 @@ describe("createMetalCrudDtoClasses", () => {
127
127
  });
128
128
  expect(classes.sortableColumns).toEqual({});
129
129
  });
130
-
131
- it("applies custom name overrides", () => {
132
- const classes = createMetalCrudDtoClasses(CrudDtoClassEntity, {
133
- baseName: "Person",
134
- names: {
135
- response: "PersonDto",
136
- params: "PersonIdDto"
137
- }
138
- });
139
-
130
+
131
+ it("applies custom name overrides", () => {
132
+ const classes = createMetalCrudDtoClasses(CrudDtoClassEntity, {
133
+ baseName: "Person",
134
+ names: {
135
+ response: "PersonDto",
136
+ params: "PersonIdDto"
137
+ }
138
+ });
139
+
140
140
  expect(classes.response.name).toBe("PersonDto");
141
141
  expect(classes.params.name).toBe("PersonIdDto");
142
142
  expect(classes.create.name).toBe("CreatePersonDto");
@@ -197,4 +197,52 @@ describe("createMetalCrudDtoClasses", () => {
197
197
  });
198
198
  expect(typeof classes.errors).toBe("function");
199
199
  });
200
+
201
+ it("exposes listConfig with all query defaults ready for service layer", () => {
202
+ const classes = createMetalCrudDtoClasses(CrudDtoClassEntity, {
203
+ query: {
204
+ filters: {
205
+ nameContains: {
206
+ schema: t.string({ minLength: 1 }),
207
+ field: "name",
208
+ operator: "contains"
209
+ }
210
+ },
211
+ sortableColumns: {
212
+ name: "name",
213
+ nickname: "nickname"
214
+ },
215
+ defaultSortBy: "name",
216
+ defaultSortDirection: "desc",
217
+ defaultPageSize: 10,
218
+ maxPageSize: 50
219
+ }
220
+ });
221
+
222
+ expect(classes.listConfig).toEqual({
223
+ filterMappings: classes.filterMappings,
224
+ sortableColumns: { name: "name", nickname: "nickname" },
225
+ defaultSortBy: "name",
226
+ defaultSortDirection: "desc",
227
+ defaultPageSize: 10,
228
+ maxPageSize: 50,
229
+ sortByKey: "sortBy",
230
+ sortDirectionKey: "sortDirection"
231
+ });
232
+ });
233
+
234
+ it("listConfig uses sensible defaults when query options are minimal", () => {
235
+ const classes = createMetalCrudDtoClasses(CrudDtoClassEntity);
236
+
237
+ expect(classes.listConfig).toEqual({
238
+ filterMappings: classes.filterMappings,
239
+ sortableColumns: {},
240
+ defaultSortBy: undefined,
241
+ defaultSortDirection: "asc",
242
+ defaultPageSize: 25,
243
+ maxPageSize: 100,
244
+ sortByKey: "sortBy",
245
+ sortDirectionKey: "sortDirection"
246
+ });
247
+ });
200
248
  });
@@ -45,4 +45,115 @@ describe("parseSort", () => {
45
45
  field: "createdAt"
46
46
  });
47
47
  });
48
+
49
+ it("parses sortOrder with uppercase DESC", () => {
50
+ const result = parseSort({
51
+ query: { sortBy: "name", sortOrder: "DESC" },
52
+ sortableColumns: { name: "name" }
53
+ });
54
+ expect(result).toEqual({
55
+ sortBy: "name",
56
+ sortDirection: "desc",
57
+ field: "name"
58
+ });
59
+ });
60
+
61
+ it("parses sortOrder with uppercase ASC", () => {
62
+ const result = parseSort({
63
+ query: { sortBy: "name", sortOrder: "ASC" },
64
+ sortableColumns: { name: "name" }
65
+ });
66
+ expect(result).toEqual({
67
+ sortBy: "name",
68
+ sortDirection: "asc",
69
+ field: "name"
70
+ });
71
+ });
72
+
73
+ it("gives sortDirection precedence over sortOrder when both present", () => {
74
+ const result = parseSort({
75
+ query: { sortBy: "name", sortDirection: "asc", sortOrder: "DESC" },
76
+ sortableColumns: { name: "name" }
77
+ });
78
+ expect(result).toEqual({
79
+ sortBy: "name",
80
+ sortDirection: "asc",
81
+ field: "name"
82
+ });
83
+ });
84
+
85
+ it("falls back to sortOrder when sortDirection is absent", () => {
86
+ const result = parseSort({
87
+ query: { sortBy: "name", sortOrder: "desc" },
88
+ sortableColumns: { name: "name" }
89
+ });
90
+ expect(result).toEqual({
91
+ sortBy: "name",
92
+ sortDirection: "desc",
93
+ field: "name"
94
+ });
95
+ });
96
+
97
+ it("falls back to default when neither sortDirection nor sortOrder present", () => {
98
+ const result = parseSort({
99
+ query: { sortBy: "name" },
100
+ sortableColumns: { name: "name" },
101
+ defaultSortDirection: "desc"
102
+ });
103
+ expect(result).toEqual({
104
+ sortBy: "name",
105
+ sortDirection: "desc",
106
+ field: "name"
107
+ });
108
+ });
109
+
110
+ it("supports custom sortOrderKey", () => {
111
+ const result = parseSort({
112
+ query: { sortBy: "name", order: "DESC" },
113
+ sortableColumns: { name: "name" },
114
+ sortOrderKey: "order"
115
+ });
116
+ expect(result).toEqual({
117
+ sortBy: "name",
118
+ sortDirection: "desc",
119
+ field: "name"
120
+ });
121
+ });
122
+
123
+ it("ignores invalid sortOrder values and uses default", () => {
124
+ const result = parseSort({
125
+ query: { sortBy: "name", sortOrder: "INVALID" },
126
+ sortableColumns: { name: "name" },
127
+ defaultSortDirection: "asc"
128
+ });
129
+ expect(result).toEqual({
130
+ sortBy: "name",
131
+ sortDirection: "asc",
132
+ field: "name"
133
+ });
134
+ });
135
+
136
+ it("normalizes mixed-case sortDirection (e.g. Desc)", () => {
137
+ const result = parseSort({
138
+ query: { sortBy: "name", sortDirection: "Desc" },
139
+ sortableColumns: { name: "name" }
140
+ });
141
+ expect(result).toEqual({
142
+ sortBy: "name",
143
+ sortDirection: "desc",
144
+ field: "name"
145
+ });
146
+ });
147
+
148
+ it("works with positional args and sortOrder fallback", () => {
149
+ const result = parseSort(
150
+ { sortBy: "name", sortOrder: "DESC" },
151
+ { name: "name" }
152
+ );
153
+ expect(result).toEqual({
154
+ sortBy: "name",
155
+ sortDirection: "desc",
156
+ field: "name"
157
+ });
158
+ });
48
159
  });