core-services-sdk 1.3.84 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "core-services-sdk",
3
- "version": "1.3.84",
3
+ "version": "1.3.86",
4
4
  "main": "src/index.js",
5
5
  "type": "module",
6
6
  "types": "types/index.d.ts",
@@ -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 - Column name to order by
6
- * @property {'asc' | 'desc'} [direction='asc'] - Order direction
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 a single orderBy object or an array of orderBy objects.
15
- * Validates order direction to prevent invalid SQL.
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 - Knex query builder instance
19
- * @param {OrderByItem | OrderByItem[]} params.orderBy - Order by definition(s)
20
- * @returns {import('knex').Knex.QueryBuilder} The modified query builder
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((query, item) => {
31
- if (!item?.column) {
32
- return query
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
- const column = tableName ? `${tableName}.${item.column}` : item.column
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 query.orderBy(column, direction)
82
+ return queryBuilder.orderBy(column, direction)
44
83
  }, query)
45
84
  }
@@ -67,32 +67,42 @@ 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,
73
78
  page = 1,
74
79
  baseQuery,
75
80
  limit = 10,
81
+ columns = '*',
76
82
  filter = {},
77
83
  snakeCase = true,
78
- distinctOn,
79
84
  }) {
80
- const listQuery = baseQuery.clone()
81
- const countQuery = baseQuery.clone()
82
-
83
85
  const applyFilterFn = snakeCase ? applyFilterSnakeCase : applyFilter
84
86
 
85
- applyFilterFn({ query: listQuery, filter })
86
- applyFilterFn({ query: countQuery, filter })
87
+ const filteredQuery = baseQuery.clone()
88
+
89
+ applyFilterFn({ query: filteredQuery, filter })
90
+
91
+ const listQuery = filteredQuery.clone()
92
+ const countQuery = filteredQuery.clone()
87
93
 
88
94
  applyOrderBy({ query: listQuery, orderBy })
89
95
  applyPagination({ query: listQuery, page, limit })
90
96
 
97
+ const wrappedCountQuery = baseQuery.client
98
+ .queryBuilder()
99
+ .count('* as count')
100
+ .from(countQuery.as('t'))
101
+ .first()
102
+
91
103
  const [rows, countResult] = await Promise.all([
92
- listQuery.select('*'),
93
- distinctOn
94
- ? countQuery.countDistinct(`${distinctOn} as count`).first()
95
- : countQuery.count('* as count').first(),
104
+ listQuery.select(columns),
105
+ wrappedCountQuery,
96
106
  ])
97
107
 
98
108
  const totalCount = normalizeNumberOrDefault(countResult?.count || 0)
@@ -14,13 +14,14 @@ import { toSnakeCase } from '../../core/case-mapper.js'
14
14
  */
15
15
 
16
16
  export class BaseRepository {
17
- constructor({ db, log, baseQueryBuilder } = {}) {
17
+ constructor({ db, log, columns, baseQueryBuilder } = {}) {
18
18
  if (!db) {
19
19
  throw new Error('BaseRepository requires db instance')
20
20
  }
21
21
 
22
22
  this.db = db
23
23
  this.log = log
24
+ this._columns = columns ?? '*'
24
25
  this._baseQueryBuilder = baseQueryBuilder
25
26
  }
26
27
 
@@ -48,7 +49,7 @@ export class BaseRepository {
48
49
 
49
50
  if (this._baseQueryBuilder) {
50
51
  // Pass full params for optional dynamic shaping
51
- return this._baseQueryBuilder(qb, params)
52
+ this._baseQueryBuilder(qb, params)
52
53
  }
53
54
 
54
55
  return qb
@@ -59,11 +60,12 @@ export class BaseRepository {
59
60
  */
60
61
  async find({
61
62
  page = 1,
63
+ columns,
62
64
  limit = 10,
63
65
  filter = {},
64
- orderBy = { column: 'created_at', direction: 'desc' },
65
66
  options = {},
66
67
  mapRow = (row) => row,
68
+ orderBy = { column: 'created_at', direction: 'desc' },
67
69
  ...restParams
68
70
  }) {
69
71
  try {
@@ -79,10 +81,11 @@ export class BaseRepository {
79
81
  return await sqlPaginate({
80
82
  page,
81
83
  limit,
84
+ mapRow,
82
85
  orderBy,
83
86
  baseQuery: qb,
84
- mapRow,
85
87
  filter: toSnakeCase(filter),
88
+ columns: columns ?? this._columns,
86
89
  })
87
90
  } catch (error) {
88
91
  if (this.log) {
@@ -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.bigInteger('age').notNullable()
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
- const records = [
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
- describe('paginate integration', () => {
94
- it('returns first page without ordering guarantees', async () => {
95
- const result = await sqlPaginate({
96
- baseQuery: db('tenants'),
97
- page: 2,
98
- limit: 2,
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
- expect(result.totalCount).toBe(5)
102
- expect(result.pages).toBe(3)
103
- expect(result.page).toBe(2)
104
- expect(result.hasPrevious).toBe(true)
105
- expect(result.hasNext).toBe(true)
106
- expect(result.list).toHaveLength(2)
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
- it('returns second page without ordering guarantees', async () => {
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('applies filters correctly', async () => {
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('supports custom ordering', async () => {
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.map((t) => t.name)).toEqual([
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('returns empty list when no records match filter', async () => {
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: { type: 'non-existing' },
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.hasNext).toBe(false)
182
- expect(result.hasPrevious).toBe(false)
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
  })
@@ -0,0 +1,172 @@
1
+ // @ts-nocheck
2
+ import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'
3
+ import knex from 'knex'
4
+
5
+ import {
6
+ stopPostgres,
7
+ startPostgres,
8
+ buildPostgresUri,
9
+ } from '../../src/postgresql/start-stop-postgres-docker.js'
10
+
11
+ import { BaseRepository } from '../../src/postgresql/repositories/BaseRepository.js'
12
+
13
+ const PG_OPTIONS = {
14
+ port: 5445,
15
+ db: 'testdb',
16
+ user: 'testuser',
17
+ pass: 'testpass',
18
+ containerName: 'postgres-base-repo-test-5445',
19
+ }
20
+
21
+ const DATABASE_URI = buildPostgresUri(PG_OPTIONS)
22
+
23
+ let db
24
+
25
+ beforeAll(async () => {
26
+ startPostgres(PG_OPTIONS)
27
+
28
+ db = knex({
29
+ client: 'pg',
30
+ connection: DATABASE_URI,
31
+ })
32
+
33
+ await db.schema.createTable('customers', (table) => {
34
+ table.uuid('id').primary()
35
+ table.string('name').notNullable()
36
+ table.timestamp('created_at').notNullable()
37
+ })
38
+
39
+ await db.schema.createTable('orders', (table) => {
40
+ table.uuid('id').primary()
41
+ table.uuid('customer_id').notNullable()
42
+ table.decimal('amount', 10, 2)
43
+ table.timestamp('created_at').notNullable()
44
+ })
45
+ })
46
+
47
+ afterAll(async () => {
48
+ if (db) {
49
+ await db.destroy()
50
+ }
51
+
52
+ stopPostgres(PG_OPTIONS.containerName)
53
+ })
54
+
55
+ beforeEach(async () => {
56
+ await db('orders').truncate()
57
+ await db('customers').truncate()
58
+
59
+ await db('customers').insert([
60
+ {
61
+ id: '00000000-0000-0000-0000-000000000001',
62
+ name: 'Alice',
63
+ created_at: new Date('2024-01-01'),
64
+ },
65
+ {
66
+ id: '00000000-0000-0000-0000-000000000002',
67
+ name: 'Bob',
68
+ created_at: new Date('2024-01-02'),
69
+ },
70
+ ])
71
+
72
+ await db('orders').insert([
73
+ {
74
+ id: '00000000-0000-0000-0000-000000000101',
75
+ customer_id: '00000000-0000-0000-0000-000000000001',
76
+ amount: 100,
77
+ created_at: new Date('2024-01-10'),
78
+ },
79
+ {
80
+ id: '00000000-0000-0000-0000-000000000102',
81
+ customer_id: '00000000-0000-0000-0000-000000000001',
82
+ amount: 200,
83
+ created_at: new Date('2024-01-11'),
84
+ },
85
+ {
86
+ id: '00000000-0000-0000-0000-000000000103',
87
+ customer_id: '00000000-0000-0000-0000-000000000002',
88
+ amount: 300,
89
+ created_at: new Date('2024-01-12'),
90
+ },
91
+ ])
92
+ })
93
+
94
+ class OrdersRepository extends BaseRepository {
95
+ static tableName = 'orders'
96
+
97
+ constructor(deps) {
98
+ super({
99
+ ...deps,
100
+ baseQueryBuilder(qb) {
101
+ qb.innerJoin('customers', 'customers.id', 'orders.customer_id')
102
+ },
103
+ })
104
+ }
105
+ }
106
+
107
+ describe('BaseRepository integration', () => {
108
+ it('baseQueryBuilder applies JOIN correctly', async () => {
109
+ const repo = new OrdersRepository({ db })
110
+
111
+ const qb = repo.baseQuery()
112
+
113
+ const rows = await qb.select('orders.id', 'customers.name')
114
+
115
+ expect(rows).toHaveLength(3)
116
+ expect(rows[0]).toHaveProperty('name')
117
+ })
118
+
119
+ it('find returns joined data using columns', async () => {
120
+ const repo = new OrdersRepository({ db })
121
+
122
+ const result = await repo.find({
123
+ columns: ['orders.id', 'orders.amount', 'customers.name'],
124
+ limit: 10,
125
+ })
126
+
127
+ expect(result.list.length).toBe(3)
128
+
129
+ const row = result.list[0]
130
+
131
+ expect(row).toHaveProperty('amount')
132
+ expect(row).toHaveProperty('name')
133
+ })
134
+
135
+ it('pagination works with joins', async () => {
136
+ const repo = new OrdersRepository({ db })
137
+
138
+ const result = await repo.find({
139
+ columns: ['orders.id', 'customers.name'],
140
+ limit: 2,
141
+ page: 1,
142
+ })
143
+
144
+ expect(result.list.length).toBe(2)
145
+ expect(result.totalCount).toBe(3)
146
+ expect(result.pages).toBe(2)
147
+ })
148
+
149
+ it('constructor default columns are used', async () => {
150
+ class RepoWithColumns extends BaseRepository {
151
+ static tableName = 'orders'
152
+
153
+ constructor(deps) {
154
+ super({
155
+ ...deps,
156
+ columns: ['orders.id'],
157
+ baseQueryBuilder(qb) {
158
+ qb.innerJoin('customers', 'customers.id', 'orders.customer_id')
159
+ },
160
+ })
161
+ }
162
+ }
163
+
164
+ const repo = new RepoWithColumns({ db })
165
+
166
+ const result = await repo.find({})
167
+
168
+ const row = result.list[0]
169
+
170
+ expect(Object.keys(row)).toEqual(['id'])
171
+ })
172
+ })
@@ -1,21 +1,14 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest'
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
2
  import { BaseRepository } from '../../src/postgresql/repositories/BaseRepository.js'
3
3
 
4
- // 🔥 mock dependencies
4
+ // mock paginate
5
5
  vi.mock('../../src/postgresql/pagination/paginate.js', () => ({
6
- sqlPaginate: vi.fn().mockResolvedValue({ page: 1 }),
6
+ sqlPaginate: vi.fn(),
7
7
  }))
8
8
 
9
- vi.mock('../../filters/apply-filter-snake-case.js', () => ({
10
- applyFilterSnakeCase: vi.fn(),
11
- }))
12
-
13
- vi.mock('../../filters/apply-filter.js', () => ({
14
- applyFilter: vi.fn(),
15
- }))
16
-
17
- vi.mock('../../core/normalize-premitives-types-or-default.js', () => ({
18
- normalizeNumberOrDefault: vi.fn((v) => Number(v)),
9
+ // mock snakeCase mapper
10
+ vi.mock('../../src/core/case-mapper.js', () => ({
11
+ toSnakeCase: vi.fn((v) => v),
19
12
  }))
20
13
 
21
14
  // import AFTER mocks
@@ -24,6 +17,7 @@ import { toSnakeCase } from '../../src/core/case-mapper.js'
24
17
 
25
18
  describe('BaseRepository', () => {
26
19
  let dbMock
20
+ let qbMock
27
21
  let logMock
28
22
 
29
23
  class TestRepo extends BaseRepository {
@@ -31,13 +25,21 @@ describe('BaseRepository', () => {
31
25
  }
32
26
 
33
27
  beforeEach(() => {
34
- dbMock = vi.fn(() => ({
35
- clone: vi.fn(),
36
- }))
28
+ qbMock = {
29
+ clone: vi.fn().mockReturnThis(),
30
+ }
31
+
32
+ dbMock = vi.fn(() => qbMock)
37
33
 
38
34
  logMock = {
39
35
  error: vi.fn(),
40
36
  }
37
+
38
+ sqlPaginate.mockResolvedValue({ page: 1 })
39
+ })
40
+
41
+ afterEach(() => {
42
+ vi.clearAllMocks()
41
43
  })
42
44
 
43
45
  it('throws if db not provided', () => {
@@ -58,7 +60,7 @@ describe('BaseRepository', () => {
58
60
  })
59
61
 
60
62
  it('baseQuery uses trx when provided', () => {
61
- const trxMock = vi.fn()
63
+ const trxMock = vi.fn(() => qbMock)
62
64
  const repo = new TestRepo({ db: dbMock })
63
65
 
64
66
  repo.baseQuery({ trx: trxMock })
@@ -68,7 +70,8 @@ describe('BaseRepository', () => {
68
70
  })
69
71
 
70
72
  it('baseQuery applies baseQueryBuilder', () => {
71
- const builderMock = vi.fn((qb) => qb)
73
+ const builderMock = vi.fn()
74
+
72
75
  const repo = new TestRepo({
73
76
  db: dbMock,
74
77
  baseQueryBuilder: builderMock,
@@ -76,7 +79,7 @@ describe('BaseRepository', () => {
76
79
 
77
80
  repo.baseQuery({}, { some: 'param' })
78
81
 
79
- expect(builderMock).toHaveBeenCalled()
82
+ expect(builderMock).toHaveBeenCalledWith(qbMock, { some: 'param' })
80
83
  })
81
84
 
82
85
  it('find calls sqlPaginate with correct params', async () => {
@@ -88,17 +91,65 @@ describe('BaseRepository', () => {
88
91
  filter: { a: 1 },
89
92
  })
90
93
 
91
- expect(sqlPaginate).toHaveBeenCalled()
94
+ expect(toSnakeCase).toHaveBeenCalledWith({ a: 1 })
95
+
96
+ expect(sqlPaginate).toHaveBeenCalledWith(
97
+ expect.objectContaining({
98
+ page: 2,
99
+ limit: 5,
100
+ baseQuery: qbMock,
101
+ }),
102
+ )
92
103
  })
93
104
 
94
105
  it('logs error if sqlPaginate throws', async () => {
95
- // @ts-ignore
96
106
  sqlPaginate.mockRejectedValueOnce(new Error('fail'))
97
107
 
98
108
  const repo = new TestRepo({ db: dbMock, log: logMock })
99
109
 
100
- await expect(repo.find({ filter: {} })).rejects.toThrow()
110
+ await expect(repo.find({ filter: {} })).rejects.toThrow('fail')
101
111
 
102
112
  expect(logMock.error).toHaveBeenCalled()
103
113
  })
114
+
115
+ it('find uses columns passed to find()', async () => {
116
+ const repo = new TestRepo({ db: dbMock })
117
+
118
+ await repo.find({
119
+ columns: ['id', 'name'],
120
+ })
121
+
122
+ expect(sqlPaginate).toHaveBeenCalledWith(
123
+ expect.objectContaining({
124
+ columns: ['id', 'name'],
125
+ }),
126
+ )
127
+ })
128
+
129
+ it('find falls back to constructor columns', async () => {
130
+ const repo = new TestRepo({
131
+ db: dbMock,
132
+ columns: ['id', 'email'],
133
+ })
134
+
135
+ await repo.find({})
136
+
137
+ expect(sqlPaginate).toHaveBeenCalledWith(
138
+ expect.objectContaining({
139
+ columns: ['id', 'email'],
140
+ }),
141
+ )
142
+ })
143
+
144
+ it('find falls back to "*" when no columns provided', async () => {
145
+ const repo = new TestRepo({ db: dbMock })
146
+
147
+ await repo.find({})
148
+
149
+ expect(sqlPaginate).toHaveBeenCalledWith(
150
+ expect.objectContaining({
151
+ columns: '*',
152
+ }),
153
+ )
154
+ })
104
155
  })
@@ -0,0 +1,173 @@
1
+ // @ts-nocheck
2
+ import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'
3
+ import knex from 'knex'
4
+
5
+ import {
6
+ stopPostgres,
7
+ startPostgres,
8
+ buildPostgresUri,
9
+ } from '../../src/postgresql/start-stop-postgres-docker.js'
10
+
11
+ import { TenantScopedRepository } from '../../src/postgresql/repositories/TenantScopedRepository.js'
12
+
13
+ const PG_OPTIONS = {
14
+ port: 5446,
15
+ db: 'testdb',
16
+ user: 'testuser',
17
+ pass: 'testpass',
18
+ containerName: 'postgres-tenant-repo-test-5446',
19
+ }
20
+
21
+ const DATABASE_URI = buildPostgresUri(PG_OPTIONS)
22
+
23
+ const TENANT_A = '00000000-0000-0000-0000-000000000010'
24
+ const TENANT_B = '00000000-0000-0000-0000-000000000020'
25
+
26
+ let db
27
+
28
+ beforeAll(async () => {
29
+ startPostgres(PG_OPTIONS)
30
+
31
+ db = knex({
32
+ client: 'pg',
33
+ connection: DATABASE_URI,
34
+ })
35
+
36
+ await db.schema.createTable('customers', (table) => {
37
+ table.uuid('id').primary()
38
+ table.uuid('tenant_id').notNullable()
39
+ table.string('name')
40
+ table.timestamp('created_at').notNullable()
41
+ })
42
+
43
+ await db.schema.createTable('orders', (table) => {
44
+ table.uuid('id').primary()
45
+ table.uuid('tenant_id').notNullable()
46
+ table.uuid('customer_id')
47
+ table.decimal('amount', 10, 2)
48
+ table.timestamp('created_at').notNullable()
49
+ })
50
+ })
51
+
52
+ afterAll(async () => {
53
+ if (db) {
54
+ await db.destroy()
55
+ }
56
+
57
+ stopPostgres(PG_OPTIONS.containerName)
58
+ })
59
+
60
+ beforeEach(async () => {
61
+ await db('orders').truncate()
62
+ await db('customers').truncate()
63
+
64
+ await db('customers').insert([
65
+ {
66
+ id: '00000000-0000-0000-0000-000000000001',
67
+ tenant_id: TENANT_A,
68
+ name: 'Alice',
69
+ created_at: new Date('2024-01-01'),
70
+ },
71
+ {
72
+ id: '00000000-0000-0000-0000-000000000002',
73
+ tenant_id: TENANT_B,
74
+ name: 'Bob',
75
+ created_at: new Date('2024-01-02'),
76
+ },
77
+ ])
78
+
79
+ await db('orders').insert([
80
+ {
81
+ id: '00000000-0000-0000-0000-000000000101',
82
+ tenant_id: TENANT_A,
83
+ customer_id: '00000000-0000-0000-0000-000000000001',
84
+ amount: 100,
85
+ created_at: new Date('2024-01-10'),
86
+ },
87
+ {
88
+ id: '00000000-0000-0000-0000-000000000102',
89
+ tenant_id: TENANT_A,
90
+ customer_id: '00000000-0000-0000-0000-000000000001',
91
+ amount: 200,
92
+ created_at: new Date('2024-01-11'),
93
+ },
94
+ {
95
+ id: '00000000-0000-0000-0000-000000000103',
96
+ tenant_id: TENANT_B,
97
+ customer_id: '00000000-0000-0000-0000-000000000002',
98
+ amount: 300,
99
+ created_at: new Date('2024-01-12'),
100
+ },
101
+ ])
102
+ })
103
+
104
+ class OrdersRepository extends TenantScopedRepository {
105
+ static tableName = 'orders'
106
+
107
+ constructor(deps) {
108
+ super({
109
+ ...deps,
110
+ baseQueryBuilder(qb) {
111
+ qb.innerJoin('customers', 'customers.id', 'orders.customer_id')
112
+ },
113
+ })
114
+ }
115
+ }
116
+
117
+ describe('TenantScopedRepository integration', () => {
118
+ it('throws if tenantId missing', async () => {
119
+ const repo = new OrdersRepository({ db })
120
+
121
+ await expect(
122
+ repo.find({
123
+ filter: {},
124
+ }),
125
+ ).rejects.toThrow('tenantId is required')
126
+ })
127
+
128
+ it('returns only rows for tenant', async () => {
129
+ const repo = new OrdersRepository({ db })
130
+
131
+ const result = await repo.find({
132
+ filter: {
133
+ tenantId: TENANT_A,
134
+ },
135
+ columns: ['orders.id', 'orders.amount'],
136
+ })
137
+
138
+ expect(result.list.length).toBe(2)
139
+ })
140
+
141
+ it('works with join and columns', async () => {
142
+ const repo = new OrdersRepository({ db })
143
+
144
+ const result = await repo.find({
145
+ filter: {
146
+ tenantId: TENANT_A,
147
+ },
148
+ columns: ['orders.id', 'orders.amount', 'customers.name'],
149
+ })
150
+
151
+ const row = result.list[0]
152
+
153
+ expect(row).toHaveProperty('amount')
154
+ expect(row).toHaveProperty('name')
155
+ })
156
+
157
+ it('pagination respects tenant scope', async () => {
158
+ const repo = new OrdersRepository({ db })
159
+
160
+ const result = await repo.find({
161
+ filter: {
162
+ tenantId: TENANT_A,
163
+ },
164
+ limit: 1,
165
+ page: 1,
166
+ columns: ['orders.id'],
167
+ })
168
+
169
+ expect(result.list.length).toBe(1)
170
+ expect(result.totalCount).toBe(2)
171
+ expect(result.pages).toBe(2)
172
+ })
173
+ })
@@ -1,13 +1,18 @@
1
1
  /**
2
2
  * Applies ORDER BY clause(s) to a Knex query builder.
3
3
  *
4
- * Supports a single orderBy object or an array of orderBy objects.
5
- * Validates order direction to prevent invalid SQL.
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 - Knex query builder instance
9
- * @param {OrderByItem | OrderByItem[]} params.orderBy - Order by definition(s)
10
- * @returns {import('knex').Knex.QueryBuilder} The modified query builder
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,21 +61,34 @@
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,
67
72
  page,
68
73
  baseQuery,
69
74
  limit,
75
+ columns,
70
76
  filter,
71
77
  snakeCase,
72
- distinctOn,
73
78
  }: {
74
- baseQuery: import('knex').Knex.QueryBuilder
75
- filter?: any
76
- snakeCase?: boolean
77
- orderBy?: any | any[]
79
+ mapRow: any
80
+ orderBy: any
78
81
  page?: number
82
+ baseQuery: any
79
83
  limit?: number
80
- mapRow?: Function
81
- }): Promise<any>
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
+ }>
@@ -10,9 +10,10 @@
10
10
  * Does NOT enforce tenant isolation.
11
11
  */
12
12
  export class BaseRepository {
13
- constructor({ db, log, baseQueryBuilder }?: {})
13
+ constructor({ db, log, columns, baseQueryBuilder }?: {})
14
14
  db: any
15
15
  log: any
16
+ _columns: any
16
17
  _baseQueryBuilder: any
17
18
  /**
18
19
  * Each concrete repository must define:
@@ -29,22 +30,31 @@ export class BaseRepository {
29
30
  */
30
31
  find({
31
32
  page,
33
+ columns,
32
34
  limit,
33
35
  filter,
34
- orderBy,
35
36
  options,
36
37
  mapRow,
38
+ orderBy,
37
39
  ...restParams
38
40
  }: {
39
41
  [x: string]: any
40
42
  page?: number
43
+ columns: any
41
44
  limit?: number
42
45
  filter?: {}
46
+ options?: {}
47
+ mapRow?: (row: any) => any
43
48
  orderBy?: {
44
49
  column: string
45
50
  direction: string
46
51
  }
47
- options?: {}
48
- mapRow?: (row: any) => any
49
- }): Promise<any>
52
+ }): Promise<{
53
+ page: number
54
+ pages: number
55
+ totalCount: number
56
+ hasPrevious: boolean
57
+ hasNext: boolean
58
+ list: any
59
+ }>
50
60
  }
@@ -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<any>
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'