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 +78 -1
- package/dist/adapter/metal-orm/crud-dtos.js +12 -1
- package/dist/adapter/metal-orm/index.d.ts +1 -1
- package/dist/adapter/metal-orm/sort.js +6 -3
- package/dist/adapter/metal-orm/types.d.ts +27 -0
- package/package.json +1 -1
- package/src/adapter/metal-orm/crud-dtos.ts +13 -1
- package/src/adapter/metal-orm/index.ts +54 -53
- package/src/adapter/metal-orm/sort.ts +6 -3
- package/src/adapter/metal-orm/types.ts +175 -147
- package/tests/unit/crud-dtos.test.ts +150 -102
- package/tests/unit/parse-sort.test.ts +111 -0
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
|
-
|
|
49
|
+
const lower = raw?.toLowerCase();
|
|
50
|
+
if (lower === "desc") {
|
|
48
51
|
return "desc";
|
|
49
52
|
}
|
|
50
|
-
if (
|
|
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
|
@@ -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
|
-
|
|
77
|
+
const lower = raw?.toLowerCase();
|
|
78
|
+
if (lower === "desc") {
|
|
76
79
|
return "desc";
|
|
77
80
|
}
|
|
78
|
-
if (
|
|
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
|
});
|