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,8 +1,19 @@
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";
16
+ import type { RequestContext } from "../express/types";
6
17
 
7
18
  /**
8
19
  * Metal ORM DTO modes.
@@ -176,6 +187,11 @@ export interface ParsedSort<T = Record<string, unknown>> {
176
187
  field?: FilterFieldInput<T>;
177
188
  }
178
189
 
190
+ /**
191
+ * Sort terms accepted by metal-orm execution helpers.
192
+ */
193
+ export type CrudListSortTerm = ColumnDef | Record<string, unknown>;
194
+
179
195
  /**
180
196
  * Ready-to-use list/query configuration extracted from CRUD DTO class generation.
181
197
  * Eliminates the need for consumers to reassemble filter/sort/pagination config
@@ -200,6 +216,58 @@ export interface ListConfig<T = Record<string, unknown>> {
200
216
  sortDirectionKey: string;
201
217
  }
202
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
+
203
271
  /**
204
272
  * Filter operator.
205
273
  */
@@ -475,6 +543,86 @@ export interface MetalCrudDtoClasses<T = Record<string, unknown>> {
475
543
  listConfig: ListConfig<T>;
476
544
  }
477
545
 
546
+ /**
547
+ * Awaitable helper for CRUD service methods.
548
+ */
549
+ export type Awaitable<T> = T | Promise<T>;
550
+
551
+ /**
552
+ * Input for CRUD controller service: class or ready instance.
553
+ */
554
+ export type CrudControllerServiceInput<TDtos extends MetalCrudDtoClasses<any>> =
555
+ CrudControllerService<TDtos>
556
+ | (new () => CrudControllerService<TDtos>);
557
+
558
+ /**
559
+ * CRUD controller service contract used by createCrudController.
560
+ */
561
+ export interface CrudControllerService<TDtos extends MetalCrudDtoClasses<any>> {
562
+ list(
563
+ ctx: RequestContext<unknown, InstanceType<TDtos["queryDto"]>>
564
+ ): Awaitable<InstanceType<TDtos["pagedResponseDto"]>>;
565
+ options?(
566
+ ctx: RequestContext<unknown, InstanceType<TDtos["optionsQueryDto"]>>
567
+ ): Awaitable<InstanceType<TDtos["optionsDto"]>>;
568
+ getById(
569
+ id: number,
570
+ ctx: RequestContext<unknown, undefined, InstanceType<TDtos["params"]>>
571
+ ): Awaitable<InstanceType<TDtos["response"]>>;
572
+ create(
573
+ body: InstanceType<TDtos["create"]>,
574
+ ctx: RequestContext<InstanceType<TDtos["create"]>>
575
+ ): Awaitable<InstanceType<TDtos["response"]>>;
576
+ replace?(
577
+ id: number,
578
+ body: InstanceType<TDtos["replace"]>,
579
+ ctx: RequestContext<
580
+ InstanceType<TDtos["replace"]>,
581
+ undefined,
582
+ InstanceType<TDtos["params"]>
583
+ >
584
+ ): Awaitable<InstanceType<TDtos["response"]>>;
585
+ update?(
586
+ id: number,
587
+ body: InstanceType<TDtos["update"]>,
588
+ ctx: RequestContext<
589
+ InstanceType<TDtos["update"]>,
590
+ undefined,
591
+ InstanceType<TDtos["params"]>
592
+ >
593
+ ): Awaitable<InstanceType<TDtos["response"]>>;
594
+ delete?(
595
+ id: number,
596
+ ctx: RequestContext<unknown, undefined, InstanceType<TDtos["params"]>>
597
+ ): Awaitable<void>;
598
+ }
599
+
600
+ /**
601
+ * createCrudController options.
602
+ */
603
+ export interface CreateCrudControllerOptions<
604
+ TDtos extends MetalCrudDtoClasses<any>
605
+ > {
606
+ /** Controller path. */
607
+ path: string;
608
+ /** Service instance or class (new () => service). */
609
+ service: CrudControllerServiceInput<TDtos>;
610
+ /** DTO bundle produced by createMetalCrudDtoClasses. */
611
+ dtos: TDtos;
612
+ /** Entity label used by parseIdOrThrow messages. */
613
+ entityName: string;
614
+ /** Generate GET /options route (default: true). */
615
+ withOptionsRoute?: boolean;
616
+ /** Generate PUT /:id route (default: true). */
617
+ withReplace?: boolean;
618
+ /** Generate PATCH /:id route (default: true). */
619
+ withPatch?: boolean;
620
+ /** Generate DELETE /:id route (default: true). */
621
+ withDelete?: boolean;
622
+ /** Optional OpenAPI tags for generated controller. */
623
+ tags?: string[];
624
+ }
625
+
478
626
  /**
479
627
  * Metal Tree DTO class names.
480
628
  */
@@ -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,222 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildOpenApi,
4
+ createCrudController,
5
+ createMetalCrudDtoClasses,
6
+ t,
7
+ type RequestContext
8
+ } from "../../src/index";
9
+ import { getControllerMeta } from "../../src/core/metadata";
10
+ import { Column, Entity, PrimaryKey, col } from "metal-orm";
11
+
12
+ @Entity({ tableName: "crud_factory_entities" })
13
+ class CrudFactoryEntity {
14
+ @PrimaryKey(col.autoIncrement(col.int()))
15
+ id!: number;
16
+
17
+ @Column(col.notNull(col.text()))
18
+ nome!: string;
19
+
20
+ @Column(col.notNull(col.boolean()))
21
+ ativo!: boolean;
22
+ }
23
+
24
+ const crudDtos = createMetalCrudDtoClasses(CrudFactoryEntity, {
25
+ mutationExclude: ["id"],
26
+ query: {
27
+ filters: {
28
+ nomeContains: {
29
+ schema: t.string({ minLength: 1 }),
30
+ field: "nome",
31
+ operator: "contains"
32
+ },
33
+ ativo: {
34
+ schema: t.boolean(),
35
+ field: "ativo",
36
+ operator: "equals"
37
+ }
38
+ },
39
+ sortableColumns: {
40
+ id: "id",
41
+ nome: "nome"
42
+ },
43
+ options: {
44
+ labelField: "nome"
45
+ }
46
+ },
47
+ errors: true
48
+ });
49
+
50
+ class CrudFactoryService {
51
+ async list(_ctx: RequestContext<unknown, InstanceType<typeof crudDtos.queryDto>>) {
52
+ return { items: [], total: 0, page: 1, pageSize: 25 } as InstanceType<typeof crudDtos.pagedResponseDto>;
53
+ }
54
+
55
+ async options(_ctx: RequestContext<unknown, InstanceType<typeof crudDtos.optionsQueryDto>>) {
56
+ return { items: [], total: 0, page: 1, pageSize: 25 } as InstanceType<typeof crudDtos.optionsDto>;
57
+ }
58
+
59
+ async getById(
60
+ id: number,
61
+ _ctx: RequestContext<unknown, undefined, InstanceType<typeof crudDtos.params>>
62
+ ) {
63
+ return { id, nome: "Teste", ativo: true } as InstanceType<typeof crudDtos.response>;
64
+ }
65
+
66
+ async create(
67
+ body: InstanceType<typeof crudDtos.create>,
68
+ _ctx: RequestContext<InstanceType<typeof crudDtos.create>>
69
+ ) {
70
+ return { id: 1, ...body } as InstanceType<typeof crudDtos.response>;
71
+ }
72
+
73
+ async replace(
74
+ id: number,
75
+ body: InstanceType<typeof crudDtos.replace>,
76
+ _ctx: RequestContext<InstanceType<typeof crudDtos.replace>, undefined, InstanceType<typeof crudDtos.params>>
77
+ ) {
78
+ return { id, ...body } as InstanceType<typeof crudDtos.response>;
79
+ }
80
+
81
+ async update(
82
+ id: number,
83
+ body: InstanceType<typeof crudDtos.update>,
84
+ _ctx: RequestContext<InstanceType<typeof crudDtos.update>, undefined, InstanceType<typeof crudDtos.params>>
85
+ ) {
86
+ return { id, ...body } as InstanceType<typeof crudDtos.response>;
87
+ }
88
+
89
+ async delete(
90
+ _id: number,
91
+ _ctx: RequestContext<unknown, undefined, InstanceType<typeof crudDtos.params>>
92
+ ) {}
93
+ }
94
+
95
+ const CrudFactoryController = createCrudController({
96
+ path: "/crud-factory",
97
+ service: CrudFactoryService,
98
+ dtos: crudDtos,
99
+ entityName: "CrudFactoryEntity"
100
+ });
101
+
102
+ describe("createCrudController", () => {
103
+ it("registers CRUD routes with expected schemas and statuses", () => {
104
+ const meta = getControllerMeta(CrudFactoryController);
105
+ expect(meta).toBeDefined();
106
+ expect(meta?.basePath).toBe("/crud-factory");
107
+
108
+ const routeKeys = (meta?.routes ?? [])
109
+ .map((route) => `${route.httpMethod} ${route.path}`)
110
+ .sort();
111
+
112
+ expect(routeKeys).toEqual([
113
+ "delete /:id",
114
+ "get /",
115
+ "get /:id",
116
+ "get /options",
117
+ "patch /:id",
118
+ "post /",
119
+ "put /:id"
120
+ ]);
121
+
122
+ const byKey = new Map(
123
+ (meta?.routes ?? []).map((route) => [`${route.httpMethod} ${route.path}`, route] as const)
124
+ );
125
+
126
+ const listRoute = byKey.get("get /");
127
+ expect(listRoute?.query?.schema).toBe(crudDtos.queryDto);
128
+ expect(listRoute?.responses).toEqual(
129
+ expect.arrayContaining([
130
+ expect.objectContaining({ status: 200, schema: crudDtos.pagedResponseDto })
131
+ ])
132
+ );
133
+
134
+ const optionsRoute = byKey.get("get /options");
135
+ expect(optionsRoute?.query?.schema).toBe(crudDtos.optionsQueryDto);
136
+ expect(optionsRoute?.responses).toEqual(
137
+ expect.arrayContaining([
138
+ expect.objectContaining({ status: 200, schema: crudDtos.optionsDto })
139
+ ])
140
+ );
141
+
142
+ const createRoute = byKey.get("post /");
143
+ expect(createRoute?.body?.schema).toBe(crudDtos.create);
144
+ expect(createRoute?.responses).toEqual(
145
+ expect.arrayContaining([
146
+ expect.objectContaining({ status: 201, schema: crudDtos.response })
147
+ ])
148
+ );
149
+
150
+ for (const key of ["get /:id", "put /:id", "patch /:id", "delete /:id"]) {
151
+ const route = byKey.get(key);
152
+ expect(route?.params?.schema).toBe(crudDtos.params);
153
+ expect(route?.responses).toEqual(
154
+ expect.arrayContaining([
155
+ expect.objectContaining({ status: 400, error: true }),
156
+ expect.objectContaining({ status: 404, error: true })
157
+ ])
158
+ );
159
+ }
160
+
161
+ expect(byKey.get("put /:id")?.body?.schema).toBe(crudDtos.replace);
162
+ expect(byKey.get("patch /:id")?.body?.schema).toBe(crudDtos.update);
163
+ expect(byKey.get("delete /:id")?.responses).toEqual(
164
+ expect.arrayContaining([expect.objectContaining({ status: 204 })])
165
+ );
166
+ });
167
+
168
+ it("exposes matching schemas/status in OpenAPI", () => {
169
+ const doc = buildOpenApi({
170
+ info: { title: "Test API", version: "1.0.0" },
171
+ controllers: [CrudFactoryController]
172
+ });
173
+
174
+ const listResponses = (doc.paths["/crud-factory"]?.get as Record<string, any>)?.responses;
175
+ const createResponses = (doc.paths["/crud-factory"]?.post as Record<string, any>)?.responses;
176
+ const getByIdResponses = (doc.paths["/crud-factory/{id}"]?.get as Record<string, any>)?.responses;
177
+ const deleteResponses = (doc.paths["/crud-factory/{id}"]?.delete as Record<string, any>)?.responses;
178
+
179
+ expect(listResponses?.["200"]).toBeDefined();
180
+ expect(createResponses?.["201"]).toBeDefined();
181
+ expect(getByIdResponses?.["200"]).toBeDefined();
182
+ expect(getByIdResponses?.["400"]).toBeDefined();
183
+ expect(getByIdResponses?.["404"]).toBeDefined();
184
+ expect(deleteResponses?.["204"]).toBeDefined();
185
+ });
186
+
187
+ it("supports disabling optional routes with flags", () => {
188
+ const MinimalController = createCrudController({
189
+ path: "/crud-factory-minimal",
190
+ service: new CrudFactoryService(),
191
+ dtos: crudDtos,
192
+ entityName: "CrudFactoryEntity",
193
+ withOptionsRoute: false,
194
+ withReplace: false,
195
+ withPatch: false,
196
+ withDelete: false
197
+ });
198
+
199
+ const meta = getControllerMeta(MinimalController);
200
+ const routeKeys = (meta?.routes ?? [])
201
+ .map((route) => `${route.httpMethod} ${route.path}`)
202
+ .sort();
203
+
204
+ expect(routeKeys).toEqual([
205
+ "get /",
206
+ "get /:id",
207
+ "post /"
208
+ ]);
209
+ });
210
+
211
+ it("uses entityName in parseIdOrThrow messages", async () => {
212
+ const controller = new CrudFactoryController();
213
+ await expect(
214
+ controller.getById({
215
+ params: { id: "invalid" }
216
+ } as any)
217
+ ).rejects.toMatchObject({
218
+ status: 400,
219
+ message: "Invalid CrudFactoryEntity id."
220
+ });
221
+ });
222
+ });
@@ -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
+ });