core-services-sdk 1.3.85 → 1.3.87
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/http/http.js +67 -11
- package/src/postgresql/modifiers/apply-order-by.js +52 -13
- package/src/postgresql/pagination/paginate.js +18 -9
- package/src/postgresql/start-stop-postgres-docker.js +10 -0
- package/src/rabbit-mq/start-stop-rabbitmq.js +13 -0
- package/tests/http/http.int.test.js +141 -0
- package/tests/postgresql/paginate.integration.test.js +284 -52
- package/tests/rabbit-mq/rabbit-mq.test.js +1 -1
- package/tests/repositories/BaseRepository.int.test.js +2 -2
- package/tests/repositories/TenantScopedRepository.int.test.js +2 -2
- package/types/http/http.d.ts +4 -3
- 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/vitest.config.js +1 -1
package/package.json
CHANGED
package/src/http/http.js
CHANGED
|
@@ -183,6 +183,45 @@ const getResponsePayload = async (response, responseType) => {
|
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Normalizes the request body before sending it via fetch.
|
|
188
|
+
*
|
|
189
|
+
* Ensures backward compatibility while allowing additional body types.
|
|
190
|
+
* - undefined/null → no body sent
|
|
191
|
+
* - string → sent as-is
|
|
192
|
+
* - URLSearchParams → sent as-is (fetch serializes automatically)
|
|
193
|
+
* - FormData → sent as-is (fetch sets multipart boundary automatically)
|
|
194
|
+
* - ArrayBuffer / Blob → sent as-is
|
|
195
|
+
* - object / array → JSON.stringify applied and Content-Type ensured
|
|
196
|
+
*
|
|
197
|
+
* @param {any} body
|
|
198
|
+
* @param {Record<string,string>} headers
|
|
199
|
+
* @returns {{ body:any, headers:Record<string,string> }}
|
|
200
|
+
*/
|
|
201
|
+
const prepareRequestBody = (body, headers = {}) => {
|
|
202
|
+
if (body === undefined || body === null) {
|
|
203
|
+
return { body: undefined, headers }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (
|
|
207
|
+
typeof body === 'string' ||
|
|
208
|
+
body instanceof URLSearchParams ||
|
|
209
|
+
body instanceof FormData ||
|
|
210
|
+
body instanceof ArrayBuffer ||
|
|
211
|
+
body instanceof Blob
|
|
212
|
+
) {
|
|
213
|
+
return { body, headers }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
body: JSON.stringify(body),
|
|
218
|
+
headers: {
|
|
219
|
+
'Content-Type': 'application/json',
|
|
220
|
+
...headers,
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
186
225
|
/**
|
|
187
226
|
* Sends an HTTP GET request.
|
|
188
227
|
*
|
|
@@ -219,11 +258,15 @@ export const post = async ({
|
|
|
219
258
|
extraParams = {},
|
|
220
259
|
expectedType = ResponseType.json,
|
|
221
260
|
}) => {
|
|
261
|
+
const { body: preparedBody, headers: preparedHeaders } = prepareRequestBody(
|
|
262
|
+
body,
|
|
263
|
+
headers,
|
|
264
|
+
)
|
|
222
265
|
const response = await fetch(url, {
|
|
223
266
|
...extraParams,
|
|
224
267
|
method: HTTP_METHODS.POST,
|
|
225
|
-
headers: { ...JSON_HEADER, ...
|
|
226
|
-
body:
|
|
268
|
+
headers: { ...JSON_HEADER, ...preparedHeaders },
|
|
269
|
+
body: preparedBody,
|
|
227
270
|
})
|
|
228
271
|
await checkStatus(response)
|
|
229
272
|
return getResponsePayload(response, expectedType)
|
|
@@ -243,11 +286,15 @@ export const put = async ({
|
|
|
243
286
|
extraParams = {},
|
|
244
287
|
expectedType = ResponseType.json,
|
|
245
288
|
}) => {
|
|
289
|
+
const { body: preparedBody, headers: preparedHeaders } = prepareRequestBody(
|
|
290
|
+
body,
|
|
291
|
+
headers,
|
|
292
|
+
)
|
|
246
293
|
const response = await fetch(url, {
|
|
247
294
|
...extraParams,
|
|
248
295
|
method: HTTP_METHODS.PUT,
|
|
249
|
-
headers: { ...JSON_HEADER, ...
|
|
250
|
-
body:
|
|
296
|
+
headers: { ...JSON_HEADER, ...preparedHeaders },
|
|
297
|
+
body: preparedBody,
|
|
251
298
|
})
|
|
252
299
|
await checkStatus(response)
|
|
253
300
|
return getResponsePayload(response, expectedType)
|
|
@@ -267,11 +314,15 @@ export const patch = async ({
|
|
|
267
314
|
extraParams = {},
|
|
268
315
|
expectedType = ResponseType.json,
|
|
269
316
|
}) => {
|
|
317
|
+
const { body: preparedBody, headers: preparedHeaders } = prepareRequestBody(
|
|
318
|
+
body,
|
|
319
|
+
headers,
|
|
320
|
+
)
|
|
270
321
|
const response = await fetch(url, {
|
|
271
322
|
...extraParams,
|
|
272
323
|
method: HTTP_METHODS.PATCH,
|
|
273
|
-
headers: { ...JSON_HEADER, ...
|
|
274
|
-
body:
|
|
324
|
+
headers: { ...JSON_HEADER, ...preparedHeaders },
|
|
325
|
+
body: preparedBody,
|
|
275
326
|
})
|
|
276
327
|
await checkStatus(response)
|
|
277
328
|
return getResponsePayload(response, expectedType)
|
|
@@ -291,11 +342,15 @@ export const deleteApi = async ({
|
|
|
291
342
|
extraParams = {},
|
|
292
343
|
expectedType = ResponseType.json,
|
|
293
344
|
}) => {
|
|
345
|
+
const { body: preparedBody, headers: preparedHeaders } = prepareRequestBody(
|
|
346
|
+
body,
|
|
347
|
+
headers,
|
|
348
|
+
)
|
|
294
349
|
const response = await fetch(url, {
|
|
295
350
|
...extraParams,
|
|
296
351
|
method: HTTP_METHODS.DELETE,
|
|
297
|
-
headers: { ...JSON_HEADER, ...
|
|
298
|
-
...(
|
|
352
|
+
headers: { ...JSON_HEADER, ...preparedHeaders },
|
|
353
|
+
...(preparedBody ? { body: preparedBody } : {}),
|
|
299
354
|
})
|
|
300
355
|
await checkStatus(response)
|
|
301
356
|
return getResponsePayload(response, expectedType)
|
|
@@ -328,8 +383,9 @@ export const head = async ({ url, headers = {}, extraParams = {} }) => {
|
|
|
328
383
|
* put: (options: HttpPutOptions) => Promise<any>,
|
|
329
384
|
* post: (options: HttpPostOptions) => Promise<any>,
|
|
330
385
|
* patch: (options: HttpPatchOptions) => Promise<any>,
|
|
331
|
-
*
|
|
332
|
-
*
|
|
386
|
+
* head: (options: HttpHeadOptions) => Promise<Response>,
|
|
387
|
+
* deleteApi: (options: HttpDeleteOptions) => Promise<any>
|
|
388
|
+
*
|
|
333
389
|
* }}
|
|
334
390
|
*/
|
|
335
391
|
|
|
@@ -337,7 +393,7 @@ export const http = {
|
|
|
337
393
|
get,
|
|
338
394
|
put,
|
|
339
395
|
post,
|
|
396
|
+
head,
|
|
340
397
|
patch,
|
|
341
398
|
deleteApi,
|
|
342
|
-
head,
|
|
343
399
|
}
|
|
@@ -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)
|
|
@@ -39,6 +39,16 @@ export function startPostgres({
|
|
|
39
39
|
|
|
40
40
|
stopPostgres(containerName)
|
|
41
41
|
|
|
42
|
+
// Kill any container that might still be holding the port
|
|
43
|
+
try {
|
|
44
|
+
const containerId = execSync(`docker ps -q --filter "publish=${port}"`, {
|
|
45
|
+
encoding: 'utf8',
|
|
46
|
+
}).trim()
|
|
47
|
+
if (containerId) {
|
|
48
|
+
execSync(`docker rm -f ${containerId}`, { stdio: 'ignore' })
|
|
49
|
+
}
|
|
50
|
+
} catch {}
|
|
51
|
+
|
|
42
52
|
execSync(
|
|
43
53
|
`docker run -d \
|
|
44
54
|
--name ${containerName} \
|
|
@@ -35,6 +35,18 @@ export function startRabbit({ containerName, ...rest }) {
|
|
|
35
35
|
execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' })
|
|
36
36
|
} catch {}
|
|
37
37
|
|
|
38
|
+
// Kill any containers that might still be holding the ports
|
|
39
|
+
for (const port of [rest.amqpPort, rest.uiPort]) {
|
|
40
|
+
try {
|
|
41
|
+
const id = execSync(`docker ps -q --filter "publish=${port}"`, {
|
|
42
|
+
encoding: 'utf8',
|
|
43
|
+
}).trim()
|
|
44
|
+
if (id) {
|
|
45
|
+
execSync(`docker rm -f ${id}`, { stdio: 'ignore' })
|
|
46
|
+
}
|
|
47
|
+
} catch {}
|
|
48
|
+
}
|
|
49
|
+
|
|
38
50
|
execSync(
|
|
39
51
|
`docker run -d \
|
|
40
52
|
--name ${containerName} \
|
|
@@ -42,6 +54,7 @@ export function startRabbit({ containerName, ...rest }) {
|
|
|
42
54
|
-e RABBITMQ_DEFAULT_PASS=${rest.pass} \
|
|
43
55
|
-p ${rest.amqpPort}:5672 \
|
|
44
56
|
-p ${rest.uiPort}:15672 \
|
|
57
|
+
--tmpfs /var/lib/rabbitmq \
|
|
45
58
|
--health-cmd="rabbitmq-diagnostics -q ping" \
|
|
46
59
|
--health-interval=5s \
|
|
47
60
|
--health-timeout=5s \
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
3
|
+
import httpServer from 'node:http'
|
|
4
|
+
import { http } from '../../src/http/http.js'
|
|
5
|
+
|
|
6
|
+
let server
|
|
7
|
+
let baseUrl
|
|
8
|
+
|
|
9
|
+
function collectBody(req) {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
const chunks = []
|
|
12
|
+
|
|
13
|
+
req.on('data', (chunk) => {
|
|
14
|
+
chunks.push(chunk)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
req.on('end', () => {
|
|
18
|
+
resolve(Buffer.concat(chunks).toString())
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
beforeAll(async () => {
|
|
24
|
+
server = httpServer.createServer(async (req, res) => {
|
|
25
|
+
const body = await collectBody(req)
|
|
26
|
+
|
|
27
|
+
res.setHeader('content-type', 'application/json')
|
|
28
|
+
|
|
29
|
+
res.end(
|
|
30
|
+
JSON.stringify({
|
|
31
|
+
method: req.method,
|
|
32
|
+
headers: req.headers,
|
|
33
|
+
body,
|
|
34
|
+
}),
|
|
35
|
+
)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
await new Promise((resolve) => {
|
|
39
|
+
server.listen(0, () => {
|
|
40
|
+
const { port } = server.address()
|
|
41
|
+
baseUrl = `http://localhost:${port}`
|
|
42
|
+
resolve()
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
afterAll(async () => {
|
|
48
|
+
await new Promise((resolve) => server.close(resolve))
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('http util integration', () => {
|
|
52
|
+
it('should send JSON body', async () => {
|
|
53
|
+
const result = await http.post({
|
|
54
|
+
url: `${baseUrl}/json`,
|
|
55
|
+
body: { hello: 'world' },
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
expect(result.method).toBe('POST')
|
|
59
|
+
expect(result.body).toBe(JSON.stringify({ hello: 'world' }))
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should send string body without stringify', async () => {
|
|
63
|
+
const result = await http.post({
|
|
64
|
+
url: `${baseUrl}/string`,
|
|
65
|
+
body: 'plain-text',
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
expect(result.body).toBe('plain-text')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('should support base64 string', async () => {
|
|
72
|
+
const base64 = Buffer.from('hello').toString('base64')
|
|
73
|
+
|
|
74
|
+
const result = await http.post({
|
|
75
|
+
url: `${baseUrl}/base64`,
|
|
76
|
+
body: base64,
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
expect(result.body).toBe(base64)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('should support URLSearchParams', async () => {
|
|
83
|
+
const params = new URLSearchParams({
|
|
84
|
+
grant_type: 'client_credentials',
|
|
85
|
+
scope: 'read',
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const result = await http.post({
|
|
89
|
+
url: `${baseUrl}/form`,
|
|
90
|
+
body: params,
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
expect(result.body).toBe(params.toString())
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('should support PUT with JSON', async () => {
|
|
97
|
+
const result = await http.put({
|
|
98
|
+
url: `${baseUrl}/put`,
|
|
99
|
+
body: { a: 1 },
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
expect(result.method).toBe('PUT')
|
|
103
|
+
expect(result.body).toBe(JSON.stringify({ a: 1 }))
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('should support PATCH with JSON', async () => {
|
|
107
|
+
const result = await http.patch({
|
|
108
|
+
url: `${baseUrl}/patch`,
|
|
109
|
+
body: { b: 2 },
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
expect(result.method).toBe('PATCH')
|
|
113
|
+
expect(result.body).toBe(JSON.stringify({ b: 2 }))
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('should support DELETE with body', async () => {
|
|
117
|
+
const result = await http.deleteApi({
|
|
118
|
+
url: `${baseUrl}/delete`,
|
|
119
|
+
body: { remove: true },
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
expect(result.method).toBe('DELETE')
|
|
123
|
+
expect(result.body).toBe(JSON.stringify({ remove: true }))
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('should support GET request', async () => {
|
|
127
|
+
const result = await http.get({
|
|
128
|
+
url: `${baseUrl}/get`,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
expect(result.method).toBe('GET')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('should support HEAD request', async () => {
|
|
135
|
+
const response = await http.head({
|
|
136
|
+
url: `${baseUrl}/head`,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
expect(response.status).toBe(200)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -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
|
})
|
|
@@ -20,7 +20,7 @@ const log = pino({
|
|
|
20
20
|
level: 'silent',
|
|
21
21
|
})
|
|
22
22
|
// @ts-ignore
|
|
23
|
-
async function waitForRabbitConnection({ uri, log, timeoutMs =
|
|
23
|
+
async function waitForRabbitConnection({ uri, log, timeoutMs = 30000 }) {
|
|
24
24
|
const start = Date.now()
|
|
25
25
|
|
|
26
26
|
while (Date.now() - start < timeoutMs) {
|
|
@@ -11,11 +11,11 @@ import {
|
|
|
11
11
|
import { BaseRepository } from '../../src/postgresql/repositories/BaseRepository.js'
|
|
12
12
|
|
|
13
13
|
const PG_OPTIONS = {
|
|
14
|
-
port:
|
|
14
|
+
port: 5449,
|
|
15
15
|
db: 'testdb',
|
|
16
16
|
user: 'testuser',
|
|
17
17
|
pass: 'testpass',
|
|
18
|
-
containerName: 'postgres-base-repo-test-
|
|
18
|
+
containerName: 'postgres-base-repo-test-5449',
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
const DATABASE_URI = buildPostgresUri(PG_OPTIONS)
|
|
@@ -11,11 +11,11 @@ import {
|
|
|
11
11
|
import { TenantScopedRepository } from '../../src/postgresql/repositories/TenantScopedRepository.js'
|
|
12
12
|
|
|
13
13
|
const PG_OPTIONS = {
|
|
14
|
-
port:
|
|
14
|
+
port: 5448,
|
|
15
15
|
db: 'testdb',
|
|
16
16
|
user: 'testuser',
|
|
17
17
|
pass: 'testpass',
|
|
18
|
-
containerName: 'postgres-tenant-repo-test-
|
|
18
|
+
containerName: 'postgres-tenant-repo-test-5448',
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
const DATABASE_URI = buildPostgresUri(PG_OPTIONS)
|
package/types/http/http.d.ts
CHANGED
|
@@ -45,8 +45,9 @@ export function head({
|
|
|
45
45
|
* put: (options: HttpPutOptions) => Promise<any>,
|
|
46
46
|
* post: (options: HttpPostOptions) => Promise<any>,
|
|
47
47
|
* patch: (options: HttpPatchOptions) => Promise<any>,
|
|
48
|
-
*
|
|
49
|
-
*
|
|
48
|
+
* head: (options: HttpHeadOptions) => Promise<Response>,
|
|
49
|
+
* deleteApi: (options: HttpDeleteOptions) => Promise<any>
|
|
50
|
+
*
|
|
50
51
|
* }}
|
|
51
52
|
*/
|
|
52
53
|
export const http: {
|
|
@@ -54,8 +55,8 @@ export const http: {
|
|
|
54
55
|
put: (options: HttpPutOptions) => Promise<any>
|
|
55
56
|
post: (options: HttpPostOptions) => Promise<any>
|
|
56
57
|
patch: (options: HttpPatchOptions) => Promise<any>
|
|
57
|
-
deleteApi: (options: HttpDeleteOptions) => Promise<any>
|
|
58
58
|
head: (options: HttpHeadOptions) => Promise<Response>
|
|
59
|
+
deleteApi: (options: HttpDeleteOptions) => Promise<any>
|
|
59
60
|
}
|
|
60
61
|
export type ResponseTypeValue = 'json' | 'xml' | 'text' | 'raw' | 'file'
|
|
61
62
|
export type HttpGetOptions = {
|
|
@@ -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'
|