core-services-sdk 1.3.64 → 1.3.66

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.
Files changed (32) hide show
  1. package/package.json +2 -1
  2. package/src/postgresql/core/get-table-name.js +10 -0
  3. package/src/postgresql/filters/apply-filter-object.js +19 -0
  4. package/src/postgresql/filters/apply-filter-snake-case.js +16 -0
  5. package/src/postgresql/filters/apply-filter.js +33 -0
  6. package/src/postgresql/filters/operators.js +32 -0
  7. package/src/postgresql/index.js +5 -2
  8. package/src/postgresql/modifiers/apply-order-by.js +19 -0
  9. package/src/postgresql/modifiers/apply-pagination.js +12 -0
  10. package/src/postgresql/pagination/paginate.js +48 -0
  11. package/tests/core/normalize-phone-number.unit.test.js +1 -1
  12. package/tests/postgresql/apply-filter-snake-case.integration.test.js +27 -356
  13. package/tests/postgresql/apply-filter.integration.test.js +31 -81
  14. package/tests/postgresql/core/get-table-name.unit.test.js +20 -0
  15. package/tests/postgresql/filters/apply-filter-object.test.js +23 -0
  16. package/tests/postgresql/filters/operators.unit.test.js +23 -0
  17. package/tests/postgresql/modifiers/apply-order-by.test.js +80 -0
  18. package/tests/postgresql/modifiers/apply-pagination.unit.test.js +18 -0
  19. package/tests/postgresql/paginate.integration.test.js +55 -36
  20. package/tests/postgresql/pagination/paginate.js +49 -0
  21. package/tests/postgresql/validate-schema.integration.test.js +9 -5
  22. package/types/postgresql/core/get-table-name.d.ts +10 -0
  23. package/types/postgresql/filters/apply-filter-object.d.ts +15 -0
  24. package/types/postgresql/filters/apply-filter-snake-case.d.ts +14 -0
  25. package/types/postgresql/filters/apply-filter.d.ts +15 -0
  26. package/types/postgresql/filters/operators.d.ts +14 -0
  27. package/types/postgresql/index.d.ts +5 -2
  28. package/types/postgresql/modifiers/apply-order-by.d.ts +17 -0
  29. package/types/postgresql/modifiers/apply-pagination.d.ts +17 -0
  30. package/types/postgresql/pagination/paginate.d.ts +29 -0
  31. package/src/postgresql/apply-filter.js +0 -401
  32. package/src/postgresql/paginate.js +0 -61
@@ -0,0 +1,23 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { applyFilterObject } from '../../../src/postgresql/filters/apply-filter-object.js'
3
+ import { OPERATORS } from '../../../src/postgresql/filters/operators.js'
4
+
5
+ describe('applyFilterObject', () => {
6
+ it('applies multiple operators', () => {
7
+ const q = {}
8
+ // @ts-ignore
9
+ const spy = vi.spyOn(OPERATORS, 'gte').mockReturnValue(q)
10
+
11
+ // @ts-ignore
12
+ applyFilterObject(q, 'price', { gte: 100 })
13
+
14
+ expect(spy).toHaveBeenCalledWith(q, 'price', 100)
15
+ })
16
+
17
+ it('ignores unknown operators', () => {
18
+ const q = {}
19
+ // @ts-ignore
20
+ const result = applyFilterObject(q, 'x', { unknown: 1 })
21
+ expect(result).toBe(q)
22
+ })
23
+ })
@@ -0,0 +1,23 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+
3
+ import { OPERATORS } from '../../../src/postgresql/filters/operators.js'
4
+
5
+ describe('OPERATORS', () => {
6
+ it('eq calls where with "="', () => {
7
+ const q = { where: vi.fn(() => q) }
8
+ OPERATORS.eq(q, 'a', 1)
9
+ expect(q.where).toHaveBeenCalledWith('a', '=', 1)
10
+ })
11
+
12
+ it('in calls whereIn', () => {
13
+ const q = { whereIn: vi.fn(() => q) }
14
+ OPERATORS.in(q, 'a', [1, 2])
15
+ expect(q.whereIn).toHaveBeenCalledWith('a', [1, 2])
16
+ })
17
+
18
+ it('isNull true calls whereNull', () => {
19
+ const q = { whereNull: vi.fn(() => q) }
20
+ OPERATORS.isNull(q, 'a', true)
21
+ expect(q.whereNull).toHaveBeenCalledWith('a')
22
+ })
23
+ })
@@ -0,0 +1,80 @@
1
+ // @ts-nocheck
2
+ import { describe, it, expect, vi } from 'vitest'
3
+ import { applyOrderBy } from '../../../src/postgresql/modifiers/apply-order-by.js'
4
+
5
+ describe('applyOrderBy', () => {
6
+ it('does nothing when orderBy is not provided', () => {
7
+ const query = {}
8
+
9
+ // @ts-ignore
10
+ const result = applyOrderBy({ query })
11
+
12
+ expect(result).toBe(query)
13
+ })
14
+
15
+ it('does nothing when orderBy.column is missing', () => {
16
+ const query = {}
17
+
18
+ const result = applyOrderBy({
19
+ query,
20
+ orderBy: {},
21
+ })
22
+
23
+ expect(result).toBe(query)
24
+ })
25
+
26
+ it('applies ORDER BY with default direction asc', () => {
27
+ const query = {
28
+ orderBy: vi.fn(() => query),
29
+ _single: { table: 'assets' },
30
+ }
31
+
32
+ applyOrderBy({
33
+ query,
34
+ orderBy: { column: 'created_at' },
35
+ })
36
+
37
+ expect(query.orderBy).toHaveBeenCalledWith('assets.created_at', 'asc')
38
+ })
39
+
40
+ it('applies ORDER BY with explicit direction', () => {
41
+ const query = {
42
+ orderBy: vi.fn(() => query),
43
+ _single: { table: 'assets' },
44
+ }
45
+
46
+ applyOrderBy({
47
+ query,
48
+ orderBy: { column: 'created_at', direction: 'desc' },
49
+ })
50
+
51
+ expect(query.orderBy).toHaveBeenCalledWith('assets.created_at', 'desc')
52
+ })
53
+
54
+ it('uses unqualified column when table name cannot be resolved', () => {
55
+ const query = {
56
+ orderBy: vi.fn(() => query),
57
+ }
58
+
59
+ applyOrderBy({
60
+ query,
61
+ orderBy: { column: 'created_at' },
62
+ })
63
+
64
+ expect(query.orderBy).toHaveBeenCalledWith('created_at', 'asc')
65
+ })
66
+
67
+ it('returns the same query instance for chaining', () => {
68
+ const query = {
69
+ orderBy: vi.fn(() => query),
70
+ _single: { table: 'assets' },
71
+ }
72
+
73
+ const result = applyOrderBy({
74
+ query,
75
+ orderBy: { column: 'id' },
76
+ })
77
+
78
+ expect(result).toBe(query)
79
+ })
80
+ })
@@ -0,0 +1,18 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+
3
+ import { applyPagination } from '../../../src/postgresql/modifiers/apply-pagination.js'
4
+
5
+ describe('applyPagination', () => {
6
+ it('applies limit and offset', () => {
7
+ const q = {
8
+ limit: vi.fn(() => q),
9
+ offset: vi.fn(() => q),
10
+ }
11
+
12
+ // @ts-ignore
13
+ applyPagination({ query: q, page: 2, limit: 10 })
14
+
15
+ expect(q.limit).toHaveBeenCalledWith(10)
16
+ expect(q.offset).toHaveBeenCalledWith(10)
17
+ })
18
+ })
@@ -8,14 +8,15 @@ import {
8
8
  buildPostgresUri,
9
9
  } from '../../src/postgresql/start-stop-postgres-docker.js'
10
10
 
11
- import { sqlPaginate } from '../../src/postgresql/paginate.js'
11
+ import { sqlPaginate } from '../../src/postgresql/pagination/paginate.js'
12
+ import { sub } from 'date-fns'
12
13
 
13
14
  const PG_OPTIONS = {
14
15
  port: 5442,
15
- containerName: 'postgres-paginate-test-5442',
16
+ db: 'testdb',
16
17
  user: 'testuser',
17
18
  pass: 'testpass',
18
- db: 'testdb',
19
+ containerName: 'postgres-paginate-test-5442',
19
20
  }
20
21
 
21
22
  const DATABASE_URI = buildPostgresUri(PG_OPTIONS)
@@ -34,6 +35,7 @@ beforeAll(async () => {
34
35
  table.uuid('id').primary()
35
36
  table.string('name').notNullable()
36
37
  table.string('type').notNullable()
38
+ table.bigInteger('age').notNullable()
37
39
  table.timestamp('created_at').notNullable()
38
40
  })
39
41
  })
@@ -47,70 +49,88 @@ afterAll(async () => {
47
49
 
48
50
  beforeEach(async () => {
49
51
  await db('tenants').truncate()
50
-
51
- await db('tenants').insert([
52
+ const records = [
52
53
  {
53
54
  id: '00000000-0000-0000-0000-000000000001',
54
55
  name: 'Tenant A',
55
56
  type: 'business',
56
- created_at: new Date('2024-01-01'),
57
+ age: 2,
58
+ created_at: new Date('2025-01-01'),
57
59
  },
58
60
  {
59
61
  id: '00000000-0000-0000-0000-000000000002',
60
62
  name: 'Tenant B',
63
+ age: 3,
61
64
  type: 'business',
62
65
  created_at: new Date('2024-01-02'),
63
66
  },
64
67
  {
65
68
  id: '00000000-0000-0000-0000-000000000003',
66
69
  name: 'Tenant C',
70
+ age: 1,
71
+ type: 'cpa',
72
+ created_at: new Date('2023-01-03'),
73
+ },
74
+
75
+ {
76
+ id: '00000000-0000-0000-0000-000000000004',
77
+ name: 'Tenant D',
78
+ age: 7,
79
+ type: 'cpa',
80
+ created_at: new Date('2022-01-03'),
81
+ },
82
+
83
+ {
84
+ id: '00000000-0000-0000-0000-000000000005',
85
+ name: 'Tenant E',
86
+ age: 0,
67
87
  type: 'cpa',
68
- created_at: new Date('2024-01-03'),
88
+ created_at: new Date('2021-01-03'),
69
89
  },
70
- ])
90
+ ]
91
+ await db('tenants').insert(records)
71
92
  })
72
93
 
73
94
  describe('paginate integration', () => {
74
95
  it('returns first page without ordering guarantees', async () => {
75
96
  const result = await sqlPaginate({
76
- db,
77
- tableName: 'tenants',
78
- page: 1,
97
+ baseQuery: db('tenants'),
98
+ page: 2,
79
99
  limit: 2,
80
100
  })
81
101
 
82
- expect(result.totalCount).toBe(3)
83
- expect(result.totalPages).toBe(2)
84
- expect(result.currentPage).toBe(1)
85
- expect(result.hasPrevious).toBe(false)
102
+ expect(result.totalCount).toBe(5)
103
+ expect(result.pages).toBe(3)
104
+ expect(result.page).toBe(2)
105
+ expect(result.hasPrevious).toBe(true)
86
106
  expect(result.hasNext).toBe(true)
87
-
88
107
  expect(result.list).toHaveLength(2)
89
108
  })
90
109
 
91
110
  it('returns second page without ordering guarantees', async () => {
92
111
  const result = await sqlPaginate({
93
- db,
94
- tableName: 'tenants',
112
+ baseQuery: db('tenants'),
95
113
  page: 2,
96
114
  limit: 2,
97
115
  })
98
116
 
99
- expect(result.totalCount).toBe(3)
100
- expect(result.totalPages).toBe(2)
101
- expect(result.currentPage).toBe(2)
117
+ expect(result.totalCount).toBe(5)
118
+ expect(result.pages).toBe(3)
119
+ expect(result.page).toBe(2)
102
120
  expect(result.hasPrevious).toBe(true)
103
- expect(result.hasNext).toBe(false)
104
-
105
- expect(result.list).toHaveLength(1)
121
+ expect(result.hasNext).toBe(true)
122
+ expect(result.list).toHaveLength(2)
106
123
  })
107
124
 
108
- it('applies filters correctly without ordering guarantees', async () => {
125
+ it('applies filters correctly', async () => {
126
+ const minDate = sub(new Date(), { years: 2 })
109
127
  const result = await sqlPaginate({
110
- db,
111
- tableName: 'tenants',
112
- filter: { type: 'business' },
128
+ baseQuery: db('tenants'),
129
+ filter: {
130
+ createdAt: { lte: new Date(), gte: minDate },
131
+ },
113
132
  limit: 10,
133
+ page: 1,
114
134
  })
115
135
 
116
136
  expect(result.totalCount).toBe(2)
@@ -121,10 +141,9 @@ describe('paginate integration', () => {
121
141
 
122
142
  it('supports custom ordering', async () => {
123
143
  const result = await sqlPaginate({
124
- db,
125
- tableName: 'tenants',
144
+ baseQuery: db('tenants'),
126
145
  orderBy: {
127
- column: 'created_at',
146
+ column: 'name',
128
147
  direction: 'asc',
129
148
  },
130
149
  })
@@ -133,13 +152,14 @@ describe('paginate integration', () => {
133
152
  'Tenant A',
134
153
  'Tenant B',
135
154
  'Tenant C',
155
+ 'Tenant D',
156
+ 'Tenant E',
136
157
  ])
137
158
  })
138
159
 
139
160
  it('supports row mapping', async () => {
140
161
  const result = await sqlPaginate({
141
- db,
142
- tableName: 'tenants',
162
+ baseQuery: db('tenants'),
143
163
  mapRow: (row) => ({
144
164
  ...row,
145
165
  mapped: true,
@@ -152,13 +172,12 @@ describe('paginate integration', () => {
152
172
 
153
173
  it('returns empty list when no records match filter', async () => {
154
174
  const result = await sqlPaginate({
155
- db,
156
- tableName: 'tenants',
175
+ baseQuery: db('tenants'),
157
176
  filter: { type: 'non-existing' },
158
177
  })
159
178
 
160
179
  expect(result.totalCount).toBe(0)
161
- expect(result.totalPages).toBe(0)
180
+ expect(result.pages).toBe(0)
162
181
  expect(result.list).toEqual([])
163
182
  expect(result.hasNext).toBe(false)
164
183
  expect(result.hasPrevious).toBe(false)
@@ -0,0 +1,49 @@
1
+ // @ts-nocheck
2
+ import { applyFilter } from '../filters/apply-filter.js'
3
+ import { applyFilterSnakeCase } from '../filters/apply-filter-snake-case.js'
4
+ import { applyOrderBy } from '../modifiers/apply-order-by.js'
5
+ import { applyPagination } from '../modifiers/apply-pagination.js'
6
+ import { normalizeNumberOrDefault } from '../../core/normalize-premitives-types-or-default.js'
7
+
8
+ /**
9
+ * Executes paginated query.
10
+ *
11
+ * @async
12
+ */
13
+ export async function sqlPaginate({
14
+ mapRow,
15
+ orderBy,
16
+ page = 1,
17
+ baseQuery,
18
+ limit = 10,
19
+ filter = {},
20
+ snakeCase = true,
21
+ }) {
22
+ const listQuery = baseQuery.clone()
23
+ const countQuery = baseQuery.clone()
24
+
25
+ const applyFilterFn = snakeCase ? applyFilterSnakeCase : applyFilter
26
+
27
+ applyFilterFn({ query: listQuery, filter })
28
+ applyFilterFn({ query: countQuery, filter })
29
+
30
+ applyOrderBy({ query: listQuery, orderBy })
31
+ applyPagination({ query: listQuery, page, limit })
32
+
33
+ const [rows, countResult] = await Promise.all([
34
+ listQuery.select('*'),
35
+ countQuery.count('* as count').first(),
36
+ ])
37
+
38
+ const totalCount = normalizeNumberOrDefault(countResult?.count || 0)
39
+ const pages = Math.ceil(totalCount / limit)
40
+
41
+ return {
42
+ totalCount,
43
+ pages,
44
+ currentPage: page,
45
+ hasPrevious: page > 1,
46
+ hasNext: page < pages,
47
+ list: mapRow ? rows.map(mapRow) : rows,
48
+ }
49
+ }
@@ -5,20 +5,21 @@ import {
5
5
  stopPostgres,
6
6
  buildPostgresUri,
7
7
  } from '../../src/postgresql/start-stop-postgres-docker.js'
8
+
8
9
  import { connectToPg } from '../../src/postgresql/connect-to-pg.js'
9
10
  import { validateSchema } from '../../src/postgresql/validate-schema.js'
10
11
 
11
12
  const PG_OPTIONS = {
12
13
  port: 5431,
13
- containerName: 'postgres-validate-schema-test-5431',
14
+ db: 'testdb',
14
15
  user: 'testuser',
15
16
  pass: 'testpass',
16
- db: 'testdb',
17
+ containerName: 'postgres-validate-schema-test-5431',
17
18
  }
18
19
 
19
20
  const DATABASE_URI = buildPostgresUri(PG_OPTIONS)
20
21
 
21
- describe('validateSchema', () => {
22
+ describe('validateSchema integration', () => {
22
23
  beforeAll(async () => {
23
24
  startPostgres(PG_OPTIONS)
24
25
 
@@ -32,13 +33,16 @@ describe('validateSchema', () => {
32
33
  await db.destroy()
33
34
  })
34
35
 
35
- afterAll(() => {
36
+ afterAll(async () => {
36
37
  stopPostgres(PG_OPTIONS.containerName)
37
38
  })
38
39
 
39
40
  it('does not throw when all required tables exist', async () => {
40
41
  await expect(
41
- validateSchema({ connection: DATABASE_URI, tables: ['files'] }),
42
+ validateSchema({
43
+ connection: DATABASE_URI,
44
+ tables: ['files'],
45
+ }),
42
46
  ).resolves.not.toThrow()
43
47
  })
44
48
 
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Extracts base table name from Knex QueryBuilder.
3
+ * Relies on Knex internal structure.
4
+ *
5
+ * @param {import('knex').Knex.QueryBuilder} query
6
+ * @returns {string|undefined}
7
+ */
8
+ export function getTableNameFromQuery(
9
+ query: import('knex').Knex.QueryBuilder,
10
+ ): string | undefined
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Applies multiple operators on the same column.
3
+ *
4
+ * @param {import('knex').Knex.QueryBuilder} query
5
+ * @param {string} qualifiedKey
6
+ * @param {Object<string, *>} value
7
+ * @returns {import('knex').Knex.QueryBuilder}
8
+ */
9
+ export function applyFilterObject(
10
+ query: import('knex').Knex.QueryBuilder,
11
+ qualifiedKey: string,
12
+ value: {
13
+ [x: string]: any
14
+ },
15
+ ): import('knex').Knex.QueryBuilder
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Applies filters with automatic camelCase to snake_case conversion.
3
+ *
4
+ * @param {Object} params
5
+ * @param {import('knex').Knex.QueryBuilder} params.query
6
+ * @param {Object} params.filter
7
+ */
8
+ export function applyFilterSnakeCase({
9
+ query,
10
+ filter,
11
+ }: {
12
+ query: import('knex').Knex.QueryBuilder
13
+ filter: any
14
+ }): import('knex').Knex.QueryBuilder<any, any>
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Applies MongoDB-style filters to a Knex QueryBuilder.
3
+ *
4
+ * @param {Object} params
5
+ * @param {import('knex').Knex.QueryBuilder} params.query
6
+ * @param {Object} [params.filter]
7
+ * @returns {import('knex').Knex.QueryBuilder}
8
+ */
9
+ export function applyFilter({
10
+ query,
11
+ filter,
12
+ }: {
13
+ query: import('knex').Knex.QueryBuilder
14
+ filter?: any
15
+ }): import('knex').Knex.QueryBuilder
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @typedef {Function} OperatorFunction
3
+ * @param {import('knex').Knex.QueryBuilder} q
4
+ * @param {string} key
5
+ * @param {*} value
6
+ * @returns {import('knex').Knex.QueryBuilder}
7
+ */
8
+ /**
9
+ * @type {Object<string, OperatorFunction>}
10
+ */
11
+ export const OPERATORS: {
12
+ [x: string]: Function
13
+ }
14
+ export type OperatorFunction = Function
@@ -1,5 +1,8 @@
1
- export * from './paginate.js'
2
- export * from './apply-filter.js'
3
1
  export * from './connect-to-pg.js'
4
2
  export * from './validate-schema.js'
3
+ export * from './pagination/paginate.js'
4
+ export * from './filters/apply-filter.js'
5
+ export * from './modifiers/apply-order-by.js'
5
6
  export * from './start-stop-postgres-docker.js'
7
+ export * from './modifiers/apply-pagination.js'
8
+ export * from './filters/apply-filter-snake-case.js'
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Applies ORDER BY clause.
3
+ *
4
+ * @param {Object} params
5
+ * @param {import('knex').Knex.QueryBuilder} params.query
6
+ * @param {{ column: string, direction?: 'asc'|'desc' }} params.orderBy
7
+ */
8
+ export function applyOrderBy({
9
+ query,
10
+ orderBy,
11
+ }: {
12
+ query: import('knex').Knex.QueryBuilder
13
+ orderBy: {
14
+ column: string
15
+ direction?: 'asc' | 'desc'
16
+ }
17
+ }): import('knex').Knex.QueryBuilder<any, any>
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Applies LIMIT and OFFSET.
3
+ *
4
+ * @param {Object} params
5
+ * @param {import('knex').Knex.QueryBuilder} params.query
6
+ * @param {number} [params.page=1]
7
+ * @param {number} [params.limit=10]
8
+ */
9
+ export function applyPagination({
10
+ query,
11
+ page,
12
+ limit,
13
+ }: {
14
+ query: import('knex').Knex.QueryBuilder
15
+ page?: number
16
+ limit?: number
17
+ }): import('knex').Knex.QueryBuilder<any, any>
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Executes paginated query.
3
+ *
4
+ * @async
5
+ */
6
+ export function sqlPaginate({
7
+ mapRow,
8
+ orderBy,
9
+ page,
10
+ baseQuery,
11
+ limit,
12
+ filter,
13
+ snakeCase,
14
+ }: {
15
+ mapRow: any
16
+ orderBy: any
17
+ page?: number
18
+ baseQuery: any
19
+ limit?: number
20
+ filter?: {}
21
+ snakeCase?: boolean
22
+ }): Promise<{
23
+ totalCount: number
24
+ totalPages: number
25
+ currentPage: number
26
+ hasPrevious: boolean
27
+ hasNext: boolean
28
+ list: any
29
+ }>