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 CHANGED
@@ -398,13 +398,10 @@ import {
398
398
  Body,
399
399
  Query,
400
400
  Returns,
401
- parseFilter,
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
- const ormQuery = applyFilter(
442
- User.select().orderBy(User.id, direction),
443
- User,
444
- filters
445
- );
446
-
447
- const paged = await ormQuery.executePaged(session, { page, pageSize });
448
- return toPagedResponse(paged);
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
- parseFilter, parsePagination, parseSort,
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
- const ormQuery = applyFilter(
646
- User.select().orderBy(User.id, direction),
647
- User,
648
- filters
649
- );
650
- const paged = await ormQuery.executePaged(session, { page, pageSize });
651
- return toPagedResponse(paged);
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.6",
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.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
  }
@@ -192,8 +192,18 @@ function coerceArrayValue(
192
192
  if (value === undefined || value === null) {
193
193
  return { value, ok: true, changed: false };
194
194
  }
195
- const input = Array.isArray(value) ? value : [value];
196
- let changed = !Array.isArray(value);
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 { BelongsToReference, HasManyCollection, HasOneReference, ManyToManyCollection } from "metal-orm";
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
  */
@@ -317,13 +317,31 @@ function buildParameters(
317
317
  if (!fieldEntries.length) {
318
318
  return [];
319
319
  }
320
- return fieldEntries.map((entry) => ({
321
- name: entry.name,
322
- in: location,
323
- required: location === "path" ? true : entry.required,
324
- description: entry.description,
325
- schema: buildSchemaFromSource(entry.schema, context)
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
+ });