dynamo-query-engine 1.0.1 → 1.0.2

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.
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @fileoverview Tests for cursor utilities
3
+ */
4
+
5
+ import { describe, it, expect } from "vitest";
6
+ import {
7
+ encodeCursor,
8
+ decodeCursor,
9
+ validateCursor,
10
+ } from "../../src/utils/cursor.js";
11
+
12
+ describe("Cursor Utils", () => {
13
+ describe("encodeCursor", () => {
14
+ it("should encode cursor with lastKey and queryHash", () => {
15
+ const lastKey = { pk: "ORG#123", sk: "USER#456" };
16
+ const queryHash = "abc123def456";
17
+
18
+ const cursor = encodeCursor(lastKey, queryHash);
19
+
20
+ expect(cursor).toBeDefined();
21
+ expect(typeof cursor).toBe("string");
22
+ expect(cursor.length).toBeGreaterThan(0);
23
+ });
24
+
25
+ it("should create different cursors for different lastKeys", () => {
26
+ const lastKey1 = { pk: "ORG#123", sk: "USER#456" };
27
+ const lastKey2 = { pk: "ORG#456", sk: "USER#789" };
28
+ const queryHash = "abc123";
29
+
30
+ const cursor1 = encodeCursor(lastKey1, queryHash);
31
+ const cursor2 = encodeCursor(lastKey2, queryHash);
32
+
33
+ expect(cursor1).not.toBe(cursor2);
34
+ });
35
+
36
+ it("should throw error when lastKey is missing", () => {
37
+ expect(() => {
38
+ encodeCursor(null, "queryHash");
39
+ }).toThrow("lastKey is required");
40
+ });
41
+
42
+ it("should throw error when queryHash is missing", () => {
43
+ expect(() => {
44
+ encodeCursor({ pk: "ORG#123" }, null);
45
+ }).toThrow("queryHash is required");
46
+ });
47
+ });
48
+
49
+ describe("decodeCursor", () => {
50
+ it("should decode a valid cursor", () => {
51
+ const lastKey = { pk: "ORG#123", sk: "USER#456" };
52
+ const queryHash = "abc123def456";
53
+ const cursor = encodeCursor(lastKey, queryHash);
54
+
55
+ const decoded = decodeCursor(cursor);
56
+
57
+ expect(decoded).toBeDefined();
58
+ expect(decoded.lastKey).toEqual(lastKey);
59
+ expect(decoded.queryHash).toBe(queryHash);
60
+ });
61
+
62
+ it("should roundtrip encode and decode", () => {
63
+ const originalData = {
64
+ lastKey: { pk: "ORG#999", createdAt: "2024-01-01" },
65
+ queryHash: "hash999",
66
+ };
67
+
68
+ const cursor = encodeCursor(originalData.lastKey, originalData.queryHash);
69
+ const decoded = decodeCursor(cursor);
70
+
71
+ expect(decoded).toEqual(originalData);
72
+ });
73
+
74
+ it("should throw error for invalid cursor string", () => {
75
+ expect(() => {
76
+ decodeCursor("invalid-cursor-string");
77
+ }).toThrow("Failed to decode cursor");
78
+ });
79
+
80
+ it("should throw error for empty cursor", () => {
81
+ expect(() => {
82
+ decodeCursor("");
83
+ }).toThrow("Invalid cursor: must be a non-empty string");
84
+ });
85
+
86
+ it("should throw error for null cursor", () => {
87
+ expect(() => {
88
+ decodeCursor(null);
89
+ }).toThrow("Invalid cursor: must be a non-empty string");
90
+ });
91
+
92
+ it("should throw error for malformed cursor structure", () => {
93
+ // Create a cursor without required fields
94
+ const invalidData = { someField: "value" };
95
+ const cursor = Buffer.from(JSON.stringify(invalidData)).toString(
96
+ "base64"
97
+ );
98
+
99
+ expect(() => {
100
+ decodeCursor(cursor);
101
+ }).toThrow("Invalid cursor structure");
102
+ });
103
+ });
104
+
105
+ describe("validateCursor", () => {
106
+ it("should not throw when query hashes match", () => {
107
+ const queryHash = "abc123def456";
108
+
109
+ expect(() => {
110
+ validateCursor(queryHash, queryHash);
111
+ }).not.toThrow();
112
+ });
113
+
114
+ it("should throw when query hashes do not match", () => {
115
+ const decodedHash = "abc123";
116
+ const currentHash = "def456";
117
+
118
+ expect(() => {
119
+ validateCursor(decodedHash, currentHash);
120
+ }).toThrow("Cursor does not match current query");
121
+ });
122
+
123
+ it("should provide helpful error message", () => {
124
+ expect(() => {
125
+ validateCursor("oldHash", "newHash");
126
+ }).toThrow(/Filters, sort, or page size have changed/);
127
+ });
128
+ });
129
+
130
+ describe("Integration: encode, decode, validate", () => {
131
+ it("should handle complete cursor workflow", () => {
132
+ const lastKey = { pk: "ORG#123", sk: "USER#789" };
133
+ const queryHash = "query-hash-123";
134
+
135
+ // Encode
136
+ const cursor = encodeCursor(lastKey, queryHash);
137
+ expect(cursor).toBeDefined();
138
+
139
+ // Decode
140
+ const decoded = decodeCursor(cursor);
141
+ expect(decoded.lastKey).toEqual(lastKey);
142
+ expect(decoded.queryHash).toBe(queryHash);
143
+
144
+ // Validate
145
+ expect(() => {
146
+ validateCursor(decoded.queryHash, queryHash);
147
+ }).not.toThrow();
148
+ });
149
+
150
+ it("should detect query changes between pagination requests", () => {
151
+ const lastKey = { pk: "ORG#123" };
152
+ const originalQueryHash = "original-hash";
153
+ const modifiedQueryHash = "modified-hash";
154
+
155
+ // Encode with original query
156
+ const cursor = encodeCursor(lastKey, originalQueryHash);
157
+
158
+ // Decode
159
+ const decoded = decodeCursor(cursor);
160
+
161
+ // Validate with modified query - should fail
162
+ expect(() => {
163
+ validateCursor(decoded.queryHash, modifiedQueryHash);
164
+ }).toThrow("Cursor does not match current query");
165
+ });
166
+ });
167
+ });
@@ -0,0 +1,474 @@
1
+ /**
2
+ * @fileoverview Tests for grid query builder
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, vi } from "vitest";
6
+ import {
7
+ buildGridQuery,
8
+ extractGridConfig,
9
+ } from "../../src/core/gridQueryBuilder.js";
10
+ import {
11
+ createMockSchema,
12
+ createMockModel,
13
+ createMockQuery,
14
+ mockAttributes,
15
+ } from "../mocks/dynamoose.mock.js";
16
+
17
+ describe("GridQueryBuilder", () => {
18
+ describe("extractGridConfig", () => {
19
+ it("should extract query config from schema", () => {
20
+ const schema = createMockSchema(mockAttributes);
21
+ const gridConfig = extractGridConfig(schema);
22
+
23
+ expect(gridConfig).toBeDefined();
24
+ expect(gridConfig.createdAt).toBeDefined();
25
+ expect(gridConfig.createdAt.sort).toEqual({ type: "key" });
26
+ expect(gridConfig.createdAt.filter).toBeDefined();
27
+ });
28
+
29
+ it("should only extract fields with query config", () => {
30
+ const attributes = {
31
+ field1: {
32
+ type: String,
33
+ query: { filter: { type: "key", operators: ["eq"] } },
34
+ },
35
+ field2: {
36
+ type: String,
37
+ // No query config
38
+ },
39
+ };
40
+
41
+ const schema = createMockSchema(attributes);
42
+ const gridConfig = extractGridConfig(schema);
43
+
44
+ expect(gridConfig.field1).toBeDefined();
45
+ expect(gridConfig.field2).toBeUndefined();
46
+ });
47
+
48
+ it("should return empty object when no fields have query config", () => {
49
+ const attributes = {
50
+ field1: { type: String },
51
+ field2: { type: String },
52
+ };
53
+
54
+ const schema = createMockSchema(attributes);
55
+ const gridConfig = extractGridConfig(schema);
56
+
57
+ expect(gridConfig).toEqual({});
58
+ });
59
+ });
60
+
61
+ describe("buildGridQuery - Basic", () => {
62
+ let mockModel;
63
+ let mockQuery;
64
+
65
+ beforeEach(() => {
66
+ const schema = createMockSchema(mockAttributes);
67
+ mockQuery = createMockQuery([]);
68
+ mockModel = createMockModel(schema, []);
69
+ mockModel.query = vi.fn(() => mockQuery);
70
+ });
71
+
72
+ it("should build query with partition key", () => {
73
+ const { query } = buildGridQuery({
74
+ model: mockModel,
75
+ partitionKeyValue: "ORG#123",
76
+ paginationModel: { pageSize: 20 },
77
+ });
78
+
79
+ expect(mockModel.query).toHaveBeenCalledWith("pk");
80
+ expect(mockQuery.eq).toHaveBeenCalledWith("ORG#123");
81
+ expect(mockQuery.limit).toHaveBeenCalledWith(20);
82
+ expect(query).toBe(mockQuery);
83
+ });
84
+
85
+ it("should throw error when model is missing", () => {
86
+ expect(() => {
87
+ buildGridQuery({
88
+ model: null,
89
+ partitionKeyValue: "ORG#123",
90
+ paginationModel: { pageSize: 20 },
91
+ });
92
+ }).toThrow("model is required");
93
+ });
94
+
95
+ it("should throw error when partitionKeyValue is missing", () => {
96
+ expect(() => {
97
+ buildGridQuery({
98
+ model: mockModel,
99
+ partitionKeyValue: null,
100
+ paginationModel: { pageSize: 20 },
101
+ });
102
+ }).toThrow("partitionKeyValue is required");
103
+ });
104
+
105
+ it("should throw error when paginationModel is missing", () => {
106
+ expect(() => {
107
+ buildGridQuery({
108
+ model: mockModel,
109
+ partitionKeyValue: "ORG#123",
110
+ });
111
+ }).toThrow("paginationModel is required");
112
+ });
113
+ });
114
+
115
+ describe("buildGridQuery - Filtering", () => {
116
+ let mockModel;
117
+ let mockQuery;
118
+
119
+ beforeEach(() => {
120
+ const schema = createMockSchema(mockAttributes);
121
+ mockQuery = createMockQuery([]);
122
+ mockModel = createMockModel(schema, []);
123
+ mockModel.query = vi.fn(() => mockQuery);
124
+ });
125
+
126
+ it("should apply eq filter", () => {
127
+ const filterModel = {
128
+ items: [{ field: "status", operator: "eq", value: "active" }],
129
+ };
130
+
131
+ buildGridQuery({
132
+ model: mockModel,
133
+ partitionKeyValue: "ORG#123",
134
+ filterModel,
135
+ paginationModel: { pageSize: 20 },
136
+ });
137
+
138
+ expect(mockQuery.where).toHaveBeenCalledWith("status");
139
+ expect(mockQuery.eq).toHaveBeenCalledWith("active");
140
+ });
141
+
142
+ it("should apply gte filter", () => {
143
+ const filterModel = {
144
+ items: [
145
+ { field: "createdAt", operator: "gte", value: "2024-01-01" },
146
+ ],
147
+ };
148
+
149
+ buildGridQuery({
150
+ model: mockModel,
151
+ partitionKeyValue: "ORG#123",
152
+ filterModel,
153
+ paginationModel: { pageSize: 20 },
154
+ });
155
+
156
+ expect(mockQuery.where).toHaveBeenCalledWith("createdAt");
157
+ expect(mockQuery.ge).toHaveBeenCalledWith("2024-01-01");
158
+ });
159
+
160
+ it("should apply between filter", () => {
161
+ const filterModel = {
162
+ items: [
163
+ {
164
+ field: "createdAt",
165
+ operator: "between",
166
+ value: ["2024-01-01", "2024-12-31"],
167
+ },
168
+ ],
169
+ };
170
+
171
+ buildGridQuery({
172
+ model: mockModel,
173
+ partitionKeyValue: "ORG#123",
174
+ filterModel,
175
+ paginationModel: { pageSize: 20 },
176
+ });
177
+
178
+ expect(mockQuery.between).toHaveBeenCalledWith("2024-01-01", "2024-12-31");
179
+ });
180
+
181
+ it("should use GSI when filter specifies index", () => {
182
+ const filterModel = {
183
+ items: [{ field: "status", operator: "eq", value: "active" }],
184
+ };
185
+
186
+ buildGridQuery({
187
+ model: mockModel,
188
+ partitionKeyValue: "ORG#123",
189
+ filterModel,
190
+ paginationModel: { pageSize: 20 },
191
+ });
192
+
193
+ expect(mockQuery.using).toHaveBeenCalledWith("status-createdAt-index");
194
+ });
195
+
196
+ it("should throw error for invalid field", () => {
197
+ const filterModel = {
198
+ items: [{ field: "invalidField", operator: "eq", value: "test" }],
199
+ };
200
+
201
+ expect(() => {
202
+ buildGridQuery({
203
+ model: mockModel,
204
+ partitionKeyValue: "ORG#123",
205
+ filterModel,
206
+ paginationModel: { pageSize: 20 },
207
+ });
208
+ }).toThrow("Filtering not allowed");
209
+ });
210
+
211
+ it("should throw error for invalid operator", () => {
212
+ const filterModel = {
213
+ items: [{ field: "status", operator: "contains", value: "test" }],
214
+ };
215
+
216
+ expect(() => {
217
+ buildGridQuery({
218
+ model: mockModel,
219
+ partitionKeyValue: "ORG#123",
220
+ filterModel,
221
+ paginationModel: { pageSize: 20 },
222
+ });
223
+ }).toThrow("not allowed");
224
+ });
225
+ });
226
+
227
+ describe("buildGridQuery - Sorting", () => {
228
+ let mockModel;
229
+ let mockQuery;
230
+
231
+ beforeEach(() => {
232
+ const schema = createMockSchema(mockAttributes);
233
+ mockQuery = createMockQuery([]);
234
+ mockModel = createMockModel(schema, []);
235
+ mockModel.query = vi.fn(() => mockQuery);
236
+ });
237
+
238
+ it("should apply descending sort", () => {
239
+ const sortModel = [{ field: "createdAt", sort: "desc" }];
240
+
241
+ buildGridQuery({
242
+ model: mockModel,
243
+ partitionKeyValue: "ORG#123",
244
+ sortModel,
245
+ paginationModel: { pageSize: 20 },
246
+ });
247
+
248
+ expect(mockQuery.sort).toHaveBeenCalledWith("descending");
249
+ });
250
+
251
+ it("should not call sort for ascending (default)", () => {
252
+ const sortModel = [{ field: "createdAt", sort: "asc" }];
253
+
254
+ buildGridQuery({
255
+ model: mockModel,
256
+ partitionKeyValue: "ORG#123",
257
+ sortModel,
258
+ paginationModel: { pageSize: 20 },
259
+ });
260
+
261
+ // Ascending is default, so sort() should not be called
262
+ expect(mockQuery.sort).not.toHaveBeenCalled();
263
+ });
264
+
265
+ it("should throw error for multiple sort fields", () => {
266
+ const sortModel = [
267
+ { field: "createdAt", sort: "desc" },
268
+ { field: "status", sort: "asc" },
269
+ ];
270
+
271
+ expect(() => {
272
+ buildGridQuery({
273
+ model: mockModel,
274
+ partitionKeyValue: "ORG#123",
275
+ sortModel,
276
+ paginationModel: { pageSize: 20 },
277
+ });
278
+ }).toThrow(/sorting by one field/);
279
+ });
280
+
281
+ it("should throw error for invalid sort field", () => {
282
+ const sortModel = [{ field: "name", sort: "desc" }];
283
+
284
+ expect(() => {
285
+ buildGridQuery({
286
+ model: mockModel,
287
+ partitionKeyValue: "ORG#123",
288
+ sortModel,
289
+ paginationModel: { pageSize: 20 },
290
+ });
291
+ }).toThrow("Sorting not allowed");
292
+ });
293
+ });
294
+
295
+ describe("buildGridQuery - Pagination", () => {
296
+ let mockModel;
297
+ let mockQuery;
298
+
299
+ beforeEach(() => {
300
+ const schema = createMockSchema(mockAttributes);
301
+ mockQuery = createMockQuery([]);
302
+ mockModel = createMockModel(schema, []);
303
+ mockModel.query = vi.fn(() => mockQuery);
304
+ });
305
+
306
+ it("should apply page size limit", () => {
307
+ buildGridQuery({
308
+ model: mockModel,
309
+ partitionKeyValue: "ORG#123",
310
+ paginationModel: { pageSize: 50 },
311
+ });
312
+
313
+ expect(mockQuery.limit).toHaveBeenCalledWith(50);
314
+ });
315
+
316
+ it("should handle cursor for next page", () => {
317
+ const lastKey = { pk: "ORG#123", createdAt: "2024-01-01" };
318
+
319
+ // First, build query to get the correct queryHash
320
+ const firstResult = buildGridQuery({
321
+ model: mockModel,
322
+ partitionKeyValue: "ORG#123",
323
+ paginationModel: { pageSize: 20 },
324
+ });
325
+
326
+ // Create cursor with the actual queryHash
327
+ const cursor = Buffer.from(
328
+ JSON.stringify({ lastKey, queryHash: firstResult.queryHash })
329
+ ).toString("base64");
330
+
331
+ // Build query again with cursor
332
+ buildGridQuery({
333
+ model: mockModel,
334
+ partitionKeyValue: "ORG#123",
335
+ paginationModel: { pageSize: 20 },
336
+ cursor,
337
+ });
338
+
339
+ expect(mockQuery.startAt).toHaveBeenCalledWith(lastKey);
340
+ });
341
+
342
+ it("should validate cursor matches query", () => {
343
+ const lastKey = { pk: "ORG#123" };
344
+ const queryHash = "originalHash";
345
+ const cursor = Buffer.from(
346
+ JSON.stringify({ lastKey, queryHash })
347
+ ).toString("base64");
348
+
349
+ // Different query parameters should cause hash mismatch
350
+ expect(() => {
351
+ buildGridQuery({
352
+ model: mockModel,
353
+ partitionKeyValue: "ORG#123",
354
+ paginationModel: { pageSize: 50 }, // Different page size
355
+ cursor,
356
+ });
357
+ }).toThrow("Cursor does not match");
358
+ });
359
+ });
360
+
361
+ describe("buildGridQuery - Query Hash", () => {
362
+ let mockModel;
363
+
364
+ beforeEach(() => {
365
+ const schema = createMockSchema(mockAttributes);
366
+ mockModel = createMockModel(schema, []);
367
+ });
368
+
369
+ it("should return consistent query hash for same parameters", () => {
370
+ const params = {
371
+ model: mockModel,
372
+ partitionKeyValue: "ORG#123",
373
+ filterModel: {
374
+ items: [{ field: "status", operator: "eq", value: "active" }],
375
+ },
376
+ sortModel: [{ field: "createdAt", sort: "desc" }],
377
+ paginationModel: { pageSize: 20 },
378
+ };
379
+
380
+ const result1 = buildGridQuery(params);
381
+ const result2 = buildGridQuery(params);
382
+
383
+ expect(result1.queryHash).toBe(result2.queryHash);
384
+ });
385
+
386
+ it("should return different hash when filters change", () => {
387
+ const baseParams = {
388
+ model: mockModel,
389
+ partitionKeyValue: "ORG#123",
390
+ paginationModel: { pageSize: 20 },
391
+ };
392
+
393
+ const result1 = buildGridQuery({
394
+ ...baseParams,
395
+ filterModel: {
396
+ items: [{ field: "status", operator: "eq", value: "active" }],
397
+ },
398
+ });
399
+
400
+ const result2 = buildGridQuery({
401
+ ...baseParams,
402
+ filterModel: {
403
+ items: [{ field: "status", operator: "eq", value: "inactive" }],
404
+ },
405
+ });
406
+
407
+ expect(result1.queryHash).not.toBe(result2.queryHash);
408
+ });
409
+
410
+ it("should return different hash when page size changes", () => {
411
+ const baseParams = {
412
+ model: mockModel,
413
+ partitionKeyValue: "ORG#123",
414
+ };
415
+
416
+ const result1 = buildGridQuery({
417
+ ...baseParams,
418
+ paginationModel: { pageSize: 20 },
419
+ });
420
+
421
+ const result2 = buildGridQuery({
422
+ ...baseParams,
423
+ paginationModel: { pageSize: 50 },
424
+ });
425
+
426
+ expect(result1.queryHash).not.toBe(result2.queryHash);
427
+ });
428
+ });
429
+
430
+ describe("buildGridQuery - Complex Scenarios", () => {
431
+ let mockModel;
432
+ let mockQuery;
433
+
434
+ beforeEach(() => {
435
+ const schema = createMockSchema(mockAttributes);
436
+ mockQuery = createMockQuery([]);
437
+ mockModel = createMockModel(schema, []);
438
+ mockModel.query = vi.fn(() => mockQuery);
439
+ });
440
+
441
+ it("should handle query with filter, sort, and pagination", () => {
442
+ buildGridQuery({
443
+ model: mockModel,
444
+ partitionKeyValue: "ORG#123",
445
+ filterModel: {
446
+ items: [{ field: "status", operator: "eq", value: "active" }],
447
+ },
448
+ sortModel: [{ field: "createdAt", sort: "desc" }],
449
+ paginationModel: { pageSize: 25 },
450
+ });
451
+
452
+ // Verify all operations were applied
453
+ expect(mockModel.query).toHaveBeenCalledWith("pk");
454
+ expect(mockQuery.eq).toHaveBeenCalled();
455
+ expect(mockQuery.using).toHaveBeenCalled();
456
+ expect(mockQuery.where).toHaveBeenCalled();
457
+ expect(mockQuery.sort).toHaveBeenCalledWith("descending");
458
+ expect(mockQuery.limit).toHaveBeenCalledWith(25);
459
+ });
460
+
461
+ it("should handle empty filter and sort models", () => {
462
+ const { query } = buildGridQuery({
463
+ model: mockModel,
464
+ partitionKeyValue: "ORG#123",
465
+ filterModel: { items: [] },
466
+ sortModel: [],
467
+ paginationModel: { pageSize: 20 },
468
+ });
469
+
470
+ expect(query).toBeDefined();
471
+ expect(mockQuery.limit).toHaveBeenCalledWith(20);
472
+ });
473
+ });
474
+ });