core-services-sdk 1.3.85 → 1.3.86
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/package.json +1 -1
- package/src/postgresql/modifiers/apply-order-by.js +52 -13
- package/src/postgresql/pagination/paginate.js +18 -9
- package/tests/postgresql/paginate.integration.test.js +284 -52
- package/types/postgresql/modifiers/apply-order-by.d.ts +10 -11
- package/types/postgresql/pagination/paginate.d.ts +19 -7
- package/types/postgresql/repositories/BaseRepository.d.ts +8 -1
- package/types/postgresql/repositories/TenantScopedRepository.d.ts +8 -1
package/package.json
CHANGED
|
@@ -2,22 +2,53 @@ import { getTableNameFromQuery } from '../core/get-table-name.js'
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @typedef {Object} OrderByItem
|
|
5
|
-
* @property {string} column
|
|
6
|
-
* @property {'asc' | 'desc'} [direction='asc']
|
|
5
|
+
* @property {string} column
|
|
6
|
+
* @property {'asc' | 'desc'} [direction='asc']
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
const ALLOWED_DIRECTIONS = ['asc', 'desc']
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Returns true if the column is a SQL expression or aggregate function call.
|
|
13
|
+
* These must be passed to orderByRaw — Knex's .orderBy() would quote them.
|
|
14
|
+
*
|
|
15
|
+
* Examples: "count(*)", "lower(name)", "max(created_at)"
|
|
16
|
+
*
|
|
17
|
+
* @param {string} column
|
|
18
|
+
* @returns {boolean}
|
|
19
|
+
*/
|
|
20
|
+
function isSqlExpression(column) {
|
|
21
|
+
return column.includes('(') || column.includes(')')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Returns true if the column is already table-qualified.
|
|
26
|
+
* These should be passed as-is to .orderBy() — no auto-prefix needed.
|
|
27
|
+
*
|
|
28
|
+
* Examples: "tenants.name", "roles.role_type"
|
|
29
|
+
*
|
|
30
|
+
* @param {string} column
|
|
31
|
+
* @returns {boolean}
|
|
32
|
+
*/
|
|
33
|
+
function isQualified(column) {
|
|
34
|
+
return column.includes('.')
|
|
35
|
+
}
|
|
36
|
+
|
|
11
37
|
/**
|
|
12
38
|
* Applies ORDER BY clause(s) to a Knex query builder.
|
|
13
39
|
*
|
|
14
|
-
* Supports
|
|
15
|
-
*
|
|
40
|
+
* Supports single or multiple order definitions.
|
|
41
|
+
*
|
|
42
|
+
* Column handling:
|
|
43
|
+
* - SQL expressions (e.g. "lower(name)", "count(*)") → orderByRaw, never quoted
|
|
44
|
+
* - Already-qualified columns (e.g. "tenants.name") → orderBy as-is
|
|
45
|
+
* - Plain column names (e.g. "name") → auto-prefixed with base table name
|
|
46
|
+
* to avoid ambiguity in joined queries, matching applyFilter behaviour
|
|
16
47
|
*
|
|
17
48
|
* @param {Object} params
|
|
18
|
-
* @param {import('knex').Knex.QueryBuilder} params.query
|
|
19
|
-
* @param {OrderByItem | OrderByItem[]} params.orderBy
|
|
20
|
-
* @returns {import('knex').Knex.QueryBuilder}
|
|
49
|
+
* @param {import('knex').Knex.QueryBuilder} params.query
|
|
50
|
+
* @param {OrderByItem | OrderByItem[]} params.orderBy
|
|
51
|
+
* @returns {import('knex').Knex.QueryBuilder}
|
|
21
52
|
*/
|
|
22
53
|
export function applyOrderBy({ query, orderBy }) {
|
|
23
54
|
if (!orderBy) {
|
|
@@ -27,19 +58,27 @@ export function applyOrderBy({ query, orderBy }) {
|
|
|
27
58
|
const tableName = getTableNameFromQuery(query)
|
|
28
59
|
const orderByArray = [].concat(orderBy)
|
|
29
60
|
|
|
30
|
-
return orderByArray.reduce((
|
|
31
|
-
if (!item
|
|
32
|
-
return
|
|
61
|
+
return orderByArray.reduce((queryBuilder, item) => {
|
|
62
|
+
if (!item || !item.column) {
|
|
63
|
+
return queryBuilder
|
|
33
64
|
}
|
|
34
65
|
|
|
35
|
-
const direction = item.direction || 'asc'
|
|
66
|
+
const direction = (item.direction || 'asc').toLowerCase()
|
|
36
67
|
|
|
37
68
|
if (!ALLOWED_DIRECTIONS.includes(direction)) {
|
|
38
69
|
throw new Error(`Invalid order direction: ${direction}`)
|
|
39
70
|
}
|
|
40
71
|
|
|
41
|
-
|
|
72
|
+
// SQL expressions must bypass Knex's column quoting
|
|
73
|
+
if (isSqlExpression(item.column)) {
|
|
74
|
+
return queryBuilder.orderByRaw(`${item.column} ${direction}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const column =
|
|
78
|
+
tableName && !isQualified(item.column)
|
|
79
|
+
? `${tableName}.${item.column}`
|
|
80
|
+
: item.column
|
|
42
81
|
|
|
43
|
-
return
|
|
82
|
+
return queryBuilder.orderBy(column, direction)
|
|
44
83
|
}, query)
|
|
45
84
|
}
|
|
@@ -67,6 +67,11 @@ import { normalizeNumberOrDefault } from '../../core/normalize-premitives-types-
|
|
|
67
67
|
* The list of records for the current page.
|
|
68
68
|
* Rows are mapped using `mapRow` if provided.
|
|
69
69
|
*/
|
|
70
|
+
/**
|
|
71
|
+
* Generic SQL pagination helper that works with joins.
|
|
72
|
+
*
|
|
73
|
+
* @async
|
|
74
|
+
*/
|
|
70
75
|
export async function sqlPaginate({
|
|
71
76
|
mapRow,
|
|
72
77
|
orderBy,
|
|
@@ -76,24 +81,28 @@ export async function sqlPaginate({
|
|
|
76
81
|
columns = '*',
|
|
77
82
|
filter = {},
|
|
78
83
|
snakeCase = true,
|
|
79
|
-
distinctOn,
|
|
80
84
|
}) {
|
|
81
|
-
const listQuery = baseQuery.clone()
|
|
82
|
-
const countQuery = baseQuery.clone()
|
|
83
|
-
|
|
84
85
|
const applyFilterFn = snakeCase ? applyFilterSnakeCase : applyFilter
|
|
85
86
|
|
|
86
|
-
|
|
87
|
-
|
|
87
|
+
const filteredQuery = baseQuery.clone()
|
|
88
|
+
|
|
89
|
+
applyFilterFn({ query: filteredQuery, filter })
|
|
90
|
+
|
|
91
|
+
const listQuery = filteredQuery.clone()
|
|
92
|
+
const countQuery = filteredQuery.clone()
|
|
88
93
|
|
|
89
94
|
applyOrderBy({ query: listQuery, orderBy })
|
|
90
95
|
applyPagination({ query: listQuery, page, limit })
|
|
91
96
|
|
|
97
|
+
const wrappedCountQuery = baseQuery.client
|
|
98
|
+
.queryBuilder()
|
|
99
|
+
.count('* as count')
|
|
100
|
+
.from(countQuery.as('t'))
|
|
101
|
+
.first()
|
|
102
|
+
|
|
92
103
|
const [rows, countResult] = await Promise.all([
|
|
93
104
|
listQuery.select(columns),
|
|
94
|
-
|
|
95
|
-
? countQuery.countDistinct(`${distinctOn} as count`).first()
|
|
96
|
-
: countQuery.count('* as count').first(),
|
|
105
|
+
wrappedCountQuery,
|
|
97
106
|
])
|
|
98
107
|
|
|
99
108
|
const totalCount = normalizeNumberOrDefault(countResult?.count || 0)
|
|
@@ -34,21 +34,37 @@ beforeAll(async () => {
|
|
|
34
34
|
table.uuid('id').primary()
|
|
35
35
|
table.string('name').notNullable()
|
|
36
36
|
table.string('type').notNullable()
|
|
37
|
-
table.
|
|
37
|
+
table.integer('age').notNullable()
|
|
38
38
|
table.timestamp('created_at').notNullable()
|
|
39
39
|
})
|
|
40
|
+
|
|
41
|
+
await db.schema.createTable('roles', (table) => {
|
|
42
|
+
table.increments('id').primary()
|
|
43
|
+
table.uuid('tenant_id').notNullable()
|
|
44
|
+
table.string('role_type').notNullable()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
await db.schema.createTable('tenant_tags', (table) => {
|
|
48
|
+
table.increments('id').primary()
|
|
49
|
+
table.uuid('tenant_id').notNullable()
|
|
50
|
+
table.string('tag').notNullable()
|
|
51
|
+
})
|
|
40
52
|
})
|
|
41
53
|
|
|
42
54
|
afterAll(async () => {
|
|
43
55
|
if (db) {
|
|
44
56
|
await db.destroy()
|
|
45
57
|
}
|
|
58
|
+
|
|
46
59
|
stopPostgres(PG_OPTIONS.containerName)
|
|
47
60
|
})
|
|
48
61
|
|
|
49
62
|
beforeEach(async () => {
|
|
63
|
+
await db('tenant_tags').truncate()
|
|
64
|
+
await db('roles').truncate()
|
|
50
65
|
await db('tenants').truncate()
|
|
51
|
-
|
|
66
|
+
|
|
67
|
+
await db('tenants').insert([
|
|
52
68
|
{
|
|
53
69
|
id: '00000000-0000-0000-0000-000000000001',
|
|
54
70
|
name: 'Tenant A',
|
|
@@ -59,54 +75,61 @@ beforeEach(async () => {
|
|
|
59
75
|
{
|
|
60
76
|
id: '00000000-0000-0000-0000-000000000002',
|
|
61
77
|
name: 'Tenant B',
|
|
62
|
-
age: 3,
|
|
63
78
|
type: 'business',
|
|
79
|
+
age: 3,
|
|
64
80
|
created_at: new Date('2024-01-02'),
|
|
65
81
|
},
|
|
66
82
|
{
|
|
67
83
|
id: '00000000-0000-0000-0000-000000000003',
|
|
68
84
|
name: 'Tenant C',
|
|
69
|
-
age: 1,
|
|
70
85
|
type: 'cpa',
|
|
86
|
+
age: 1,
|
|
71
87
|
created_at: new Date('2023-01-03'),
|
|
72
88
|
},
|
|
73
|
-
|
|
74
89
|
{
|
|
75
90
|
id: '00000000-0000-0000-0000-000000000004',
|
|
76
91
|
name: 'Tenant D',
|
|
77
|
-
age: 7,
|
|
78
92
|
type: 'cpa',
|
|
93
|
+
age: 7,
|
|
79
94
|
created_at: new Date('2022-01-03'),
|
|
80
95
|
},
|
|
81
|
-
|
|
82
96
|
{
|
|
83
97
|
id: '00000000-0000-0000-0000-000000000005',
|
|
84
98
|
name: 'Tenant E',
|
|
85
|
-
age: 0,
|
|
86
99
|
type: 'cpa',
|
|
100
|
+
age: 0,
|
|
87
101
|
created_at: new Date('2021-01-03'),
|
|
88
102
|
},
|
|
89
|
-
]
|
|
90
|
-
await db('tenants').insert(records)
|
|
91
|
-
})
|
|
103
|
+
])
|
|
92
104
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
105
|
+
await db('roles').insert([
|
|
106
|
+
{
|
|
107
|
+
tenant_id: '00000000-0000-0000-0000-000000000001',
|
|
108
|
+
role_type: 'customer',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
tenant_id: '00000000-0000-0000-0000-000000000001',
|
|
112
|
+
role_type: 'supplier',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
tenant_id: '00000000-0000-0000-0000-000000000002',
|
|
116
|
+
role_type: 'customer',
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
tenant_id: '00000000-0000-0000-0000-000000000003',
|
|
120
|
+
role_type: 'supplier',
|
|
121
|
+
},
|
|
122
|
+
])
|
|
100
123
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
})
|
|
124
|
+
await db('tenant_tags').insert([
|
|
125
|
+
{ tenant_id: '00000000-0000-0000-0000-000000000001', tag: 'vip' },
|
|
126
|
+
{ tenant_id: '00000000-0000-0000-0000-000000000001', tag: 'active' },
|
|
127
|
+
{ tenant_id: '00000000-0000-0000-0000-000000000002', tag: 'active' },
|
|
128
|
+
])
|
|
129
|
+
})
|
|
108
130
|
|
|
109
|
-
|
|
131
|
+
describe('sqlPaginate integration', () => {
|
|
132
|
+
it('basic pagination works', async () => {
|
|
110
133
|
const result = await sqlPaginate({
|
|
111
134
|
baseQuery: db('tenants'),
|
|
112
135
|
page: 2,
|
|
@@ -116,29 +139,19 @@ describe('paginate integration', () => {
|
|
|
116
139
|
expect(result.totalCount).toBe(5)
|
|
117
140
|
expect(result.pages).toBe(3)
|
|
118
141
|
expect(result.page).toBe(2)
|
|
119
|
-
expect(result.hasPrevious).toBe(true)
|
|
120
|
-
expect(result.hasNext).toBe(true)
|
|
121
142
|
expect(result.list).toHaveLength(2)
|
|
122
143
|
})
|
|
123
144
|
|
|
124
|
-
it('
|
|
125
|
-
const minDate = new Date('2024-01-01')
|
|
145
|
+
it('filters correctly', async () => {
|
|
126
146
|
const result = await sqlPaginate({
|
|
127
147
|
baseQuery: db('tenants'),
|
|
128
|
-
filter: {
|
|
129
|
-
createdAt: { lte: new Date(), gte: minDate },
|
|
130
|
-
},
|
|
131
|
-
limit: 10,
|
|
132
|
-
page: 1,
|
|
148
|
+
filter: { type: 'business' },
|
|
133
149
|
})
|
|
134
150
|
|
|
135
151
|
expect(result.totalCount).toBe(2)
|
|
136
|
-
|
|
137
|
-
const names = result.list.map((t) => t.name).sort()
|
|
138
|
-
expect(names).toEqual(['Tenant A', 'Tenant B'])
|
|
139
152
|
})
|
|
140
153
|
|
|
141
|
-
it('
|
|
154
|
+
it('orders correctly', async () => {
|
|
142
155
|
const result = await sqlPaginate({
|
|
143
156
|
baseQuery: db('tenants'),
|
|
144
157
|
orderBy: {
|
|
@@ -147,13 +160,7 @@ describe('paginate integration', () => {
|
|
|
147
160
|
},
|
|
148
161
|
})
|
|
149
162
|
|
|
150
|
-
expect(result.list.
|
|
151
|
-
'Tenant A',
|
|
152
|
-
'Tenant B',
|
|
153
|
-
'Tenant C',
|
|
154
|
-
'Tenant D',
|
|
155
|
-
'Tenant E',
|
|
156
|
-
])
|
|
163
|
+
expect(result.list[0].name).toBe('Tenant A')
|
|
157
164
|
})
|
|
158
165
|
|
|
159
166
|
it('supports row mapping', async () => {
|
|
@@ -165,20 +172,245 @@ describe('paginate integration', () => {
|
|
|
165
172
|
}),
|
|
166
173
|
})
|
|
167
174
|
|
|
168
|
-
expect(result.list.length).toBeGreaterThan(0)
|
|
169
175
|
expect(result.list[0].mapped).toBe(true)
|
|
170
176
|
})
|
|
171
177
|
|
|
172
|
-
it('
|
|
178
|
+
it('works with join', async () => {
|
|
179
|
+
const baseQuery = db('tenants').innerJoin(
|
|
180
|
+
'roles',
|
|
181
|
+
'roles.tenant_id',
|
|
182
|
+
'tenants.id',
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
const result = await sqlPaginate({
|
|
186
|
+
baseQuery,
|
|
187
|
+
limit: 10,
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
expect(result.totalCount).toBeGreaterThan(0)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('filters on joined table', async () => {
|
|
194
|
+
// Joined-table column filters are applied directly to baseQuery,
|
|
195
|
+
// not through the filter param (which is scoped to the base table).
|
|
196
|
+
const baseQuery = db('tenants')
|
|
197
|
+
.innerJoin('roles', 'roles.tenant_id', 'tenants.id')
|
|
198
|
+
.where('roles.role_type', 'customer')
|
|
199
|
+
|
|
200
|
+
const result = await sqlPaginate({
|
|
201
|
+
baseQuery,
|
|
202
|
+
limit: 10,
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
const names = result.list.map((t) => t.name).sort()
|
|
206
|
+
|
|
207
|
+
expect(names).toEqual(['Tenant A', 'Tenant B'])
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('handles duplicate rows from join', async () => {
|
|
211
|
+
const baseQuery = db('tenants').innerJoin(
|
|
212
|
+
'tenant_tags',
|
|
213
|
+
'tenant_tags.tenant_id',
|
|
214
|
+
'tenants.id',
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
const result = await sqlPaginate({
|
|
218
|
+
baseQuery,
|
|
219
|
+
limit: 10,
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
expect(result.totalCount).toBeGreaterThan(0)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('paginates correctly with join', async () => {
|
|
226
|
+
const baseQuery = db('tenants').innerJoin(
|
|
227
|
+
'roles',
|
|
228
|
+
'roles.tenant_id',
|
|
229
|
+
'tenants.id',
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
const result = await sqlPaginate({
|
|
233
|
+
baseQuery,
|
|
234
|
+
limit: 1,
|
|
235
|
+
page: 2,
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
expect(result.page).toBe(2)
|
|
239
|
+
expect(result.list.length).toBe(1)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('orders correctly with join', async () => {
|
|
243
|
+
const baseQuery = db('tenants').innerJoin(
|
|
244
|
+
'roles',
|
|
245
|
+
'roles.tenant_id',
|
|
246
|
+
'tenants.id',
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
const result = await sqlPaginate({
|
|
250
|
+
baseQuery,
|
|
251
|
+
orderBy: {
|
|
252
|
+
column: 'tenants.name',
|
|
253
|
+
direction: 'asc',
|
|
254
|
+
},
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
expect(result.list[0].name).toBe('Tenant A')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('works with double join (3 tables)', async () => {
|
|
261
|
+
const baseQuery = db('tenants')
|
|
262
|
+
.innerJoin('roles', 'roles.tenant_id', 'tenants.id')
|
|
263
|
+
.innerJoin('tenant_tags', 'tenant_tags.tenant_id', 'tenants.id')
|
|
264
|
+
|
|
265
|
+
const result = await sqlPaginate({
|
|
266
|
+
baseQuery,
|
|
267
|
+
limit: 10,
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
expect(result.totalCount).toBeGreaterThan(0)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('returns empty result correctly', async () => {
|
|
173
274
|
const result = await sqlPaginate({
|
|
174
275
|
baseQuery: db('tenants'),
|
|
175
|
-
filter: {
|
|
276
|
+
filter: { name: 'does-not-exist' },
|
|
176
277
|
})
|
|
177
278
|
|
|
178
279
|
expect(result.totalCount).toBe(0)
|
|
179
|
-
expect(result.pages).toBe(0)
|
|
180
280
|
expect(result.list).toEqual([])
|
|
181
|
-
expect(result.
|
|
182
|
-
|
|
281
|
+
expect(result.pages).toBe(0)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('exact count is correct when join produces duplicate rows', async () => {
|
|
285
|
+
// tenant A has 2 tags, tenant B has 1 tag → join produces 3 rows total
|
|
286
|
+
const baseQuery = db('tenants').innerJoin(
|
|
287
|
+
'tenant_tags',
|
|
288
|
+
'tenant_tags.tenant_id',
|
|
289
|
+
'tenants.id',
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
const result = await sqlPaginate({
|
|
293
|
+
baseQuery,
|
|
294
|
+
limit: 10,
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
expect(result.totalCount).toBe(3)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('orders correctly with plain column on join (auto-prefix)', async () => {
|
|
301
|
+
// plain 'name' should be auto-prefixed to 'tenants.name' to avoid ambiguity
|
|
302
|
+
const baseQuery = db('tenants').innerJoin(
|
|
303
|
+
'roles',
|
|
304
|
+
'roles.tenant_id',
|
|
305
|
+
'tenants.id',
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
const result = await sqlPaginate({
|
|
309
|
+
baseQuery,
|
|
310
|
+
orderBy: { column: 'name', direction: 'asc' },
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
expect(result.list[0].name).toBe('Tenant A')
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('orders correctly with multiple columns (array)', async () => {
|
|
317
|
+
// sort by type asc, then name desc → business: B, A then cpa: E, D, C
|
|
318
|
+
const result = await sqlPaginate({
|
|
319
|
+
baseQuery: db('tenants'),
|
|
320
|
+
orderBy: [
|
|
321
|
+
{ column: 'type', direction: 'asc' },
|
|
322
|
+
{ column: 'name', direction: 'desc' },
|
|
323
|
+
],
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
expect(result.list[0].name).toBe('Tenant B')
|
|
327
|
+
expect(result.list[1].name).toBe('Tenant A')
|
|
328
|
+
expect(result.list[4].name).toBe('Tenant C')
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('orders correctly with SQL expression (skips auto-prefix)', async () => {
|
|
332
|
+
// lower(name) is a SQL expression — must not be prefixed
|
|
333
|
+
const result = await sqlPaginate({
|
|
334
|
+
baseQuery: db('tenants'),
|
|
335
|
+
orderBy: { column: 'lower(name)', direction: 'asc' },
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
expect(result.list[0].name).toBe('Tenant A')
|
|
339
|
+
expect(result.list[4].name).toBe('Tenant E')
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('returns only requested columns', async () => {
|
|
343
|
+
const baseQuery = db('tenants').innerJoin(
|
|
344
|
+
'roles',
|
|
345
|
+
'roles.tenant_id',
|
|
346
|
+
'tenants.id',
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
// columns must be an array — a comma-separated string is not valid for Knex select
|
|
350
|
+
const result = await sqlPaginate({
|
|
351
|
+
baseQuery,
|
|
352
|
+
columns: ['tenants.name', 'tenants.type'],
|
|
353
|
+
limit: 10,
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
const row = result.list[0]
|
|
357
|
+
|
|
358
|
+
expect(row).toHaveProperty('name')
|
|
359
|
+
expect(row).toHaveProperty('type')
|
|
360
|
+
expect(row).not.toHaveProperty('id')
|
|
361
|
+
expect(row).not.toHaveProperty('role_type')
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('hasPrevious and hasNext are correct', async () => {
|
|
365
|
+
// 5 tenants, limit 2 → 3 pages
|
|
366
|
+
const page1 = await sqlPaginate({
|
|
367
|
+
baseQuery: db('tenants'),
|
|
368
|
+
page: 1,
|
|
369
|
+
limit: 2,
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
expect(page1.hasPrevious).toBe(false)
|
|
373
|
+
expect(page1.hasNext).toBe(true)
|
|
374
|
+
|
|
375
|
+
const page2 = await sqlPaginate({
|
|
376
|
+
baseQuery: db('tenants'),
|
|
377
|
+
page: 2,
|
|
378
|
+
limit: 2,
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
expect(page2.hasPrevious).toBe(true)
|
|
382
|
+
expect(page2.hasNext).toBe(true)
|
|
383
|
+
|
|
384
|
+
const page3 = await sqlPaginate({
|
|
385
|
+
baseQuery: db('tenants'),
|
|
386
|
+
page: 3,
|
|
387
|
+
limit: 2,
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
expect(page3.hasPrevious).toBe(true)
|
|
391
|
+
expect(page3.hasNext).toBe(false)
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('filters with multiple conditions', async () => {
|
|
395
|
+
const result = await sqlPaginate({
|
|
396
|
+
baseQuery: db('tenants'),
|
|
397
|
+
filter: { type: 'cpa', name: 'Tenant C' },
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
expect(result.totalCount).toBe(1)
|
|
401
|
+
expect(result.list[0].name).toBe('Tenant C')
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('filters with array values (IN operator)', async () => {
|
|
405
|
+
const result = await sqlPaginate({
|
|
406
|
+
baseQuery: db('tenants'),
|
|
407
|
+
filter: { name: ['Tenant A', 'Tenant C'] },
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
expect(result.totalCount).toBe(2)
|
|
411
|
+
|
|
412
|
+
const names = result.list.map((r) => r.name).sort()
|
|
413
|
+
|
|
414
|
+
expect(names).toEqual(['Tenant A', 'Tenant C'])
|
|
183
415
|
})
|
|
184
416
|
})
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Applies ORDER BY clause(s) to a Knex query builder.
|
|
3
3
|
*
|
|
4
|
-
* Supports
|
|
5
|
-
*
|
|
4
|
+
* Supports single or multiple order definitions.
|
|
5
|
+
*
|
|
6
|
+
* Column handling:
|
|
7
|
+
* - SQL expressions (e.g. "lower(name)", "count(*)") → orderByRaw, never quoted
|
|
8
|
+
* - Already-qualified columns (e.g. "tenants.name") → orderBy as-is
|
|
9
|
+
* - Plain column names (e.g. "name") → auto-prefixed with base table name
|
|
10
|
+
* to avoid ambiguity in joined queries, matching applyFilter behaviour
|
|
6
11
|
*
|
|
7
12
|
* @param {Object} params
|
|
8
|
-
* @param {import('knex').Knex.QueryBuilder} params.query
|
|
9
|
-
* @param {OrderByItem | OrderByItem[]} params.orderBy
|
|
10
|
-
* @returns {import('knex').Knex.QueryBuilder}
|
|
13
|
+
* @param {import('knex').Knex.QueryBuilder} params.query
|
|
14
|
+
* @param {OrderByItem | OrderByItem[]} params.orderBy
|
|
15
|
+
* @returns {import('knex').Knex.QueryBuilder}
|
|
11
16
|
*/
|
|
12
17
|
export function applyOrderBy({
|
|
13
18
|
query,
|
|
@@ -17,12 +22,6 @@ export function applyOrderBy({
|
|
|
17
22
|
orderBy: OrderByItem | OrderByItem[]
|
|
18
23
|
}): import('knex').Knex.QueryBuilder
|
|
19
24
|
export type OrderByItem = {
|
|
20
|
-
/**
|
|
21
|
-
* - Column name to order by
|
|
22
|
-
*/
|
|
23
25
|
column: string
|
|
24
|
-
/**
|
|
25
|
-
* - Order direction
|
|
26
|
-
*/
|
|
27
26
|
direction?: 'asc' | 'desc'
|
|
28
27
|
}
|
|
@@ -61,6 +61,11 @@
|
|
|
61
61
|
* The list of records for the current page.
|
|
62
62
|
* Rows are mapped using `mapRow` if provided.
|
|
63
63
|
*/
|
|
64
|
+
/**
|
|
65
|
+
* Generic SQL pagination helper that works with joins.
|
|
66
|
+
*
|
|
67
|
+
* @async
|
|
68
|
+
*/
|
|
64
69
|
export function sqlPaginate({
|
|
65
70
|
mapRow,
|
|
66
71
|
orderBy,
|
|
@@ -70,13 +75,20 @@ export function sqlPaginate({
|
|
|
70
75
|
columns,
|
|
71
76
|
filter,
|
|
72
77
|
snakeCase,
|
|
73
|
-
distinctOn,
|
|
74
78
|
}: {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
snakeCase?: boolean
|
|
78
|
-
orderBy?: any | any[]
|
|
79
|
+
mapRow: any
|
|
80
|
+
orderBy: any
|
|
79
81
|
page?: number
|
|
82
|
+
baseQuery: any
|
|
80
83
|
limit?: number
|
|
81
|
-
|
|
82
|
-
}
|
|
84
|
+
columns?: string
|
|
85
|
+
filter?: {}
|
|
86
|
+
snakeCase?: boolean
|
|
87
|
+
}): Promise<{
|
|
88
|
+
page: number
|
|
89
|
+
pages: number
|
|
90
|
+
totalCount: number
|
|
91
|
+
hasPrevious: boolean
|
|
92
|
+
hasNext: boolean
|
|
93
|
+
list: any
|
|
94
|
+
}>
|
|
@@ -6,6 +6,13 @@ export class TenantScopedRepository extends BaseRepository {
|
|
|
6
6
|
/**
|
|
7
7
|
* Overrides find to enforce tenant presence
|
|
8
8
|
*/
|
|
9
|
-
find(params?: {}): Promise<
|
|
9
|
+
find(params?: {}): Promise<{
|
|
10
|
+
page: number
|
|
11
|
+
pages: number
|
|
12
|
+
totalCount: number
|
|
13
|
+
hasPrevious: boolean
|
|
14
|
+
hasNext: boolean
|
|
15
|
+
list: any
|
|
16
|
+
}>
|
|
10
17
|
}
|
|
11
18
|
import { BaseRepository } from './BaseRepository.js'
|