core-services-sdk 1.3.81 → 1.3.82
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/index.js +2 -0
- package/src/postgresql/pagination/paginate.js +4 -1
- package/src/postgresql/repositories/BaseRepository.js +95 -0
- package/src/postgresql/repositories/TenantScopedRepository.js +40 -0
- package/tests/repositories/BaseRepository.test.js +104 -0
- package/tests/repositories/TenantScopedRepository.test.js +44 -0
package/package.json
CHANGED
package/src/postgresql/index.js
CHANGED
|
@@ -5,4 +5,6 @@ export * from './filters/apply-filter.js'
|
|
|
5
5
|
export * from './modifiers/apply-order-by.js'
|
|
6
6
|
export * from './start-stop-postgres-docker.js'
|
|
7
7
|
export * from './modifiers/apply-pagination.js'
|
|
8
|
+
export * from './repositories/BaseRepository.js'
|
|
8
9
|
export * from './filters/apply-filter-snake-case.js'
|
|
10
|
+
export * from './repositories/TenantScopedRepository.js'
|
|
@@ -75,6 +75,7 @@ export async function sqlPaginate({
|
|
|
75
75
|
limit = 10,
|
|
76
76
|
filter = {},
|
|
77
77
|
snakeCase = true,
|
|
78
|
+
distinctOn,
|
|
78
79
|
}) {
|
|
79
80
|
const listQuery = baseQuery.clone()
|
|
80
81
|
const countQuery = baseQuery.clone()
|
|
@@ -89,7 +90,9 @@ export async function sqlPaginate({
|
|
|
89
90
|
|
|
90
91
|
const [rows, countResult] = await Promise.all([
|
|
91
92
|
listQuery.select('*'),
|
|
92
|
-
|
|
93
|
+
distinctOn
|
|
94
|
+
? countQuery.countDistinct(`${distinctOn} as count`).first()
|
|
95
|
+
: countQuery.count('* as count').first(),
|
|
93
96
|
])
|
|
94
97
|
|
|
95
98
|
const totalCount = normalizeNumberOrDefault(countResult?.count || 0)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { sqlPaginate } from '../pagination/paginate.js'
|
|
2
|
+
import { toSnakeCase } from '../../core/case-mapper.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* BaseRepository
|
|
6
|
+
*
|
|
7
|
+
* Provides:
|
|
8
|
+
* - tableName getter (static + instance support)
|
|
9
|
+
* - baseQuery(options)
|
|
10
|
+
* - constructor-level query shaping (optional)
|
|
11
|
+
* - generic find() with pagination
|
|
12
|
+
*
|
|
13
|
+
* Does NOT enforce tenant isolation.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export class BaseRepository {
|
|
17
|
+
constructor({ db, log, baseQueryBuilder } = {}) {
|
|
18
|
+
if (!db) {
|
|
19
|
+
throw new Error('BaseRepository requires db instance')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
this.db = db
|
|
23
|
+
this.log = log
|
|
24
|
+
this._baseQueryBuilder = baseQueryBuilder
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Each concrete repository must define:
|
|
29
|
+
* static tableName = 'table_name'
|
|
30
|
+
*/
|
|
31
|
+
get tableName() {
|
|
32
|
+
if (!this.constructor.tableName) {
|
|
33
|
+
throw new Error(`tableName not defined for ${this.constructor.name}`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return this.constructor.tableName
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Builds the base knex query.
|
|
41
|
+
* Applies constructor-level baseQueryBuilder if provided.
|
|
42
|
+
*/
|
|
43
|
+
baseQuery(options = {}, params) {
|
|
44
|
+
const { trx } = options
|
|
45
|
+
const connection = trx || this.db
|
|
46
|
+
|
|
47
|
+
const qb = connection(this.tableName)
|
|
48
|
+
|
|
49
|
+
if (this._baseQueryBuilder) {
|
|
50
|
+
// Pass full params for optional dynamic shaping
|
|
51
|
+
return this._baseQueryBuilder(qb, params)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return qb
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Generic paginated find
|
|
59
|
+
*/
|
|
60
|
+
async find({
|
|
61
|
+
page = 1,
|
|
62
|
+
limit = 10,
|
|
63
|
+
filter = {},
|
|
64
|
+
orderBy = { column: 'created_at', direction: 'desc' },
|
|
65
|
+
options = {},
|
|
66
|
+
mapRow = (row) => row,
|
|
67
|
+
...restParams
|
|
68
|
+
}) {
|
|
69
|
+
try {
|
|
70
|
+
const qb = this.baseQuery(options, {
|
|
71
|
+
page,
|
|
72
|
+
limit,
|
|
73
|
+
filter,
|
|
74
|
+
orderBy,
|
|
75
|
+
options,
|
|
76
|
+
...restParams,
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
return await sqlPaginate({
|
|
80
|
+
page,
|
|
81
|
+
limit,
|
|
82
|
+
orderBy,
|
|
83
|
+
baseQuery: qb,
|
|
84
|
+
mapRow,
|
|
85
|
+
filter: toSnakeCase(filter),
|
|
86
|
+
})
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (this.log) {
|
|
89
|
+
this.log.error({ error }, `Error finding from ${this.tableName}`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
throw error
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TenantScopedRepository
|
|
3
|
+
*
|
|
4
|
+
* Extends BaseRepository
|
|
5
|
+
* Enforces tenantId presence inside filter.
|
|
6
|
+
*
|
|
7
|
+
* Does NOT modify filter.
|
|
8
|
+
* Does NOT rebuild filter.
|
|
9
|
+
* Only validates existence.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { BaseRepository } from './BaseRepository.js'
|
|
13
|
+
|
|
14
|
+
export class TenantScopedRepository extends BaseRepository {
|
|
15
|
+
/**
|
|
16
|
+
* Extracts and validates tenantId from filter
|
|
17
|
+
*/
|
|
18
|
+
getRequiredTenantId(filter = {}) {
|
|
19
|
+
const { tenantId } = filter
|
|
20
|
+
|
|
21
|
+
if (!tenantId) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`Scoped repository (${this.tableName}), tenantId is required`,
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return tenantId
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Overrides find to enforce tenant presence
|
|
32
|
+
*/
|
|
33
|
+
async find(params = {}) {
|
|
34
|
+
const { filter = {} } = params
|
|
35
|
+
|
|
36
|
+
this.getRequiredTenantId(filter)
|
|
37
|
+
|
|
38
|
+
return super.find(params)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { BaseRepository } from '../../src/postgresql/repositories/BaseRepository.js'
|
|
3
|
+
|
|
4
|
+
// 🔥 mock dependencies
|
|
5
|
+
vi.mock('../../src/postgresql/pagination/paginate.js', () => ({
|
|
6
|
+
sqlPaginate: vi.fn().mockResolvedValue({ page: 1 }),
|
|
7
|
+
}))
|
|
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)),
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
// import AFTER mocks
|
|
22
|
+
import { sqlPaginate } from '../../src/postgresql/pagination/paginate.js'
|
|
23
|
+
import { toSnakeCase } from '../../src/core/case-mapper.js'
|
|
24
|
+
|
|
25
|
+
describe('BaseRepository', () => {
|
|
26
|
+
let dbMock
|
|
27
|
+
let logMock
|
|
28
|
+
|
|
29
|
+
class TestRepo extends BaseRepository {
|
|
30
|
+
static tableName = 'test_table'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
dbMock = vi.fn(() => ({
|
|
35
|
+
clone: vi.fn(),
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
logMock = {
|
|
39
|
+
error: vi.fn(),
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('throws if db not provided', () => {
|
|
44
|
+
expect(() => new BaseRepository()).toThrow()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('returns correct tableName', () => {
|
|
48
|
+
const repo = new TestRepo({ db: dbMock })
|
|
49
|
+
expect(repo.tableName).toBe('test_table')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('baseQuery uses db when no trx', () => {
|
|
53
|
+
const repo = new TestRepo({ db: dbMock })
|
|
54
|
+
|
|
55
|
+
repo.baseQuery()
|
|
56
|
+
|
|
57
|
+
expect(dbMock).toHaveBeenCalledWith('test_table')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('baseQuery uses trx when provided', () => {
|
|
61
|
+
const trxMock = vi.fn()
|
|
62
|
+
const repo = new TestRepo({ db: dbMock })
|
|
63
|
+
|
|
64
|
+
repo.baseQuery({ trx: trxMock })
|
|
65
|
+
|
|
66
|
+
expect(trxMock).toHaveBeenCalledWith('test_table')
|
|
67
|
+
expect(dbMock).not.toHaveBeenCalled()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('baseQuery applies baseQueryBuilder', () => {
|
|
71
|
+
const builderMock = vi.fn((qb) => qb)
|
|
72
|
+
const repo = new TestRepo({
|
|
73
|
+
db: dbMock,
|
|
74
|
+
baseQueryBuilder: builderMock,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
repo.baseQuery({}, { some: 'param' })
|
|
78
|
+
|
|
79
|
+
expect(builderMock).toHaveBeenCalled()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('find calls sqlPaginate with correct params', async () => {
|
|
83
|
+
const repo = new TestRepo({ db: dbMock })
|
|
84
|
+
|
|
85
|
+
await repo.find({
|
|
86
|
+
page: 2,
|
|
87
|
+
limit: 5,
|
|
88
|
+
filter: { a: 1 },
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
expect(sqlPaginate).toHaveBeenCalled()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('logs error if sqlPaginate throws', async () => {
|
|
95
|
+
// @ts-ignore
|
|
96
|
+
sqlPaginate.mockRejectedValueOnce(new Error('fail'))
|
|
97
|
+
|
|
98
|
+
const repo = new TestRepo({ db: dbMock, log: logMock })
|
|
99
|
+
|
|
100
|
+
await expect(repo.find({ filter: {} })).rejects.toThrow()
|
|
101
|
+
|
|
102
|
+
expect(logMock.error).toHaveBeenCalled()
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { TenantScopedRepository } from '../../src/postgresql/repositories/TenantScopedRepository.js'
|
|
3
|
+
|
|
4
|
+
class TestTenantRepo extends TenantScopedRepository {
|
|
5
|
+
static tableName = 'tenant_table'
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe('TenantScopedRepository', () => {
|
|
9
|
+
const dbMock = vi.fn(() => ({}))
|
|
10
|
+
|
|
11
|
+
it('throws if tenantId missing', async () => {
|
|
12
|
+
const repo = new TestTenantRepo({ db: dbMock })
|
|
13
|
+
|
|
14
|
+
await expect(repo.find({ filter: {} })).rejects.toThrow()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('does not throw if tenantId exists', async () => {
|
|
18
|
+
const repo = new TestTenantRepo({ db: dbMock })
|
|
19
|
+
|
|
20
|
+
// Spy on BaseRepository.find instead of overriding repo.find
|
|
21
|
+
const superSpy = vi
|
|
22
|
+
.spyOn(Object.getPrototypeOf(TenantScopedRepository.prototype), 'find')
|
|
23
|
+
.mockResolvedValue(true)
|
|
24
|
+
|
|
25
|
+
const result = await repo.find({
|
|
26
|
+
filter: { tenantId: 'abc' },
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
expect(result).toBe(true)
|
|
30
|
+
expect(superSpy).toHaveBeenCalled()
|
|
31
|
+
|
|
32
|
+
superSpy.mockRestore()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('getRequiredTenantId returns tenantId', () => {
|
|
36
|
+
const repo = new TestTenantRepo({ db: dbMock })
|
|
37
|
+
|
|
38
|
+
const id = repo.getRequiredTenantId({
|
|
39
|
+
tenantId: 'tenant-1',
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
expect(id).toBe('tenant-1')
|
|
43
|
+
})
|
|
44
|
+
})
|