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.
@@ -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.5",
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.0"
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
- "vitest": "^2.0.5"
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
+ };