adorn-api 1.1.6 → 1.1.8
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 +64 -38
- package/dist/adapter/metal-orm/index.d.ts +2 -1
- package/dist/adapter/metal-orm/index.js +4 -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 +44 -1
- package/package.json +5 -3
- package/src/adapter/express/coercion.ts +12 -2
- package/src/adapter/metal-orm/index.ts +9 -0
- package/src/adapter/metal-orm/list.ts +168 -0
- package/src/adapter/metal-orm/types.ts +68 -1
- package/src/core/openapi.ts +25 -7
- package/tests/metal-orm-integration/run-paged-list.test.ts +169 -0
- package/tests/unit/openapi-parameters.test.ts +97 -0
- package/tests/unit/run-paged-list.test.ts +109 -0
package/README.md
CHANGED
|
@@ -398,13 +398,10 @@ import {
|
|
|
398
398
|
Body,
|
|
399
399
|
Query,
|
|
400
400
|
Returns,
|
|
401
|
-
|
|
402
|
-
parsePagination,
|
|
403
|
-
parseSort,
|
|
401
|
+
runPagedList,
|
|
404
402
|
t,
|
|
405
403
|
type RequestContext
|
|
406
404
|
} from "adorn-api";
|
|
407
|
-
import { applyFilter, toPagedResponse } from "metal-orm";
|
|
408
405
|
import { createSession } from "./db";
|
|
409
406
|
import { User } from "./user.entity";
|
|
410
407
|
import {
|
|
@@ -428,24 +425,18 @@ export class UserController {
|
|
|
428
425
|
@Query(UserQueryDto)
|
|
429
426
|
@Returns(UserPagedResponseDto)
|
|
430
427
|
async list(ctx: RequestContext<unknown, UserQueryDto>) {
|
|
431
|
-
const query = (ctx.query ?? {}) as Record<string, unknown>;
|
|
432
|
-
const { page, pageSize } = parsePagination(query);
|
|
433
|
-
const filters = parseFilter(query, USER_FILTER_MAPPINGS);
|
|
434
|
-
const sort = parseSort(query, USER_SORTABLE_COLUMNS, {
|
|
435
|
-
defaultSortBy: "id"
|
|
436
|
-
});
|
|
437
|
-
const direction = sort?.sortDirection === "desc" ? "DESC" : "ASC";
|
|
438
428
|
const session = createSession();
|
|
439
|
-
|
|
429
|
+
|
|
440
430
|
try {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
User,
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
431
|
+
return await runPagedList({
|
|
432
|
+
query: (ctx.query ?? {}) as Record<string, unknown>,
|
|
433
|
+
target: User,
|
|
434
|
+
qb: () => User.select(),
|
|
435
|
+
session,
|
|
436
|
+
filterMappings: USER_FILTER_MAPPINGS,
|
|
437
|
+
sortableColumns: USER_SORTABLE_COLUMNS,
|
|
438
|
+
defaultSortBy: "id"
|
|
439
|
+
});
|
|
449
440
|
} finally {
|
|
450
441
|
await session.dispose();
|
|
451
442
|
}
|
|
@@ -613,10 +604,9 @@ Breaking changes summary:
|
|
|
613
604
|
// user.controller.ts — using listConfig directly
|
|
614
605
|
import {
|
|
615
606
|
Controller, Get, Query, Returns,
|
|
616
|
-
|
|
607
|
+
runPagedList,
|
|
617
608
|
type RequestContext
|
|
618
609
|
} from "adorn-api";
|
|
619
|
-
import { applyFilter, toPagedResponse } from "metal-orm";
|
|
620
610
|
import { createSession } from "./db";
|
|
621
611
|
import { User } from "./user.entity";
|
|
622
612
|
import {
|
|
@@ -631,24 +621,15 @@ export class UserController {
|
|
|
631
621
|
@Query(UserQueryDto)
|
|
632
622
|
@Returns(UserPagedResponseDto)
|
|
633
623
|
async list(ctx: RequestContext<unknown, UserQueryDto>) {
|
|
634
|
-
const query = (ctx.query ?? {}) as Record<string, unknown>;
|
|
635
|
-
const { page, pageSize } = parsePagination(query, USER_LIST_CONFIG);
|
|
636
|
-
const filters = parseFilter(query, USER_LIST_CONFIG.filterMappings);
|
|
637
|
-
const sort = parseSort(query, USER_LIST_CONFIG.sortableColumns, {
|
|
638
|
-
defaultSortBy: USER_LIST_CONFIG.defaultSortBy,
|
|
639
|
-
defaultSortDirection: USER_LIST_CONFIG.defaultSortDirection
|
|
640
|
-
});
|
|
641
|
-
const direction = sort?.sortDirection === "desc" ? "DESC" : "ASC";
|
|
642
|
-
|
|
643
624
|
const session = createSession();
|
|
644
625
|
try {
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
User,
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
626
|
+
return await runPagedList({
|
|
627
|
+
query: (ctx.query ?? {}) as Record<string, unknown>,
|
|
628
|
+
target: User,
|
|
629
|
+
qb: () => User.select(),
|
|
630
|
+
session,
|
|
631
|
+
...USER_LIST_CONFIG
|
|
632
|
+
});
|
|
652
633
|
} finally {
|
|
653
634
|
await session.dispose();
|
|
654
635
|
}
|
|
@@ -658,6 +639,51 @@ export class UserController {
|
|
|
658
639
|
|
|
659
640
|
The `listConfig` object contains: `filterMappings`, `sortableColumns`, `defaultSortBy`, `defaultSortDirection`, `defaultPageSize`, `maxPageSize`, `sortByKey`, and `sortDirectionKey`.
|
|
660
641
|
|
|
642
|
+
### `BaseService.list` Before/After (Boilerplate Reduction)
|
|
643
|
+
|
|
644
|
+
Before:
|
|
645
|
+
|
|
646
|
+
```typescript
|
|
647
|
+
async list(query: Record<string, unknown>) {
|
|
648
|
+
const { page, pageSize } = parsePagination(query, this.listConfig);
|
|
649
|
+
const filters = parseFilter(query, this.listConfig.filterMappings);
|
|
650
|
+
const sort = parseSort(query, this.listConfig.sortableColumns, {
|
|
651
|
+
defaultSortBy: this.listConfig.defaultSortBy,
|
|
652
|
+
defaultSortDirection: this.listConfig.defaultSortDirection,
|
|
653
|
+
sortByKey: this.listConfig.sortByKey,
|
|
654
|
+
sortDirectionKey: this.listConfig.sortDirectionKey
|
|
655
|
+
});
|
|
656
|
+
const direction = sort?.sortDirection === "desc" ? "DESC" : "ASC";
|
|
657
|
+
|
|
658
|
+
return withSession(this.createSession, async (session) => {
|
|
659
|
+
const qb = applyFilter(this.baseQuery().orderBy(this.ref.id, direction), this.entity, filters);
|
|
660
|
+
const paged = await qb.executePaged(session, { page, pageSize });
|
|
661
|
+
return toPagedResponse(paged);
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
After:
|
|
667
|
+
|
|
668
|
+
```typescript
|
|
669
|
+
async list(query: Record<string, unknown>) {
|
|
670
|
+
return withSession(this.createSession, async (session) =>
|
|
671
|
+
runPagedList({
|
|
672
|
+
query,
|
|
673
|
+
target: this.entity,
|
|
674
|
+
qb: () => this.baseQuery(),
|
|
675
|
+
session,
|
|
676
|
+
...this.listConfig
|
|
677
|
+
})
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
Migration note:
|
|
683
|
+
- Existing `parsePagination`, `parseFilter`, and `parseSort` remain unchanged and can still be used manually.
|
|
684
|
+
- `runPagedList`/`executeCrudList` is additive and optional; no breaking API changes.
|
|
685
|
+
- For sortable fields that are not direct columns of the base table, pass `allowedSortColumns` with explicit metal-orm sort terms.
|
|
686
|
+
|
|
661
687
|
### Sort Order Compatibility (`sortOrder` / `sortDirection`)
|
|
662
688
|
|
|
663
689
|
`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.
|
|
@@ -2,6 +2,7 @@ export { MetalDto } from "./dto";
|
|
|
2
2
|
export { parsePagination } from "./pagination";
|
|
3
3
|
export { parseFilter, createFilterMappings } from "./filters";
|
|
4
4
|
export { parseSort } from "./sort";
|
|
5
|
+
export { runPagedList, executeCrudList } from "./list";
|
|
5
6
|
export { createPagedQueryDtoClass, createPagedResponseDtoClass, createPagedFilterQueryDtoClass } from "./paged-dtos";
|
|
6
7
|
export { createMetalCrudDtos, createMetalCrudDtoClasses, createNestedCreateDtoClass } from "./crud-dtos";
|
|
7
8
|
export { createCrudController } from "./crud-controller";
|
|
@@ -10,4 +11,4 @@ export { createMetalDtoOverrides, type CreateMetalDtoOverridesOptions } from "./
|
|
|
10
11
|
export { createErrorDtoClass, StandardErrorDto, SimpleErrorDto, BasicErrorDto } from "./error-dtos";
|
|
11
12
|
export { withSession, parseIdOrThrow, compactUpdates, applyInput, getEntityOrThrow } from "./utils";
|
|
12
13
|
export { validateEntityMetadata, hasValidEntityMetadata } from "./field-builder";
|
|
13
|
-
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, CrudControllerService, CrudControllerServiceInput, CreateCrudControllerOptions, RouteErrorsDecorator, NestedCreateDtoOptions, MetalTreeDtoClassOptions, MetalTreeDtoClasses, MetalTreeDtoClassNames, MetalTreeListEntryOptions, ErrorDtoOptions, CreateSessionFn } from "./types";
|
|
14
|
+
export type { MetalDtoMode, MetalDtoOptions, MetalDtoTarget, PaginationConfig, PaginationOptions, ParsedPagination, Filter, FilterMapping, FilterFieldMapping, FilterFieldPath, FilterFieldPathArray, FilterFieldInput, RelationQuantifier, ParseFilterOptions, ParseSortOptions, ParsedSort, SortDirection, CrudListSortTerm, RunPagedListOptions, ExecuteCrudListOptions, CrudPagedResponse, ListConfig, PagedQueryDtoOptions, PagedResponseDtoOptions, PagedFilterQueryDtoOptions, FilterFieldDef, MetalCrudQueryFilterDef, MetalCrudSortableColumns, MetalCrudOptionsQueryOptions, MetalCrudQueryOptions, MetalCrudStandardErrorsOptions, MetalCrudDtoOptions, MetalCrudDtoClassOptions, MetalCrudDtoDecorators, MetalCrudDtoClasses, MetalCrudDtoClassNameKey, MetalCrudDtoClassNames, CrudControllerService, CrudControllerServiceInput, CreateCrudControllerOptions, RouteErrorsDecorator, NestedCreateDtoOptions, MetalTreeDtoClassOptions, MetalTreeDtoClasses, MetalTreeDtoClassNames, MetalTreeListEntryOptions, ErrorDtoOptions, CreateSessionFn } from "./types";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.hasValidEntityMetadata = exports.validateEntityMetadata = exports.getEntityOrThrow = exports.applyInput = exports.compactUpdates = exports.parseIdOrThrow = exports.withSession = exports.BasicErrorDto = exports.SimpleErrorDto = exports.StandardErrorDto = exports.createErrorDtoClass = exports.createMetalDtoOverrides = exports.createMetalTreeDtoClasses = exports.createCrudController = exports.createNestedCreateDtoClass = exports.createMetalCrudDtoClasses = exports.createMetalCrudDtos = exports.createPagedFilterQueryDtoClass = exports.createPagedResponseDtoClass = exports.createPagedQueryDtoClass = exports.parseSort = exports.createFilterMappings = exports.parseFilter = exports.parsePagination = exports.MetalDto = void 0;
|
|
3
|
+
exports.hasValidEntityMetadata = exports.validateEntityMetadata = exports.getEntityOrThrow = exports.applyInput = exports.compactUpdates = exports.parseIdOrThrow = exports.withSession = exports.BasicErrorDto = exports.SimpleErrorDto = exports.StandardErrorDto = exports.createErrorDtoClass = exports.createMetalDtoOverrides = exports.createMetalTreeDtoClasses = exports.createCrudController = exports.createNestedCreateDtoClass = exports.createMetalCrudDtoClasses = exports.createMetalCrudDtos = exports.createPagedFilterQueryDtoClass = exports.createPagedResponseDtoClass = exports.createPagedQueryDtoClass = exports.executeCrudList = exports.runPagedList = exports.parseSort = exports.createFilterMappings = exports.parseFilter = exports.parsePagination = exports.MetalDto = void 0;
|
|
4
4
|
// Ensure standard decorator metadata is available for metal-orm transformers.
|
|
5
5
|
const symbolMetadata = Symbol.metadata;
|
|
6
6
|
if (!symbolMetadata) {
|
|
@@ -15,6 +15,9 @@ Object.defineProperty(exports, "parseFilter", { enumerable: true, get: function
|
|
|
15
15
|
Object.defineProperty(exports, "createFilterMappings", { enumerable: true, get: function () { return filters_1.createFilterMappings; } });
|
|
16
16
|
var sort_1 = require("./sort");
|
|
17
17
|
Object.defineProperty(exports, "parseSort", { enumerable: true, get: function () { return sort_1.parseSort; } });
|
|
18
|
+
var list_1 = require("./list");
|
|
19
|
+
Object.defineProperty(exports, "runPagedList", { enumerable: true, get: function () { return list_1.runPagedList; } });
|
|
20
|
+
Object.defineProperty(exports, "executeCrudList", { enumerable: true, get: function () { return list_1.executeCrudList; } });
|
|
18
21
|
var paged_dtos_1 = require("./paged-dtos");
|
|
19
22
|
Object.defineProperty(exports, "createPagedQueryDtoClass", { enumerable: true, get: function () { return paged_dtos_1.createPagedQueryDtoClass; } });
|
|
20
23
|
Object.defineProperty(exports, "createPagedResponseDtoClass", { enumerable: true, get: function () { return paged_dtos_1.createPagedResponseDtoClass; } });
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type PagedResponse, type TableDef } from "metal-orm";
|
|
2
|
+
import type { ExecuteCrudListOptions, RunPagedListOptions } from "./types";
|
|
3
|
+
/**
|
|
4
|
+
* Runs a unified filtered/sorted/paginated list query for metal-orm.
|
|
5
|
+
*/
|
|
6
|
+
export declare function runPagedList<TResult, TTable extends TableDef = TableDef, TTarget = unknown, TFilterTarget = Record<string, unknown>>(options: RunPagedListOptions<TResult, TTable, TTarget, TFilterTarget>): Promise<PagedResponse<TResult>>;
|
|
7
|
+
/**
|
|
8
|
+
* Alias for runPagedList.
|
|
9
|
+
*/
|
|
10
|
+
export declare const executeCrudList: typeof runPagedList;
|
|
11
|
+
export type { ExecuteCrudListOptions, RunPagedListOptions };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.executeCrudList = void 0;
|
|
4
|
+
exports.runPagedList = runPagedList;
|
|
5
|
+
const metal_orm_1 = require("metal-orm");
|
|
6
|
+
const filters_1 = require("./filters");
|
|
7
|
+
const pagination_1 = require("./pagination");
|
|
8
|
+
const sort_1 = require("./sort");
|
|
9
|
+
/**
|
|
10
|
+
* Runs a unified filtered/sorted/paginated list query for metal-orm.
|
|
11
|
+
*/
|
|
12
|
+
async function runPagedList(options) {
|
|
13
|
+
const query = options.query ?? {};
|
|
14
|
+
const qb = resolveQueryBuilder(options.qb);
|
|
15
|
+
const { page, pageSize } = (0, pagination_1.parsePagination)(query, {
|
|
16
|
+
defaultPageSize: options.defaultPageSize,
|
|
17
|
+
maxPageSize: options.maxPageSize
|
|
18
|
+
});
|
|
19
|
+
const filters = (0, filters_1.parseFilter)(query, options.filterMappings);
|
|
20
|
+
const parsedSort = (0, sort_1.parseSort)(query, options.sortableColumns, {
|
|
21
|
+
defaultSortBy: options.defaultSortBy,
|
|
22
|
+
defaultSortDirection: options.defaultSortDirection,
|
|
23
|
+
sortByKey: options.sortByKey,
|
|
24
|
+
sortDirectionKey: options.sortDirectionKey,
|
|
25
|
+
sortOrderKey: options.sortOrderKey
|
|
26
|
+
});
|
|
27
|
+
const inferredSortColumns = inferAllowedSortColumns(qb.getTable(), options.sortableColumns);
|
|
28
|
+
const allowedSortColumns = {
|
|
29
|
+
...inferredSortColumns,
|
|
30
|
+
...(options.allowedSortColumns ?? {})
|
|
31
|
+
};
|
|
32
|
+
const sortBy = parsedSort?.sortBy && parsedSort.sortBy in allowedSortColumns
|
|
33
|
+
? parsedSort.sortBy
|
|
34
|
+
: undefined;
|
|
35
|
+
const defaultSortBy = !sortBy && options.defaultSortBy && options.defaultSortBy in allowedSortColumns
|
|
36
|
+
? options.defaultSortBy
|
|
37
|
+
: undefined;
|
|
38
|
+
return (0, metal_orm_1.executeFilteredPaged)({
|
|
39
|
+
qb,
|
|
40
|
+
tableOrEntity: options.target,
|
|
41
|
+
session: options.session,
|
|
42
|
+
page,
|
|
43
|
+
pageSize,
|
|
44
|
+
filters: filters,
|
|
45
|
+
sortBy,
|
|
46
|
+
sortDirection: toOrderDirection(parsedSort?.sortDirection),
|
|
47
|
+
allowedSortColumns: Object.keys(allowedSortColumns).length
|
|
48
|
+
? allowedSortColumns
|
|
49
|
+
: undefined,
|
|
50
|
+
defaultSortBy,
|
|
51
|
+
defaultSortDirection: toOrderDirection(options.defaultSortDirection),
|
|
52
|
+
tieBreakerColumn: options.tieBreakerColumn
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Alias for runPagedList.
|
|
57
|
+
*/
|
|
58
|
+
exports.executeCrudList = runPagedList;
|
|
59
|
+
function resolveQueryBuilder(qbOrFactory) {
|
|
60
|
+
return typeof qbOrFactory === "function" ? qbOrFactory() : qbOrFactory;
|
|
61
|
+
}
|
|
62
|
+
function inferAllowedSortColumns(table, sortableColumns) {
|
|
63
|
+
const output = {};
|
|
64
|
+
for (const [queryKey, field] of Object.entries(sortableColumns)) {
|
|
65
|
+
const columnName = toSortableColumnName(field);
|
|
66
|
+
if (!columnName) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const column = resolveTableColumn(table, columnName);
|
|
70
|
+
if (!column) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
output[queryKey] = column;
|
|
74
|
+
}
|
|
75
|
+
return output;
|
|
76
|
+
}
|
|
77
|
+
function toSortableColumnName(value) {
|
|
78
|
+
if (Array.isArray(value)) {
|
|
79
|
+
if (value.length !== 1) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
const segment = String(value[0]).trim();
|
|
83
|
+
return segment.length > 0 ? segment : undefined;
|
|
84
|
+
}
|
|
85
|
+
if (typeof value !== "string") {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
const path = value
|
|
89
|
+
.split(".")
|
|
90
|
+
.map((segment) => segment.trim())
|
|
91
|
+
.filter((segment) => segment.length > 0);
|
|
92
|
+
if (path.length !== 1) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
return path[0];
|
|
96
|
+
}
|
|
97
|
+
function resolveTableColumn(table, name) {
|
|
98
|
+
const byKey = table.columns[name];
|
|
99
|
+
if (byKey) {
|
|
100
|
+
return byKey;
|
|
101
|
+
}
|
|
102
|
+
return Object.values(table.columns).find((column) => column.name === name);
|
|
103
|
+
}
|
|
104
|
+
function toOrderDirection(direction) {
|
|
105
|
+
if (direction === "desc") {
|
|
106
|
+
return "DESC";
|
|
107
|
+
}
|
|
108
|
+
if (direction === "asc") {
|
|
109
|
+
return "ASC";
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
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";
|
|
@@ -138,6 +138,10 @@ export interface ParsedSort<T = Record<string, unknown>> {
|
|
|
138
138
|
/** Resolved entity field */
|
|
139
139
|
field?: FilterFieldInput<T>;
|
|
140
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Sort terms accepted by metal-orm execution helpers.
|
|
143
|
+
*/
|
|
144
|
+
export type CrudListSortTerm = ColumnDef | Record<string, unknown>;
|
|
141
145
|
/**
|
|
142
146
|
* Ready-to-use list/query configuration extracted from CRUD DTO class generation.
|
|
143
147
|
* Eliminates the need for consumers to reassemble filter/sort/pagination config
|
|
@@ -161,6 +165,45 @@ export interface ListConfig<T = Record<string, unknown>> {
|
|
|
161
165
|
/** Sort direction query key */
|
|
162
166
|
sortDirectionKey: string;
|
|
163
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>;
|
|
164
207
|
/**
|
|
165
208
|
* Filter operator.
|
|
166
209
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "adorn-api",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.8",
|
|
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
|
}
|
|
@@ -192,8 +192,18 @@ function coerceArrayValue(
|
|
|
192
192
|
if (value === undefined || value === null) {
|
|
193
193
|
return { value, ok: true, changed: false };
|
|
194
194
|
}
|
|
195
|
-
|
|
196
|
-
let changed
|
|
195
|
+
let input: unknown[];
|
|
196
|
+
let changed: boolean;
|
|
197
|
+
if (Array.isArray(value)) {
|
|
198
|
+
input = value;
|
|
199
|
+
changed = false;
|
|
200
|
+
} else if (typeof value === "string" && value.includes(",")) {
|
|
201
|
+
input = value.split(",").map((s) => s.trim());
|
|
202
|
+
changed = true;
|
|
203
|
+
} else {
|
|
204
|
+
input = [value];
|
|
205
|
+
changed = true;
|
|
206
|
+
}
|
|
197
207
|
let ok = true;
|
|
198
208
|
const output = input.map((entry) => {
|
|
199
209
|
const result = coerceValue(entry, schema.items, mode);
|
|
@@ -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,
|
|
@@ -84,6 +89,10 @@ export type {
|
|
|
84
89
|
ParseSortOptions,
|
|
85
90
|
ParsedSort,
|
|
86
91
|
SortDirection,
|
|
92
|
+
CrudListSortTerm,
|
|
93
|
+
RunPagedListOptions,
|
|
94
|
+
ExecuteCrudListOptions,
|
|
95
|
+
CrudPagedResponse,
|
|
87
96
|
ListConfig,
|
|
88
97
|
PagedQueryDtoOptions,
|
|
89
98
|
PagedResponseDtoOptions,
|
|
@@ -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
|
+
};
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
BelongsToReference,
|
|
4
|
+
ColumnDef,
|
|
5
|
+
HasManyCollection,
|
|
6
|
+
HasOneReference,
|
|
7
|
+
ManyToManyCollection,
|
|
8
|
+
OrmSession,
|
|
9
|
+
PagedResponse,
|
|
10
|
+
SelectQueryBuilder,
|
|
11
|
+
TableDef
|
|
12
|
+
} from "metal-orm";
|
|
3
13
|
import type { DtoOptions, ErrorResponseOptions, FieldOverride } from "../../core/decorators";
|
|
4
14
|
import type { SchemaNode } from "../../core/schema";
|
|
5
15
|
import type { DtoConstructor } from "../../core/types";
|
|
@@ -177,6 +187,11 @@ export interface ParsedSort<T = Record<string, unknown>> {
|
|
|
177
187
|
field?: FilterFieldInput<T>;
|
|
178
188
|
}
|
|
179
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Sort terms accepted by metal-orm execution helpers.
|
|
192
|
+
*/
|
|
193
|
+
export type CrudListSortTerm = ColumnDef | Record<string, unknown>;
|
|
194
|
+
|
|
180
195
|
/**
|
|
181
196
|
* Ready-to-use list/query configuration extracted from CRUD DTO class generation.
|
|
182
197
|
* Eliminates the need for consumers to reassemble filter/sort/pagination config
|
|
@@ -201,6 +216,58 @@ export interface ListConfig<T = Record<string, unknown>> {
|
|
|
201
216
|
sortDirectionKey: string;
|
|
202
217
|
}
|
|
203
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Unified paged list execution options for metal-orm adapter.
|
|
221
|
+
*/
|
|
222
|
+
export interface RunPagedListOptions<
|
|
223
|
+
TResult,
|
|
224
|
+
TTable extends TableDef = TableDef,
|
|
225
|
+
TTarget = unknown,
|
|
226
|
+
TFilterTarget = Record<string, unknown>
|
|
227
|
+
> extends PaginationConfig {
|
|
228
|
+
/** Raw request query */
|
|
229
|
+
query?: Record<string, unknown>;
|
|
230
|
+
/** Entity class or table used by applyFilter */
|
|
231
|
+
target: TTarget;
|
|
232
|
+
/** Base query builder or factory to create one */
|
|
233
|
+
qb: SelectQueryBuilder<TResult, TTable> | (() => SelectQueryBuilder<TResult, TTable>);
|
|
234
|
+
/** Active ORM session */
|
|
235
|
+
session: OrmSession;
|
|
236
|
+
/** Query key -> filter mapping */
|
|
237
|
+
filterMappings: Record<string, FilterMapping<TFilterTarget>>;
|
|
238
|
+
/** Query key -> field path mapping used by parseSort */
|
|
239
|
+
sortableColumns: Record<string, FilterFieldInput<TFilterTarget>>;
|
|
240
|
+
/** Optional explicit metal-orm sortable terms, overrides inferred table columns */
|
|
241
|
+
allowedSortColumns?: Record<string, CrudListSortTerm>;
|
|
242
|
+
/** Default sort field key */
|
|
243
|
+
defaultSortBy?: string;
|
|
244
|
+
/** Default sort direction */
|
|
245
|
+
defaultSortDirection?: SortDirection;
|
|
246
|
+
/** Sort field query key */
|
|
247
|
+
sortByKey?: string;
|
|
248
|
+
/** Sort direction query key */
|
|
249
|
+
sortDirectionKey?: string;
|
|
250
|
+
/** Legacy sort order query key (e.g. sortOrder=DESC) */
|
|
251
|
+
sortOrderKey?: string;
|
|
252
|
+
/** Optional stable tie-breaker column name */
|
|
253
|
+
tieBreakerColumn?: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Alias for runPagedList options.
|
|
258
|
+
*/
|
|
259
|
+
export type ExecuteCrudListOptions<
|
|
260
|
+
TResult,
|
|
261
|
+
TTable extends TableDef = TableDef,
|
|
262
|
+
TTarget = unknown,
|
|
263
|
+
TFilterTarget = Record<string, unknown>
|
|
264
|
+
> = RunPagedListOptions<TResult, TTable, TTarget, TFilterTarget>;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Alias for runPagedList response.
|
|
268
|
+
*/
|
|
269
|
+
export type CrudPagedResponse<TResult> = PagedResponse<TResult>;
|
|
270
|
+
|
|
204
271
|
/**
|
|
205
272
|
* Filter operator.
|
|
206
273
|
*/
|
package/src/core/openapi.ts
CHANGED
|
@@ -317,13 +317,31 @@ function buildParameters(
|
|
|
317
317
|
if (!fieldEntries.length) {
|
|
318
318
|
return [];
|
|
319
319
|
}
|
|
320
|
-
return fieldEntries.map((entry) =>
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
320
|
+
return fieldEntries.map((entry) => {
|
|
321
|
+
const param: Record<string, unknown> = {
|
|
322
|
+
name: entry.name,
|
|
323
|
+
in: location,
|
|
324
|
+
required: location === "path" ? true : entry.required,
|
|
325
|
+
description: entry.description,
|
|
326
|
+
schema: buildSchemaFromSource(entry.schema, context)
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
if (location === "query" && isSchemaNode(entry.schema)) {
|
|
330
|
+
if (entry.schema.kind === "array") {
|
|
331
|
+
param.style = "form";
|
|
332
|
+
param.explode = true;
|
|
333
|
+
} else if (entry.schema.kind === "object") {
|
|
334
|
+
param.style = "deepObject";
|
|
335
|
+
param.explode = true;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (entry.schema.examples && entry.schema.examples.length > 0) {
|
|
339
|
+
param.example = entry.schema.examples[0];
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return param;
|
|
344
|
+
});
|
|
327
345
|
}
|
|
328
346
|
|
|
329
347
|
function extractFields(
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import sqlite3 from "sqlite3";
|
|
3
|
+
import {
|
|
4
|
+
Orm,
|
|
5
|
+
SqliteDialect,
|
|
6
|
+
col,
|
|
7
|
+
createSqliteExecutor,
|
|
8
|
+
defineTable,
|
|
9
|
+
selectFrom,
|
|
10
|
+
type SqliteClientLike
|
|
11
|
+
} from "metal-orm";
|
|
12
|
+
import { runPagedList } from "../../src/adapter/metal-orm/list";
|
|
13
|
+
|
|
14
|
+
const users = defineTable("users", {
|
|
15
|
+
id: col.primaryKey(col.autoIncrement(col.int())),
|
|
16
|
+
name: col.notNull(col.text()),
|
|
17
|
+
email: col.notNull(col.text())
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
let db: sqlite3.Database | null = null;
|
|
21
|
+
let orm: Orm | null = null;
|
|
22
|
+
|
|
23
|
+
function createSqliteClient(database: sqlite3.Database): SqliteClientLike {
|
|
24
|
+
return {
|
|
25
|
+
all(sql, params = []) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
database.all(sql, params, (err, rows) => {
|
|
28
|
+
if (err) {
|
|
29
|
+
reject(err);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
resolve(rows as Record<string, unknown>[]);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function execSql(sql: string): Promise<void> {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
if (!db) {
|
|
42
|
+
reject(new Error("Database not initialized"));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
db.exec(sql, (err) => {
|
|
46
|
+
if (err) {
|
|
47
|
+
reject(err);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
resolve();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function closeDb(): Promise<void> {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
if (!db) {
|
|
58
|
+
resolve();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
db.close((err) => {
|
|
62
|
+
if (err) {
|
|
63
|
+
reject(err);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
resolve();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("runPagedList integration", () => {
|
|
72
|
+
beforeAll(async () => {
|
|
73
|
+
db = new sqlite3.Database(":memory:");
|
|
74
|
+
await execSql("create table users (id integer primary key autoincrement, name text not null, email text not null)");
|
|
75
|
+
await execSql("insert into users (name, email) values ('Ada', 'ada@example.com')");
|
|
76
|
+
await execSql("insert into users (name, email) values ('Alan', 'alan@example.com')");
|
|
77
|
+
await execSql("insert into users (name, email) values ('Bruna', 'bruna@example.com')");
|
|
78
|
+
|
|
79
|
+
const client = createSqliteClient(db);
|
|
80
|
+
const executor = createSqliteExecutor(client);
|
|
81
|
+
orm = new Orm({
|
|
82
|
+
dialect: new SqliteDialect(),
|
|
83
|
+
executorFactory: {
|
|
84
|
+
createExecutor: () => executor,
|
|
85
|
+
createTransactionalExecutor: () => executor,
|
|
86
|
+
dispose: async () => {}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterAll(async () => {
|
|
92
|
+
await orm?.dispose();
|
|
93
|
+
await closeDb();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("runs filtered + sorted + paginated list from raw query", async () => {
|
|
97
|
+
if (!orm) {
|
|
98
|
+
throw new Error("ORM not initialized");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const session = orm.createSession();
|
|
102
|
+
try {
|
|
103
|
+
const result = await runPagedList({
|
|
104
|
+
query: {
|
|
105
|
+
page: "1",
|
|
106
|
+
pageSize: "1",
|
|
107
|
+
nameContains: "a",
|
|
108
|
+
sortBy: "name",
|
|
109
|
+
sortOrder: "DESC"
|
|
110
|
+
},
|
|
111
|
+
target: users,
|
|
112
|
+
qb: () => selectFrom(users).select("id", "name", "email"),
|
|
113
|
+
session,
|
|
114
|
+
filterMappings: {
|
|
115
|
+
nameContains: { field: "name", operator: "contains" }
|
|
116
|
+
},
|
|
117
|
+
sortableColumns: {
|
|
118
|
+
id: "id",
|
|
119
|
+
name: "name"
|
|
120
|
+
},
|
|
121
|
+
defaultSortBy: "id",
|
|
122
|
+
defaultSortDirection: "asc"
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(result.totalItems).toBe(3);
|
|
126
|
+
expect(result.items).toHaveLength(1);
|
|
127
|
+
expect(result.items[0].name).toBe("Bruna");
|
|
128
|
+
expect(result.page).toBe(1);
|
|
129
|
+
expect(result.pageSize).toBe(1);
|
|
130
|
+
expect(result.totalPages).toBe(3);
|
|
131
|
+
expect(result.hasNextPage).toBe(true);
|
|
132
|
+
expect(result.hasPrevPage).toBe(false);
|
|
133
|
+
} finally {
|
|
134
|
+
await session.dispose();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("supports defaults when sort/page params are omitted", async () => {
|
|
139
|
+
if (!orm) {
|
|
140
|
+
throw new Error("ORM not initialized");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const session = orm.createSession();
|
|
144
|
+
try {
|
|
145
|
+
const result = await runPagedList({
|
|
146
|
+
query: {},
|
|
147
|
+
target: users,
|
|
148
|
+
qb: () => selectFrom(users).select("id", "name", "email"),
|
|
149
|
+
session,
|
|
150
|
+
filterMappings: {},
|
|
151
|
+
sortableColumns: {
|
|
152
|
+
id: "id"
|
|
153
|
+
},
|
|
154
|
+
defaultSortBy: "id",
|
|
155
|
+
defaultSortDirection: "desc",
|
|
156
|
+
defaultPageSize: 2,
|
|
157
|
+
maxPageSize: 5
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(result.items).toHaveLength(2);
|
|
161
|
+
expect(result.items[0].id).toBe(3);
|
|
162
|
+
expect(result.items[1].id).toBe(2);
|
|
163
|
+
expect(result.pageSize).toBe(2);
|
|
164
|
+
expect(result.totalItems).toBe(3);
|
|
165
|
+
} finally {
|
|
166
|
+
await session.dispose();
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach } from "vitest";
|
|
2
|
+
import { t } from "../../src/core/schema";
|
|
3
|
+
import { registerController, registerDto } from "../../src/core/metadata";
|
|
4
|
+
import { buildOpenApi } from "../../src/core/openapi";
|
|
5
|
+
import { createInputCoercer } from "../../src/adapter/express/coercion";
|
|
6
|
+
|
|
7
|
+
describe("OpenAPI query parameter serialization", () => {
|
|
8
|
+
class QueryArrayController {}
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
registerController({
|
|
12
|
+
basePath: "/items",
|
|
13
|
+
controller: QueryArrayController,
|
|
14
|
+
routes: [
|
|
15
|
+
{
|
|
16
|
+
httpMethod: "get",
|
|
17
|
+
path: "/",
|
|
18
|
+
handlerName: "list",
|
|
19
|
+
query: {
|
|
20
|
+
schema: {
|
|
21
|
+
kind: "object",
|
|
22
|
+
properties: {
|
|
23
|
+
ids: t.array(t.string()),
|
|
24
|
+
nums: t.array(t.integer()),
|
|
25
|
+
tags: t.array(t.string(), { examples: [["a", "b"]] })
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
responses: [{ status: 200 }]
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("query array<string> generates style=form + explode=true", () => {
|
|
36
|
+
const doc = buildOpenApi({
|
|
37
|
+
info: { title: "test", version: "1.0.0" },
|
|
38
|
+
controllers: [QueryArrayController]
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const params = (doc.paths["/items"] as any).get.parameters as any[];
|
|
42
|
+
const idsParam = params.find((p: any) => p.name === "ids");
|
|
43
|
+
|
|
44
|
+
expect(idsParam).toBeDefined();
|
|
45
|
+
expect(idsParam.style).toBe("form");
|
|
46
|
+
expect(idsParam.explode).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("query array<integer> generates style=form + explode=true", () => {
|
|
50
|
+
const doc = buildOpenApi({
|
|
51
|
+
info: { title: "test", version: "1.0.0" },
|
|
52
|
+
controllers: [QueryArrayController]
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const params = (doc.paths["/items"] as any).get.parameters as any[];
|
|
56
|
+
const numsParam = params.find((p: any) => p.name === "nums");
|
|
57
|
+
|
|
58
|
+
expect(numsParam).toBeDefined();
|
|
59
|
+
expect(numsParam.style).toBe("form");
|
|
60
|
+
expect(numsParam.explode).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("projects example from schema.examples to parameter.example", () => {
|
|
64
|
+
const doc = buildOpenApi({
|
|
65
|
+
info: { title: "test", version: "1.0.0" },
|
|
66
|
+
controllers: [QueryArrayController]
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const params = (doc.paths["/items"] as any).get.parameters as any[];
|
|
70
|
+
const tagsParam = params.find((p: any) => p.name === "tags");
|
|
71
|
+
|
|
72
|
+
expect(tagsParam).toBeDefined();
|
|
73
|
+
expect(tagsParam.example).toEqual(["a", "b"]);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("Query array coercion – CSV support", () => {
|
|
78
|
+
it("?ids=1&ids=2 -> [1,2] via repeated keys", () => {
|
|
79
|
+
const coerce = createInputCoercer(
|
|
80
|
+
{ schema: { kind: "object", properties: { ids: t.array(t.integer()) } } },
|
|
81
|
+
{ mode: "safe", location: "query" }
|
|
82
|
+
)!;
|
|
83
|
+
|
|
84
|
+
const result = coerce({ ids: ["1", "2"] });
|
|
85
|
+
expect(result.ids).toEqual([1, 2]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("?ids=1,2 -> [1,2] via CSV string", () => {
|
|
89
|
+
const coerce = createInputCoercer(
|
|
90
|
+
{ schema: { kind: "object", properties: { ids: t.array(t.integer()) } } },
|
|
91
|
+
{ mode: "safe", location: "query" }
|
|
92
|
+
)!;
|
|
93
|
+
|
|
94
|
+
const result = coerce({ ids: "1,2" });
|
|
95
|
+
expect(result.ids).toEqual([1, 2]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { col, defineTable } from "metal-orm";
|
|
3
|
+
|
|
4
|
+
const { executeFilteredPagedMock } = vi.hoisted(() => ({
|
|
5
|
+
executeFilteredPagedMock: vi.fn()
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock("metal-orm", async () => {
|
|
9
|
+
const actual = await vi.importActual<typeof import("metal-orm")>("metal-orm");
|
|
10
|
+
return {
|
|
11
|
+
...actual,
|
|
12
|
+
executeFilteredPaged: executeFilteredPagedMock
|
|
13
|
+
};
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
import { runPagedList } from "../../src/adapter/metal-orm/list";
|
|
17
|
+
|
|
18
|
+
describe("runPagedList", () => {
|
|
19
|
+
const users = defineTable("users", {
|
|
20
|
+
id: col.primaryKey(col.autoIncrement(col.int())),
|
|
21
|
+
name: col.notNull(col.text()),
|
|
22
|
+
email: col.notNull(col.text())
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const qb = {
|
|
26
|
+
getTable: () => users
|
|
27
|
+
} as unknown as import("metal-orm").SelectQueryBuilder<
|
|
28
|
+
{ id: number; name: string; email: string },
|
|
29
|
+
typeof users
|
|
30
|
+
>;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
executeFilteredPagedMock.mockReset();
|
|
34
|
+
executeFilteredPagedMock.mockResolvedValue({
|
|
35
|
+
items: [],
|
|
36
|
+
totalItems: 0,
|
|
37
|
+
page: 1,
|
|
38
|
+
pageSize: 25,
|
|
39
|
+
totalPages: 0,
|
|
40
|
+
hasNextPage: false,
|
|
41
|
+
hasPrevPage: false
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("combines parsing and forwards normalized sort from sortOrder", async () => {
|
|
46
|
+
await runPagedList({
|
|
47
|
+
query: {
|
|
48
|
+
page: "2",
|
|
49
|
+
pageSize: "10",
|
|
50
|
+
nameContains: "Ada",
|
|
51
|
+
sortBy: "name",
|
|
52
|
+
sortOrder: "DESC"
|
|
53
|
+
},
|
|
54
|
+
target: users,
|
|
55
|
+
qb,
|
|
56
|
+
session: {} as import("metal-orm").OrmSession,
|
|
57
|
+
filterMappings: {
|
|
58
|
+
nameContains: { field: "name", operator: "contains" }
|
|
59
|
+
},
|
|
60
|
+
sortableColumns: {
|
|
61
|
+
name: "name",
|
|
62
|
+
email: "email"
|
|
63
|
+
},
|
|
64
|
+
defaultSortBy: "email",
|
|
65
|
+
defaultSortDirection: "asc"
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(executeFilteredPagedMock).toHaveBeenCalledTimes(1);
|
|
69
|
+
expect(executeFilteredPagedMock).toHaveBeenCalledWith(
|
|
70
|
+
expect.objectContaining({
|
|
71
|
+
page: 2,
|
|
72
|
+
pageSize: 10,
|
|
73
|
+
filters: {
|
|
74
|
+
name: { contains: "Ada" }
|
|
75
|
+
},
|
|
76
|
+
sortBy: "name",
|
|
77
|
+
sortDirection: "DESC",
|
|
78
|
+
defaultSortBy: undefined,
|
|
79
|
+
defaultSortDirection: "ASC"
|
|
80
|
+
})
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("ignores unresolved sortable path and keeps helper execution safe", async () => {
|
|
85
|
+
await runPagedList({
|
|
86
|
+
query: {
|
|
87
|
+
sortBy: "deepField"
|
|
88
|
+
},
|
|
89
|
+
target: users,
|
|
90
|
+
qb,
|
|
91
|
+
session: {} as import("metal-orm").OrmSession,
|
|
92
|
+
filterMappings: {},
|
|
93
|
+
sortableColumns: {
|
|
94
|
+
deepField: "profile.some.name"
|
|
95
|
+
},
|
|
96
|
+
defaultSortBy: "deepField",
|
|
97
|
+
defaultSortDirection: "desc"
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(executeFilteredPagedMock).toHaveBeenCalledTimes(1);
|
|
101
|
+
expect(executeFilteredPagedMock).toHaveBeenCalledWith(
|
|
102
|
+
expect.objectContaining({
|
|
103
|
+
sortBy: undefined,
|
|
104
|
+
defaultSortBy: undefined,
|
|
105
|
+
defaultSortDirection: "DESC"
|
|
106
|
+
})
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
});
|