@geekmidas/db 0.2.0 → 0.3.0

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.
@@ -1,341 +1,341 @@
1
1
  import {
2
- CamelCasePlugin,
3
- type Generated,
4
- Kysely,
5
- PostgresDialect,
6
- sql,
2
+ CamelCasePlugin,
3
+ type Generated,
4
+ Kysely,
5
+ PostgresDialect,
6
+ sql,
7
7
  } from 'kysely';
8
8
  import pg from 'pg';
9
9
  import {
10
- afterAll,
11
- afterEach,
12
- beforeAll,
13
- beforeEach,
14
- describe,
15
- expect,
16
- it,
10
+ afterAll,
11
+ afterEach,
12
+ beforeAll,
13
+ beforeEach,
14
+ describe,
15
+ expect,
16
+ it,
17
17
  } from 'vitest';
18
18
  import { TEST_DATABASE_CONFIG } from '../../../../testkit/test/globalSetup';
19
19
  import {
20
- Direction,
21
- decodeCursor,
22
- encodeCursor,
23
- paginatedSearch,
20
+ Direction,
21
+ decodeCursor,
22
+ encodeCursor,
23
+ paginatedSearch,
24
24
  } from '../pagination';
25
25
 
26
26
  interface TestDatabase {
27
- paginationTestItems: {
28
- id: Generated<number>;
29
- name: string;
30
- category: string;
31
- price: number;
32
- createdAt: Generated<Date>;
33
- };
27
+ paginationTestItems: {
28
+ id: Generated<number>;
29
+ name: string;
30
+ category: string;
31
+ price: number;
32
+ createdAt: Generated<Date>;
33
+ };
34
34
  }
35
35
 
36
36
  describe('Pagination Integration Tests', () => {
37
- let db: Kysely<TestDatabase>;
38
-
39
- beforeAll(async () => {
40
- db = new Kysely<TestDatabase>({
41
- dialect: new PostgresDialect({
42
- pool: new pg.Pool({
43
- ...TEST_DATABASE_CONFIG,
44
- database: 'postgres',
45
- }),
46
- }),
47
- plugins: [new CamelCasePlugin()],
48
- });
49
-
50
- // Create test table
51
- await db.schema
52
- .createTable('pagination_test_items')
53
- .ifNotExists()
54
- .addColumn('id', 'serial', (col) => col.primaryKey())
55
- .addColumn('name', 'varchar(255)', (col) => col.notNull())
56
- .addColumn('category', 'varchar(100)', (col) => col.notNull())
57
- .addColumn('price', 'numeric(10, 2)', (col) => col.notNull())
58
- .addColumn('created_at', 'timestamptz', (col) =>
59
- col.defaultTo(sql`now()`).notNull(),
60
- )
61
- .execute();
62
- });
63
-
64
- beforeEach(async () => {
65
- // Insert 25 test items
66
- const items = [];
67
- for (let i = 1; i <= 25; i++) {
68
- items.push({
69
- name: `Item ${String(i).padStart(2, '0')}`,
70
- category:
71
- i <= 10 ? 'category-a' : i <= 20 ? 'category-b' : 'category-c',
72
- price: i * 10,
73
- });
74
- }
75
- await db.insertInto('paginationTestItems').values(items).execute();
76
- });
77
-
78
- afterEach(async () => {
79
- await db.deleteFrom('paginationTestItems').execute();
80
- });
81
-
82
- afterAll(async () => {
83
- await db.schema.dropTable('pagination_test_items').ifExists().execute();
84
- await db.destroy();
85
- });
86
-
87
- describe('paginatedSearch', () => {
88
- it('should return first page of results with default settings', async () => {
89
- const result = await paginatedSearch({
90
- query: db.selectFrom('paginationTestItems').selectAll(),
91
- limit: 10,
92
- mapRow: (row) => ({ id: row.id, name: row.name }),
93
- });
94
-
95
- expect(result.items).toHaveLength(10);
96
- expect(result.pagination.total).toBe(25);
97
- expect(result.pagination.hasMore).toBe(true);
98
- expect(result.pagination.cursor).toBeDefined();
99
- });
100
-
101
- it('should return second page using cursor', async () => {
102
- // Get first page
103
- const firstPage = await paginatedSearch({
104
- query: db.selectFrom('paginationTestItems').selectAll(),
105
- limit: 10,
106
- mapRow: (row) => ({ id: row.id, name: row.name }),
107
- cursorDirection: Direction.Asc,
108
- });
109
-
110
- expect(firstPage.pagination.cursor).toBeDefined();
111
-
112
- // Get second page using cursor
113
- const secondPage = await paginatedSearch({
114
- query: db.selectFrom('paginationTestItems').selectAll(),
115
- cursor: firstPage.pagination.cursor,
116
- limit: 10,
117
- mapRow: (row) => ({ id: row.id, name: row.name }),
118
- cursorDirection: Direction.Asc,
119
- });
120
-
121
- expect(secondPage.items).toHaveLength(10);
122
- expect(secondPage.pagination.total).toBe(25);
123
- expect(secondPage.pagination.hasMore).toBe(true);
124
-
125
- // Items should be different from first page
126
- const firstIds = firstPage.items.map((i) => i.id);
127
- const secondIds = secondPage.items.map((i) => i.id);
128
- expect(firstIds.every((id) => !secondIds.includes(id))).toBe(true);
129
- });
130
-
131
- it('should return last page with hasMore false', async () => {
132
- // Get all pages
133
- let cursor: string | undefined;
134
- let pages: { id: number; name: string }[][] = [];
135
-
136
- do {
137
- const page = await paginatedSearch({
138
- query: db.selectFrom('paginationTestItems').selectAll(),
139
- cursor,
140
- limit: 10,
141
- mapRow: (row) => ({ id: row.id, name: row.name }),
142
- cursorDirection: Direction.Asc,
143
- });
144
-
145
- pages.push(page.items);
146
- cursor = page.pagination.cursor;
147
-
148
- if (!page.pagination.hasMore) break;
149
- } while (cursor);
150
-
151
- // Should have 3 pages
152
- expect(pages).toHaveLength(3);
153
- expect(pages[0]).toHaveLength(10);
154
- expect(pages[1]).toHaveLength(10);
155
- expect(pages[2]).toHaveLength(5);
156
-
157
- // Total items should be 25
158
- const allItems = pages.flat();
159
- expect(allItems).toHaveLength(25);
160
- });
161
-
162
- it('should paginate in descending order', async () => {
163
- const result = await paginatedSearch({
164
- query: db.selectFrom('paginationTestItems').selectAll(),
165
- limit: 5,
166
- mapRow: (row) => ({ id: row.id, name: row.name }),
167
- cursorDirection: Direction.Desc,
168
- });
169
-
170
- // First item should have highest ID
171
- const ids = result.items.map((i) => i.id);
172
- for (let i = 1; i < ids.length; i++) {
173
- expect(ids[i]).toBeLessThan(ids[i - 1]);
174
- }
175
-
176
- // Get second page
177
- const secondPage = await paginatedSearch({
178
- query: db.selectFrom('paginationTestItems').selectAll(),
179
- cursor: result.pagination.cursor,
180
- limit: 5,
181
- mapRow: (row) => ({ id: row.id, name: row.name }),
182
- cursorDirection: Direction.Desc,
183
- });
184
-
185
- // All IDs in second page should be less than all in first page
186
- const maxSecondPage = Math.max(...secondPage.items.map((i) => i.id));
187
- const minFirstPage = Math.min(...ids);
188
- expect(maxSecondPage).toBeLessThan(minFirstPage);
189
- });
190
-
191
- it('should work with custom cursor field', async () => {
192
- const result = await paginatedSearch({
193
- query: db.selectFrom('paginationTestItems').selectAll(),
194
- limit: 5,
195
- mapRow: (row) => ({ id: row.id, name: row.name, price: row.price }),
196
- cursorField: 'price',
197
- cursorDirection: Direction.Asc,
198
- });
199
-
200
- // Prices should be in ascending order
201
- const prices = result.items.map((i) => Number(i.price));
202
- for (let i = 1; i < prices.length; i++) {
203
- expect(prices[i]).toBeGreaterThan(prices[i - 1]);
204
- }
205
-
206
- // Cursor should contain the last price value
207
- expect(Number(result.pagination.cursor)).toBe(prices[prices.length - 1]);
208
-
209
- // Get second page
210
- const secondPage = await paginatedSearch({
211
- query: db.selectFrom('paginationTestItems').selectAll(),
212
- cursor: result.pagination.cursor,
213
- limit: 5,
214
- mapRow: (row) => ({ id: row.id, name: row.name, price: row.price }),
215
- cursorField: 'price',
216
- cursorDirection: Direction.Asc,
217
- });
218
-
219
- // All prices in second page should be greater than cursor
220
- const minSecondPage = Math.min(
221
- ...secondPage.items.map((i) => Number(i.price)),
222
- );
223
- expect(minSecondPage).toBeGreaterThan(prices[prices.length - 1]);
224
- });
225
-
226
- it('should use default limit of 20', async () => {
227
- const result = await paginatedSearch({
228
- query: db.selectFrom('paginationTestItems').selectAll(),
229
- mapRow: (row) => ({ id: row.id }),
230
- });
231
-
232
- expect(result.items).toHaveLength(20);
233
- });
234
-
235
- it('should work with filtered queries', async () => {
236
- const result = await paginatedSearch({
237
- query: db
238
- .selectFrom('paginationTestItems')
239
- .selectAll()
240
- .where('category', '=', 'category-a'),
241
- limit: 5,
242
- mapRow: (row) => ({
243
- id: row.id,
244
- name: row.name,
245
- category: row.category,
246
- }),
247
- });
248
-
249
- expect(result.pagination.total).toBe(10); // Only category-a items
250
- expect(result.items).toHaveLength(5);
251
- result.items.forEach((item) => {
252
- expect(item.category).toBe('category-a');
253
- });
254
- });
255
-
256
- it('should handle empty result set', async () => {
257
- const result = await paginatedSearch({
258
- query: db
259
- .selectFrom('paginationTestItems')
260
- .selectAll()
261
- .where('category', '=', 'nonexistent'),
262
- limit: 10,
263
- mapRow: (row) => ({ id: row.id }),
264
- });
265
-
266
- expect(result.items).toHaveLength(0);
267
- expect(result.pagination.total).toBe(0);
268
- expect(result.pagination.hasMore).toBe(false);
269
- expect(result.pagination.cursor).toBeUndefined();
270
- });
271
-
272
- it('should handle async mapRow function', async () => {
273
- const result = await paginatedSearch({
274
- query: db.selectFrom('paginationTestItems').selectAll(),
275
- limit: 5,
276
- mapRow: async (row) => {
277
- // Simulate async transformation
278
- await new Promise((resolve) => setTimeout(resolve, 1));
279
- return {
280
- id: row.id,
281
- displayName: `Product: ${row.name}`,
282
- priceFormatted: `$${Number(row.price).toFixed(2)}`,
283
- };
284
- },
285
- });
286
-
287
- expect(result.items).toHaveLength(5);
288
- result.items.forEach((item) => {
289
- expect(item.displayName).toMatch(/^Product: Item \d+$/);
290
- expect(item.priceFormatted).toMatch(/^\$\d+\.\d{2}$/);
291
- });
292
- });
293
- });
294
-
295
- describe('encodeCursor / decodeCursor', () => {
296
- it('should encode and decode string values', () => {
297
- const original = 'some-cursor-value';
298
- const encoded = encodeCursor(original);
299
- const decoded = decodeCursor(encoded);
300
-
301
- expect(decoded).toBe(original);
302
- });
303
-
304
- it('should encode and decode number values', () => {
305
- const original = 12345;
306
- const encoded = encodeCursor(original);
307
- const decoded = decodeCursor(encoded);
308
-
309
- expect(decoded).toBe(original);
310
- });
311
-
312
- it('should encode and decode Date values', () => {
313
- const original = new Date('2024-01-15T10:30:00.000Z');
314
- const encoded = encodeCursor(original);
315
- const decoded = decodeCursor(encoded);
316
-
317
- expect(decoded).toEqual(original);
318
- });
319
-
320
- it('should produce URL-safe base64 encoding', () => {
321
- const encoded = encodeCursor('test/value+with=special');
322
-
323
- // Base64url should not contain +, /, or =
324
- expect(encoded).not.toMatch(/[+/=]/);
325
- });
326
-
327
- it('should throw error for invalid cursor format', () => {
328
- expect(() => decodeCursor('invalid-cursor')).toThrow(
329
- 'Invalid cursor format',
330
- );
331
- });
332
-
333
- it('should throw error for malformed JSON', () => {
334
- // Create valid base64url but with invalid JSON content
335
- const malformedCursor = Buffer.from('not-json').toString('base64url');
336
- expect(() => decodeCursor(malformedCursor)).toThrow(
337
- 'Invalid cursor format',
338
- );
339
- });
340
- });
37
+ let db: Kysely<TestDatabase>;
38
+
39
+ beforeAll(async () => {
40
+ db = new Kysely<TestDatabase>({
41
+ dialect: new PostgresDialect({
42
+ pool: new pg.Pool({
43
+ ...TEST_DATABASE_CONFIG,
44
+ database: 'postgres',
45
+ }),
46
+ }),
47
+ plugins: [new CamelCasePlugin()],
48
+ });
49
+
50
+ // Create test table
51
+ await db.schema
52
+ .createTable('pagination_test_items')
53
+ .ifNotExists()
54
+ .addColumn('id', 'serial', (col) => col.primaryKey())
55
+ .addColumn('name', 'varchar(255)', (col) => col.notNull())
56
+ .addColumn('category', 'varchar(100)', (col) => col.notNull())
57
+ .addColumn('price', 'numeric(10, 2)', (col) => col.notNull())
58
+ .addColumn('created_at', 'timestamptz', (col) =>
59
+ col.defaultTo(sql`now()`).notNull(),
60
+ )
61
+ .execute();
62
+ });
63
+
64
+ beforeEach(async () => {
65
+ // Insert 25 test items
66
+ const items = [];
67
+ for (let i = 1; i <= 25; i++) {
68
+ items.push({
69
+ name: `Item ${String(i).padStart(2, '0')}`,
70
+ category:
71
+ i <= 10 ? 'category-a' : i <= 20 ? 'category-b' : 'category-c',
72
+ price: i * 10,
73
+ });
74
+ }
75
+ await db.insertInto('paginationTestItems').values(items).execute();
76
+ });
77
+
78
+ afterEach(async () => {
79
+ await db.deleteFrom('paginationTestItems').execute();
80
+ });
81
+
82
+ afterAll(async () => {
83
+ await db.schema.dropTable('pagination_test_items').ifExists().execute();
84
+ await db.destroy();
85
+ });
86
+
87
+ describe('paginatedSearch', () => {
88
+ it('should return first page of results with default settings', async () => {
89
+ const result = await paginatedSearch({
90
+ query: db.selectFrom('paginationTestItems').selectAll(),
91
+ limit: 10,
92
+ mapRow: (row) => ({ id: row.id, name: row.name }),
93
+ });
94
+
95
+ expect(result.items).toHaveLength(10);
96
+ expect(result.pagination.total).toBe(25);
97
+ expect(result.pagination.hasMore).toBe(true);
98
+ expect(result.pagination.cursor).toBeDefined();
99
+ });
100
+
101
+ it('should return second page using cursor', async () => {
102
+ // Get first page
103
+ const firstPage = await paginatedSearch({
104
+ query: db.selectFrom('paginationTestItems').selectAll(),
105
+ limit: 10,
106
+ mapRow: (row) => ({ id: row.id, name: row.name }),
107
+ cursorDirection: Direction.Asc,
108
+ });
109
+
110
+ expect(firstPage.pagination.cursor).toBeDefined();
111
+
112
+ // Get second page using cursor
113
+ const secondPage = await paginatedSearch({
114
+ query: db.selectFrom('paginationTestItems').selectAll(),
115
+ cursor: firstPage.pagination.cursor,
116
+ limit: 10,
117
+ mapRow: (row) => ({ id: row.id, name: row.name }),
118
+ cursorDirection: Direction.Asc,
119
+ });
120
+
121
+ expect(secondPage.items).toHaveLength(10);
122
+ expect(secondPage.pagination.total).toBe(25);
123
+ expect(secondPage.pagination.hasMore).toBe(true);
124
+
125
+ // Items should be different from first page
126
+ const firstIds = firstPage.items.map((i) => i.id);
127
+ const secondIds = secondPage.items.map((i) => i.id);
128
+ expect(firstIds.every((id) => !secondIds.includes(id))).toBe(true);
129
+ });
130
+
131
+ it('should return last page with hasMore false', async () => {
132
+ // Get all pages
133
+ let cursor: string | undefined;
134
+ const pages: { id: number; name: string }[][] = [];
135
+
136
+ do {
137
+ const page = await paginatedSearch({
138
+ query: db.selectFrom('paginationTestItems').selectAll(),
139
+ cursor,
140
+ limit: 10,
141
+ mapRow: (row) => ({ id: row.id, name: row.name }),
142
+ cursorDirection: Direction.Asc,
143
+ });
144
+
145
+ pages.push(page.items);
146
+ cursor = page.pagination.cursor;
147
+
148
+ if (!page.pagination.hasMore) break;
149
+ } while (cursor);
150
+
151
+ // Should have 3 pages
152
+ expect(pages).toHaveLength(3);
153
+ expect(pages[0]).toHaveLength(10);
154
+ expect(pages[1]).toHaveLength(10);
155
+ expect(pages[2]).toHaveLength(5);
156
+
157
+ // Total items should be 25
158
+ const allItems = pages.flat();
159
+ expect(allItems).toHaveLength(25);
160
+ });
161
+
162
+ it('should paginate in descending order', async () => {
163
+ const result = await paginatedSearch({
164
+ query: db.selectFrom('paginationTestItems').selectAll(),
165
+ limit: 5,
166
+ mapRow: (row) => ({ id: row.id, name: row.name }),
167
+ cursorDirection: Direction.Desc,
168
+ });
169
+
170
+ // First item should have highest ID
171
+ const ids = result.items.map((i) => i.id);
172
+ for (let i = 1; i < ids.length; i++) {
173
+ expect(ids[i]).toBeLessThan(ids[i - 1]);
174
+ }
175
+
176
+ // Get second page
177
+ const secondPage = await paginatedSearch({
178
+ query: db.selectFrom('paginationTestItems').selectAll(),
179
+ cursor: result.pagination.cursor,
180
+ limit: 5,
181
+ mapRow: (row) => ({ id: row.id, name: row.name }),
182
+ cursorDirection: Direction.Desc,
183
+ });
184
+
185
+ // All IDs in second page should be less than all in first page
186
+ const maxSecondPage = Math.max(...secondPage.items.map((i) => i.id));
187
+ const minFirstPage = Math.min(...ids);
188
+ expect(maxSecondPage).toBeLessThan(minFirstPage);
189
+ });
190
+
191
+ it('should work with custom cursor field', async () => {
192
+ const result = await paginatedSearch({
193
+ query: db.selectFrom('paginationTestItems').selectAll(),
194
+ limit: 5,
195
+ mapRow: (row) => ({ id: row.id, name: row.name, price: row.price }),
196
+ cursorField: 'price',
197
+ cursorDirection: Direction.Asc,
198
+ });
199
+
200
+ // Prices should be in ascending order
201
+ const prices = result.items.map((i) => Number(i.price));
202
+ for (let i = 1; i < prices.length; i++) {
203
+ expect(prices[i]).toBeGreaterThan(prices[i - 1]);
204
+ }
205
+
206
+ // Cursor should contain the last price value
207
+ expect(Number(result.pagination.cursor)).toBe(prices[prices.length - 1]);
208
+
209
+ // Get second page
210
+ const secondPage = await paginatedSearch({
211
+ query: db.selectFrom('paginationTestItems').selectAll(),
212
+ cursor: result.pagination.cursor,
213
+ limit: 5,
214
+ mapRow: (row) => ({ id: row.id, name: row.name, price: row.price }),
215
+ cursorField: 'price',
216
+ cursorDirection: Direction.Asc,
217
+ });
218
+
219
+ // All prices in second page should be greater than cursor
220
+ const minSecondPage = Math.min(
221
+ ...secondPage.items.map((i) => Number(i.price)),
222
+ );
223
+ expect(minSecondPage).toBeGreaterThan(prices[prices.length - 1]);
224
+ });
225
+
226
+ it('should use default limit of 20', async () => {
227
+ const result = await paginatedSearch({
228
+ query: db.selectFrom('paginationTestItems').selectAll(),
229
+ mapRow: (row) => ({ id: row.id }),
230
+ });
231
+
232
+ expect(result.items).toHaveLength(20);
233
+ });
234
+
235
+ it('should work with filtered queries', async () => {
236
+ const result = await paginatedSearch({
237
+ query: db
238
+ .selectFrom('paginationTestItems')
239
+ .selectAll()
240
+ .where('category', '=', 'category-a'),
241
+ limit: 5,
242
+ mapRow: (row) => ({
243
+ id: row.id,
244
+ name: row.name,
245
+ category: row.category,
246
+ }),
247
+ });
248
+
249
+ expect(result.pagination.total).toBe(10); // Only category-a items
250
+ expect(result.items).toHaveLength(5);
251
+ result.items.forEach((item) => {
252
+ expect(item.category).toBe('category-a');
253
+ });
254
+ });
255
+
256
+ it('should handle empty result set', async () => {
257
+ const result = await paginatedSearch({
258
+ query: db
259
+ .selectFrom('paginationTestItems')
260
+ .selectAll()
261
+ .where('category', '=', 'nonexistent'),
262
+ limit: 10,
263
+ mapRow: (row) => ({ id: row.id }),
264
+ });
265
+
266
+ expect(result.items).toHaveLength(0);
267
+ expect(result.pagination.total).toBe(0);
268
+ expect(result.pagination.hasMore).toBe(false);
269
+ expect(result.pagination.cursor).toBeUndefined();
270
+ });
271
+
272
+ it('should handle async mapRow function', async () => {
273
+ const result = await paginatedSearch({
274
+ query: db.selectFrom('paginationTestItems').selectAll(),
275
+ limit: 5,
276
+ mapRow: async (row) => {
277
+ // Simulate async transformation
278
+ await new Promise((resolve) => setTimeout(resolve, 1));
279
+ return {
280
+ id: row.id,
281
+ displayName: `Product: ${row.name}`,
282
+ priceFormatted: `$${Number(row.price).toFixed(2)}`,
283
+ };
284
+ },
285
+ });
286
+
287
+ expect(result.items).toHaveLength(5);
288
+ result.items.forEach((item) => {
289
+ expect(item.displayName).toMatch(/^Product: Item \d+$/);
290
+ expect(item.priceFormatted).toMatch(/^\$\d+\.\d{2}$/);
291
+ });
292
+ });
293
+ });
294
+
295
+ describe('encodeCursor / decodeCursor', () => {
296
+ it('should encode and decode string values', () => {
297
+ const original = 'some-cursor-value';
298
+ const encoded = encodeCursor(original);
299
+ const decoded = decodeCursor(encoded);
300
+
301
+ expect(decoded).toBe(original);
302
+ });
303
+
304
+ it('should encode and decode number values', () => {
305
+ const original = 12345;
306
+ const encoded = encodeCursor(original);
307
+ const decoded = decodeCursor(encoded);
308
+
309
+ expect(decoded).toBe(original);
310
+ });
311
+
312
+ it('should encode and decode Date values', () => {
313
+ const original = new Date('2024-01-15T10:30:00.000Z');
314
+ const encoded = encodeCursor(original);
315
+ const decoded = decodeCursor(encoded);
316
+
317
+ expect(decoded).toEqual(original);
318
+ });
319
+
320
+ it('should produce URL-safe base64 encoding', () => {
321
+ const encoded = encodeCursor('test/value+with=special');
322
+
323
+ // Base64url should not contain +, /, or =
324
+ expect(encoded).not.toMatch(/[+/=]/);
325
+ });
326
+
327
+ it('should throw error for invalid cursor format', () => {
328
+ expect(() => decodeCursor('invalid-cursor')).toThrow(
329
+ 'Invalid cursor format',
330
+ );
331
+ });
332
+
333
+ it('should throw error for malformed JSON', () => {
334
+ // Create valid base64url but with invalid JSON content
335
+ const malformedCursor = Buffer.from('not-json').toString('base64url');
336
+ expect(() => decodeCursor(malformedCursor)).toThrow(
337
+ 'Invalid cursor format',
338
+ );
339
+ });
340
+ });
341
341
  });