adorn-api 1.1.5 → 1.1.7
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 +138 -38
- package/dist/adapter/metal-orm/crud-controller.d.ts +16 -0
- package/dist/adapter/metal-orm/crud-controller.js +156 -0
- package/dist/adapter/metal-orm/index.d.ts +3 -1
- package/dist/adapter/metal-orm/index.js +6 -1
- package/dist/adapter/metal-orm/list.d.ts +11 -0
- package/dist/adapter/metal-orm/list.js +112 -0
- package/dist/adapter/metal-orm/types.d.ts +88 -1
- package/package.json +5 -3
- package/src/adapter/metal-orm/crud-controller.ts +188 -0
- package/src/adapter/metal-orm/index.ts +16 -0
- package/src/adapter/metal-orm/list.ts +168 -0
- package/src/adapter/metal-orm/types.ts +149 -1
- package/tests/metal-orm-integration/run-paged-list.test.ts +169 -0
- package/tests/unit/crud-controller-factory.test.ts +222 -0
- package/tests/unit/run-paged-list.test.ts +109 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { BelongsToReference, HasManyCollection, HasOneReference, ManyToManyCollection } from "metal-orm";
|
|
1
|
+
import type { BelongsToReference, ColumnDef, HasManyCollection, HasOneReference, ManyToManyCollection, OrmSession, PagedResponse, SelectQueryBuilder, TableDef } from "metal-orm";
|
|
2
2
|
import type { DtoOptions, ErrorResponseOptions, FieldOverride } from "../../core/decorators";
|
|
3
3
|
import type { SchemaNode } from "../../core/schema";
|
|
4
4
|
import type { DtoConstructor } from "../../core/types";
|
|
5
|
+
import type { RequestContext } from "../express/types";
|
|
5
6
|
/**
|
|
6
7
|
* Metal ORM DTO modes.
|
|
7
8
|
*/
|
|
@@ -137,6 +138,10 @@ export interface ParsedSort<T = Record<string, unknown>> {
|
|
|
137
138
|
/** Resolved entity field */
|
|
138
139
|
field?: FilterFieldInput<T>;
|
|
139
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Sort terms accepted by metal-orm execution helpers.
|
|
143
|
+
*/
|
|
144
|
+
export type CrudListSortTerm = ColumnDef | Record<string, unknown>;
|
|
140
145
|
/**
|
|
141
146
|
* Ready-to-use list/query configuration extracted from CRUD DTO class generation.
|
|
142
147
|
* Eliminates the need for consumers to reassemble filter/sort/pagination config
|
|
@@ -160,6 +165,45 @@ export interface ListConfig<T = Record<string, unknown>> {
|
|
|
160
165
|
/** Sort direction query key */
|
|
161
166
|
sortDirectionKey: string;
|
|
162
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Unified paged list execution options for metal-orm adapter.
|
|
170
|
+
*/
|
|
171
|
+
export interface RunPagedListOptions<TResult, TTable extends TableDef = TableDef, TTarget = unknown, TFilterTarget = Record<string, unknown>> extends PaginationConfig {
|
|
172
|
+
/** Raw request query */
|
|
173
|
+
query?: Record<string, unknown>;
|
|
174
|
+
/** Entity class or table used by applyFilter */
|
|
175
|
+
target: TTarget;
|
|
176
|
+
/** Base query builder or factory to create one */
|
|
177
|
+
qb: SelectQueryBuilder<TResult, TTable> | (() => SelectQueryBuilder<TResult, TTable>);
|
|
178
|
+
/** Active ORM session */
|
|
179
|
+
session: OrmSession;
|
|
180
|
+
/** Query key -> filter mapping */
|
|
181
|
+
filterMappings: Record<string, FilterMapping<TFilterTarget>>;
|
|
182
|
+
/** Query key -> field path mapping used by parseSort */
|
|
183
|
+
sortableColumns: Record<string, FilterFieldInput<TFilterTarget>>;
|
|
184
|
+
/** Optional explicit metal-orm sortable terms, overrides inferred table columns */
|
|
185
|
+
allowedSortColumns?: Record<string, CrudListSortTerm>;
|
|
186
|
+
/** Default sort field key */
|
|
187
|
+
defaultSortBy?: string;
|
|
188
|
+
/** Default sort direction */
|
|
189
|
+
defaultSortDirection?: SortDirection;
|
|
190
|
+
/** Sort field query key */
|
|
191
|
+
sortByKey?: string;
|
|
192
|
+
/** Sort direction query key */
|
|
193
|
+
sortDirectionKey?: string;
|
|
194
|
+
/** Legacy sort order query key (e.g. sortOrder=DESC) */
|
|
195
|
+
sortOrderKey?: string;
|
|
196
|
+
/** Optional stable tie-breaker column name */
|
|
197
|
+
tieBreakerColumn?: string;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Alias for runPagedList options.
|
|
201
|
+
*/
|
|
202
|
+
export type ExecuteCrudListOptions<TResult, TTable extends TableDef = TableDef, TTarget = unknown, TFilterTarget = Record<string, unknown>> = RunPagedListOptions<TResult, TTable, TTarget, TFilterTarget>;
|
|
203
|
+
/**
|
|
204
|
+
* Alias for runPagedList response.
|
|
205
|
+
*/
|
|
206
|
+
export type CrudPagedResponse<TResult> = PagedResponse<TResult>;
|
|
163
207
|
/**
|
|
164
208
|
* Filter operator.
|
|
165
209
|
*/
|
|
@@ -392,6 +436,49 @@ export interface MetalCrudDtoClasses<T = Record<string, unknown>> {
|
|
|
392
436
|
/** Ready-to-use config for list/query endpoints (combines filters, sort, pagination defaults) */
|
|
393
437
|
listConfig: ListConfig<T>;
|
|
394
438
|
}
|
|
439
|
+
/**
|
|
440
|
+
* Awaitable helper for CRUD service methods.
|
|
441
|
+
*/
|
|
442
|
+
export type Awaitable<T> = T | Promise<T>;
|
|
443
|
+
/**
|
|
444
|
+
* Input for CRUD controller service: class or ready instance.
|
|
445
|
+
*/
|
|
446
|
+
export type CrudControllerServiceInput<TDtos extends MetalCrudDtoClasses<any>> = CrudControllerService<TDtos> | (new () => CrudControllerService<TDtos>);
|
|
447
|
+
/**
|
|
448
|
+
* CRUD controller service contract used by createCrudController.
|
|
449
|
+
*/
|
|
450
|
+
export interface CrudControllerService<TDtos extends MetalCrudDtoClasses<any>> {
|
|
451
|
+
list(ctx: RequestContext<unknown, InstanceType<TDtos["queryDto"]>>): Awaitable<InstanceType<TDtos["pagedResponseDto"]>>;
|
|
452
|
+
options?(ctx: RequestContext<unknown, InstanceType<TDtos["optionsQueryDto"]>>): Awaitable<InstanceType<TDtos["optionsDto"]>>;
|
|
453
|
+
getById(id: number, ctx: RequestContext<unknown, undefined, InstanceType<TDtos["params"]>>): Awaitable<InstanceType<TDtos["response"]>>;
|
|
454
|
+
create(body: InstanceType<TDtos["create"]>, ctx: RequestContext<InstanceType<TDtos["create"]>>): Awaitable<InstanceType<TDtos["response"]>>;
|
|
455
|
+
replace?(id: number, body: InstanceType<TDtos["replace"]>, ctx: RequestContext<InstanceType<TDtos["replace"]>, undefined, InstanceType<TDtos["params"]>>): Awaitable<InstanceType<TDtos["response"]>>;
|
|
456
|
+
update?(id: number, body: InstanceType<TDtos["update"]>, ctx: RequestContext<InstanceType<TDtos["update"]>, undefined, InstanceType<TDtos["params"]>>): Awaitable<InstanceType<TDtos["response"]>>;
|
|
457
|
+
delete?(id: number, ctx: RequestContext<unknown, undefined, InstanceType<TDtos["params"]>>): Awaitable<void>;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* createCrudController options.
|
|
461
|
+
*/
|
|
462
|
+
export interface CreateCrudControllerOptions<TDtos extends MetalCrudDtoClasses<any>> {
|
|
463
|
+
/** Controller path. */
|
|
464
|
+
path: string;
|
|
465
|
+
/** Service instance or class (new () => service). */
|
|
466
|
+
service: CrudControllerServiceInput<TDtos>;
|
|
467
|
+
/** DTO bundle produced by createMetalCrudDtoClasses. */
|
|
468
|
+
dtos: TDtos;
|
|
469
|
+
/** Entity label used by parseIdOrThrow messages. */
|
|
470
|
+
entityName: string;
|
|
471
|
+
/** Generate GET /options route (default: true). */
|
|
472
|
+
withOptionsRoute?: boolean;
|
|
473
|
+
/** Generate PUT /:id route (default: true). */
|
|
474
|
+
withReplace?: boolean;
|
|
475
|
+
/** Generate PATCH /:id route (default: true). */
|
|
476
|
+
withPatch?: boolean;
|
|
477
|
+
/** Generate DELETE /:id route (default: true). */
|
|
478
|
+
withDelete?: boolean;
|
|
479
|
+
/** Optional OpenAPI tags for generated controller. */
|
|
480
|
+
tags?: string[];
|
|
481
|
+
}
|
|
395
482
|
/**
|
|
396
483
|
* Metal Tree DTO class names.
|
|
397
484
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "adorn-api",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.7",
|
|
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",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"express": "^4.19.2",
|
|
18
|
-
"metal-orm": "^1.1.
|
|
18
|
+
"metal-orm": "^1.1.2"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@electric-sql/pglite": "^0.3.15",
|
|
@@ -24,12 +24,14 @@
|
|
|
24
24
|
"@types/supertest": "^2.0.16",
|
|
25
25
|
"@typescript-eslint/eslint-plugin": "^8.53.0",
|
|
26
26
|
"@typescript-eslint/parser": "^8.53.0",
|
|
27
|
+
"@vitest/mocker": "^4.0.18",
|
|
27
28
|
"eslint": "^8.57.1",
|
|
28
29
|
"sqlite3": "^5.1.7",
|
|
29
30
|
"supertest": "^6.3.4",
|
|
30
31
|
"tedious": "^19.0.0",
|
|
31
32
|
"tsx": "^4.7.0",
|
|
32
33
|
"typescript": "^5.4.5",
|
|
33
|
-
"
|
|
34
|
+
"vite": "^7.3.1",
|
|
35
|
+
"vitest": "^4.0.18"
|
|
34
36
|
}
|
|
35
37
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Body,
|
|
3
|
+
Controller,
|
|
4
|
+
Delete,
|
|
5
|
+
Get,
|
|
6
|
+
Params,
|
|
7
|
+
Patch,
|
|
8
|
+
Post,
|
|
9
|
+
Put,
|
|
10
|
+
Query,
|
|
11
|
+
Returns
|
|
12
|
+
} from "../../core/decorators";
|
|
13
|
+
import type { DtoConstructor } from "../../core/types";
|
|
14
|
+
import type { RequestContext } from "../express/types";
|
|
15
|
+
import type {
|
|
16
|
+
CreateCrudControllerOptions,
|
|
17
|
+
CrudControllerService,
|
|
18
|
+
CrudControllerServiceInput,
|
|
19
|
+
MetalCrudDtoClasses,
|
|
20
|
+
RouteErrorsDecorator
|
|
21
|
+
} from "./types";
|
|
22
|
+
import { parseIdOrThrow } from "./utils";
|
|
23
|
+
|
|
24
|
+
type DtoInstance<TDto extends DtoConstructor> = InstanceType<TDto>;
|
|
25
|
+
type MethodDecorator = (
|
|
26
|
+
value: unknown,
|
|
27
|
+
context: ClassMethodDecoratorContext
|
|
28
|
+
) => void;
|
|
29
|
+
|
|
30
|
+
export function createCrudController<TDtos extends MetalCrudDtoClasses<any>>(
|
|
31
|
+
options: CreateCrudControllerOptions<TDtos>
|
|
32
|
+
) {
|
|
33
|
+
const withOptionsRoute = options.withOptionsRoute ?? true;
|
|
34
|
+
const withReplace = options.withReplace ?? true;
|
|
35
|
+
const withPatch = options.withPatch ?? true;
|
|
36
|
+
const withDelete = options.withDelete ?? true;
|
|
37
|
+
const service = resolveService(options.service);
|
|
38
|
+
const routeErrorsDecorator = options.dtos.errors;
|
|
39
|
+
|
|
40
|
+
@Controller({ path: options.path, tags: options.tags })
|
|
41
|
+
class GeneratedCrudController {
|
|
42
|
+
@Get("/")
|
|
43
|
+
@Query(options.dtos.queryDto)
|
|
44
|
+
@Returns(options.dtos.pagedResponseDto)
|
|
45
|
+
async list(
|
|
46
|
+
ctx: RequestContext<unknown, DtoInstance<TDtos["queryDto"]>>
|
|
47
|
+
): Promise<DtoInstance<TDtos["pagedResponseDto"]>> {
|
|
48
|
+
return await service.list(ctx);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@when(withOptionsRoute, Get("/options"))
|
|
52
|
+
@when(withOptionsRoute, Query(options.dtos.optionsQueryDto))
|
|
53
|
+
@when(withOptionsRoute, Returns(options.dtos.optionsDto))
|
|
54
|
+
async options(
|
|
55
|
+
ctx: RequestContext<unknown, DtoInstance<TDtos["optionsQueryDto"]>>
|
|
56
|
+
): Promise<DtoInstance<TDtos["optionsDto"]>> {
|
|
57
|
+
assertServiceMethod(service, "options");
|
|
58
|
+
return await service.options(ctx);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@Get("/:id")
|
|
62
|
+
@Params(options.dtos.params)
|
|
63
|
+
@Returns(options.dtos.response)
|
|
64
|
+
@applyRouteErrors(routeErrorsDecorator)
|
|
65
|
+
async getById(
|
|
66
|
+
ctx: RequestContext<unknown, undefined, DtoInstance<TDtos["params"]>>
|
|
67
|
+
): Promise<DtoInstance<TDtos["response"]>> {
|
|
68
|
+
const id = parseContextId(ctx, options.entityName);
|
|
69
|
+
return await service.getById(id, ctx);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@Post("/")
|
|
73
|
+
@Body(options.dtos.create)
|
|
74
|
+
@Returns({ status: 201, schema: options.dtos.response })
|
|
75
|
+
async create(
|
|
76
|
+
ctx: RequestContext<DtoInstance<TDtos["create"]>>
|
|
77
|
+
): Promise<DtoInstance<TDtos["response"]>> {
|
|
78
|
+
return await service.create(ctx.body, ctx);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@when(withReplace, Put("/:id"))
|
|
82
|
+
@when(withReplace, Params(options.dtos.params))
|
|
83
|
+
@when(withReplace, Body(options.dtos.replace))
|
|
84
|
+
@when(withReplace, Returns(options.dtos.response))
|
|
85
|
+
@when(withReplace, applyRouteErrors(routeErrorsDecorator))
|
|
86
|
+
async replace(
|
|
87
|
+
ctx: RequestContext<
|
|
88
|
+
DtoInstance<TDtos["replace"]>,
|
|
89
|
+
undefined,
|
|
90
|
+
DtoInstance<TDtos["params"]>
|
|
91
|
+
>
|
|
92
|
+
): Promise<DtoInstance<TDtos["response"]>> {
|
|
93
|
+
assertServiceMethod(service, "replace");
|
|
94
|
+
const id = parseContextId(ctx, options.entityName);
|
|
95
|
+
return await service.replace(id, ctx.body, ctx);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@when(withPatch, Patch("/:id"))
|
|
99
|
+
@when(withPatch, Params(options.dtos.params))
|
|
100
|
+
@when(withPatch, Body(options.dtos.update))
|
|
101
|
+
@when(withPatch, Returns(options.dtos.response))
|
|
102
|
+
@when(withPatch, applyRouteErrors(routeErrorsDecorator))
|
|
103
|
+
async update(
|
|
104
|
+
ctx: RequestContext<
|
|
105
|
+
DtoInstance<TDtos["update"]>,
|
|
106
|
+
undefined,
|
|
107
|
+
DtoInstance<TDtos["params"]>
|
|
108
|
+
>
|
|
109
|
+
): Promise<DtoInstance<TDtos["response"]>> {
|
|
110
|
+
assertServiceMethod(service, "update");
|
|
111
|
+
const id = parseContextId(ctx, options.entityName);
|
|
112
|
+
return await service.update(id, ctx.body, ctx);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@when(withDelete, Delete("/:id"))
|
|
116
|
+
@when(withDelete, Params(options.dtos.params))
|
|
117
|
+
@when(withDelete, Returns({ status: 204, description: "No Content" }))
|
|
118
|
+
@when(withDelete, applyRouteErrors(routeErrorsDecorator))
|
|
119
|
+
async delete(
|
|
120
|
+
ctx: RequestContext<unknown, undefined, DtoInstance<TDtos["params"]>>
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
assertServiceMethod(service, "delete");
|
|
123
|
+
const id = parseContextId(ctx, options.entityName);
|
|
124
|
+
await service.delete(id, ctx);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
Object.defineProperty(GeneratedCrudController, "name", {
|
|
129
|
+
value: `${options.entityName}CrudController`,
|
|
130
|
+
configurable: true
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return GeneratedCrudController;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function resolveService<TDtos extends MetalCrudDtoClasses<any>>(
|
|
137
|
+
input: CrudControllerServiceInput<TDtos>
|
|
138
|
+
): CrudControllerService<TDtos> {
|
|
139
|
+
if (typeof input === "function") {
|
|
140
|
+
return new input();
|
|
141
|
+
}
|
|
142
|
+
return input;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function when(enabled: boolean, decorator: MethodDecorator): MethodDecorator {
|
|
146
|
+
return (value, context) => {
|
|
147
|
+
if (enabled) {
|
|
148
|
+
decorator(value, context);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function applyRouteErrors(
|
|
154
|
+
decorator: RouteErrorsDecorator | undefined
|
|
155
|
+
): MethodDecorator {
|
|
156
|
+
return (value, context) => {
|
|
157
|
+
decorator?.(value, context);
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function parseContextId(
|
|
162
|
+
ctx: RequestContext<unknown, undefined, object | undefined>,
|
|
163
|
+
entityName: string
|
|
164
|
+
): number {
|
|
165
|
+
const params = (ctx.params ?? {}) as Record<string, unknown>;
|
|
166
|
+
return parseIdOrThrow(toIdValue(params.id), entityName);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function toIdValue(value: unknown): string | number {
|
|
170
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
173
|
+
return "";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function assertServiceMethod<
|
|
177
|
+
TDtos extends MetalCrudDtoClasses<any>,
|
|
178
|
+
TMethod extends "options" | "replace" | "update" | "delete"
|
|
179
|
+
>(
|
|
180
|
+
service: CrudControllerService<TDtos>,
|
|
181
|
+
method: TMethod
|
|
182
|
+
): asserts service is CrudControllerService<TDtos> & Record<TMethod, NonNullable<CrudControllerService<TDtos>[TMethod]>> {
|
|
183
|
+
if (typeof service[method] !== "function") {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`CRUD service is missing "${method}" method required by enabled route.`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -21,6 +21,11 @@ export {
|
|
|
21
21
|
parseSort
|
|
22
22
|
} from "./sort";
|
|
23
23
|
|
|
24
|
+
export {
|
|
25
|
+
runPagedList,
|
|
26
|
+
executeCrudList
|
|
27
|
+
} from "./list";
|
|
28
|
+
|
|
24
29
|
export {
|
|
25
30
|
createPagedQueryDtoClass,
|
|
26
31
|
createPagedResponseDtoClass,
|
|
@@ -33,6 +38,10 @@ export {
|
|
|
33
38
|
createNestedCreateDtoClass
|
|
34
39
|
} from "./crud-dtos";
|
|
35
40
|
|
|
41
|
+
export {
|
|
42
|
+
createCrudController
|
|
43
|
+
} from "./crud-controller";
|
|
44
|
+
|
|
36
45
|
export {
|
|
37
46
|
createMetalTreeDtoClasses
|
|
38
47
|
} from "./tree-dtos";
|
|
@@ -80,6 +89,10 @@ export type {
|
|
|
80
89
|
ParseSortOptions,
|
|
81
90
|
ParsedSort,
|
|
82
91
|
SortDirection,
|
|
92
|
+
CrudListSortTerm,
|
|
93
|
+
RunPagedListOptions,
|
|
94
|
+
ExecuteCrudListOptions,
|
|
95
|
+
CrudPagedResponse,
|
|
83
96
|
ListConfig,
|
|
84
97
|
PagedQueryDtoOptions,
|
|
85
98
|
PagedResponseDtoOptions,
|
|
@@ -96,6 +109,9 @@ export type {
|
|
|
96
109
|
MetalCrudDtoClasses,
|
|
97
110
|
MetalCrudDtoClassNameKey,
|
|
98
111
|
MetalCrudDtoClassNames,
|
|
112
|
+
CrudControllerService,
|
|
113
|
+
CrudControllerServiceInput,
|
|
114
|
+
CreateCrudControllerOptions,
|
|
99
115
|
RouteErrorsDecorator,
|
|
100
116
|
NestedCreateDtoOptions,
|
|
101
117
|
MetalTreeDtoClassOptions,
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
executeFilteredPaged,
|
|
3
|
+
type PagedResponse,
|
|
4
|
+
type SelectQueryBuilder,
|
|
5
|
+
type TableDef
|
|
6
|
+
} from "metal-orm";
|
|
7
|
+
import { parseFilter } from "./filters";
|
|
8
|
+
import { parsePagination } from "./pagination";
|
|
9
|
+
import { parseSort } from "./sort";
|
|
10
|
+
import type {
|
|
11
|
+
CrudListSortTerm,
|
|
12
|
+
ExecuteCrudListOptions,
|
|
13
|
+
FilterFieldInput,
|
|
14
|
+
RunPagedListOptions,
|
|
15
|
+
SortDirection
|
|
16
|
+
} from "./types";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Runs a unified filtered/sorted/paginated list query for metal-orm.
|
|
20
|
+
*/
|
|
21
|
+
export async function runPagedList<
|
|
22
|
+
TResult,
|
|
23
|
+
TTable extends TableDef = TableDef,
|
|
24
|
+
TTarget = unknown,
|
|
25
|
+
TFilterTarget = Record<string, unknown>
|
|
26
|
+
>(
|
|
27
|
+
options: RunPagedListOptions<TResult, TTable, TTarget, TFilterTarget>
|
|
28
|
+
): Promise<PagedResponse<TResult>> {
|
|
29
|
+
const query = options.query ?? {};
|
|
30
|
+
const qb = resolveQueryBuilder(options.qb);
|
|
31
|
+
|
|
32
|
+
const { page, pageSize } = parsePagination(query, {
|
|
33
|
+
defaultPageSize: options.defaultPageSize,
|
|
34
|
+
maxPageSize: options.maxPageSize
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const filters = parseFilter(query, options.filterMappings);
|
|
38
|
+
const parsedSort = parseSort(query, options.sortableColumns, {
|
|
39
|
+
defaultSortBy: options.defaultSortBy,
|
|
40
|
+
defaultSortDirection: options.defaultSortDirection,
|
|
41
|
+
sortByKey: options.sortByKey,
|
|
42
|
+
sortDirectionKey: options.sortDirectionKey,
|
|
43
|
+
sortOrderKey: options.sortOrderKey
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const inferredSortColumns = inferAllowedSortColumns(
|
|
47
|
+
qb.getTable(),
|
|
48
|
+
options.sortableColumns
|
|
49
|
+
);
|
|
50
|
+
const allowedSortColumns = {
|
|
51
|
+
...inferredSortColumns,
|
|
52
|
+
...(options.allowedSortColumns ?? {})
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const sortBy =
|
|
56
|
+
parsedSort?.sortBy && parsedSort.sortBy in allowedSortColumns
|
|
57
|
+
? parsedSort.sortBy
|
|
58
|
+
: undefined;
|
|
59
|
+
|
|
60
|
+
const defaultSortBy =
|
|
61
|
+
!sortBy && options.defaultSortBy && options.defaultSortBy in allowedSortColumns
|
|
62
|
+
? options.defaultSortBy
|
|
63
|
+
: undefined;
|
|
64
|
+
|
|
65
|
+
return executeFilteredPaged({
|
|
66
|
+
qb,
|
|
67
|
+
tableOrEntity: options.target as any,
|
|
68
|
+
session: options.session,
|
|
69
|
+
page,
|
|
70
|
+
pageSize,
|
|
71
|
+
filters: filters as any,
|
|
72
|
+
sortBy,
|
|
73
|
+
sortDirection: toOrderDirection(parsedSort?.sortDirection),
|
|
74
|
+
allowedSortColumns: Object.keys(allowedSortColumns).length
|
|
75
|
+
? allowedSortColumns
|
|
76
|
+
: undefined,
|
|
77
|
+
defaultSortBy,
|
|
78
|
+
defaultSortDirection: toOrderDirection(options.defaultSortDirection),
|
|
79
|
+
tieBreakerColumn: options.tieBreakerColumn
|
|
80
|
+
} as any) as Promise<PagedResponse<TResult>>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Alias for runPagedList.
|
|
85
|
+
*/
|
|
86
|
+
export const executeCrudList = runPagedList;
|
|
87
|
+
|
|
88
|
+
function resolveQueryBuilder<TResult, TTable extends TableDef>(
|
|
89
|
+
qbOrFactory:
|
|
90
|
+
| SelectQueryBuilder<TResult, TTable>
|
|
91
|
+
| (() => SelectQueryBuilder<TResult, TTable>)
|
|
92
|
+
): SelectQueryBuilder<TResult, TTable> {
|
|
93
|
+
return typeof qbOrFactory === "function" ? qbOrFactory() : qbOrFactory;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function inferAllowedSortColumns<TFilterTarget>(
|
|
97
|
+
table: TableDef,
|
|
98
|
+
sortableColumns: Record<string, FilterFieldInput<TFilterTarget>>
|
|
99
|
+
): Record<string, CrudListSortTerm> {
|
|
100
|
+
const output: Record<string, CrudListSortTerm> = {};
|
|
101
|
+
|
|
102
|
+
for (const [queryKey, field] of Object.entries(sortableColumns)) {
|
|
103
|
+
const columnName = toSortableColumnName(field);
|
|
104
|
+
if (!columnName) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const column = resolveTableColumn(table, columnName);
|
|
109
|
+
if (!column) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
output[queryKey] = column;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return output;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function toSortableColumnName(value: unknown): string | undefined {
|
|
120
|
+
if (Array.isArray(value)) {
|
|
121
|
+
if (value.length !== 1) {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
const segment = String(value[0]).trim();
|
|
125
|
+
return segment.length > 0 ? segment : undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (typeof value !== "string") {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const path = value
|
|
133
|
+
.split(".")
|
|
134
|
+
.map((segment) => segment.trim())
|
|
135
|
+
.filter((segment) => segment.length > 0);
|
|
136
|
+
|
|
137
|
+
if (path.length !== 1) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return path[0];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function resolveTableColumn(table: TableDef, name: string): CrudListSortTerm | undefined {
|
|
145
|
+
const byKey = table.columns[name];
|
|
146
|
+
if (byKey) {
|
|
147
|
+
return byKey;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return Object.values(table.columns).find((column) => column.name === name);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function toOrderDirection(
|
|
154
|
+
direction: SortDirection | undefined
|
|
155
|
+
): "ASC" | "DESC" | undefined {
|
|
156
|
+
if (direction === "desc") {
|
|
157
|
+
return "DESC";
|
|
158
|
+
}
|
|
159
|
+
if (direction === "asc") {
|
|
160
|
+
return "ASC";
|
|
161
|
+
}
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export type {
|
|
166
|
+
ExecuteCrudListOptions,
|
|
167
|
+
RunPagedListOptions
|
|
168
|
+
};
|