dynamo-query-engine 1.0.8 → 1.0.9
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/index.js +1 -0
- package/package.json +1 -1
- package/src/core/expandResolver.js +1 -6
- package/src/core/gridQueryBuilder.js +220 -19
- package/src/core/index.js +7 -1
- package/src/types/jsdoc.js +8 -0
- package/src/utils/validation.js +37 -13
- package/tests/unit/gridQueryBuilder.test.js +270 -15
- package/tests/unit/validation.test.js +23 -24
package/index.js
CHANGED
package/package.json
CHANGED
|
@@ -35,9 +35,7 @@ export async function resolveExpand({
|
|
|
35
35
|
field,
|
|
36
36
|
args = {},
|
|
37
37
|
}) {
|
|
38
|
-
|
|
39
|
-
console.log(`[expandResolver] parentItems count: ${parentItems?.length || 0}`);
|
|
40
|
-
|
|
38
|
+
|
|
41
39
|
if (!parentItems || parentItems.length === 0) {
|
|
42
40
|
console.log(`[expandResolver] No parent items, skipping expand for field: ${field}`);
|
|
43
41
|
return;
|
|
@@ -287,7 +285,6 @@ export async function resolveExpands(
|
|
|
287
285
|
return null;
|
|
288
286
|
}
|
|
289
287
|
|
|
290
|
-
console.log(`[expandResolver] Expanding field '${graphqlFieldName}' (schema: ${schemaFieldName})`);
|
|
291
288
|
return resolveExpand({
|
|
292
289
|
parentItems,
|
|
293
290
|
parentModel,
|
|
@@ -297,9 +294,7 @@ export async function resolveExpands(
|
|
|
297
294
|
})
|
|
298
295
|
.filter(Boolean); // Remove null entries
|
|
299
296
|
|
|
300
|
-
console.log(`[expandResolver] Executing ${expandPromises.length} expand promises`);
|
|
301
297
|
await Promise.all(expandPromises);
|
|
302
|
-
console.log(`[expandResolver] All expands completed`);
|
|
303
298
|
return;
|
|
304
299
|
}
|
|
305
300
|
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
validateSortField,
|
|
11
11
|
validateFilterOperator,
|
|
12
12
|
} from "../utils/validation.js";
|
|
13
|
-
import { decodeCursor, validateCursor } from "../utils/cursor.js";
|
|
13
|
+
import { decodeCursor, validateCursor, encodeCursor } from "../utils/cursor.js";
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Generate a hash for query parameters to validate cursor consistency
|
|
@@ -52,13 +52,13 @@ export function extractGridConfig(schema) {
|
|
|
52
52
|
*/
|
|
53
53
|
export function getHashKey(schema) {
|
|
54
54
|
const attributes = schema.getAttributes();
|
|
55
|
-
|
|
55
|
+
|
|
56
56
|
for (const [field, definition] of Object.entries(attributes)) {
|
|
57
57
|
if (definition.hashKey === true) {
|
|
58
58
|
return field;
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
-
|
|
61
|
+
|
|
62
62
|
throw new Error("No hash key found in schema");
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -69,18 +69,19 @@ export function getHashKey(schema) {
|
|
|
69
69
|
*/
|
|
70
70
|
export function getRangeKey(schema) {
|
|
71
71
|
const attributes = schema.getAttributes();
|
|
72
|
-
|
|
72
|
+
|
|
73
73
|
for (const [field, definition] of Object.entries(attributes)) {
|
|
74
74
|
if (definition.rangeKey === true) {
|
|
75
75
|
return field;
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
|
-
|
|
78
|
+
|
|
79
79
|
return null;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
83
|
* Determine which GSI to use based on filter model
|
|
84
|
+
* Only uses index if explicitly configured in grid config
|
|
84
85
|
* @param {FilterModel} filterModel - Filter model
|
|
85
86
|
* @param {ExtractedGridConfig} gridConfig - Grid configuration
|
|
86
87
|
* @returns {string|null} GSI name or null
|
|
@@ -90,7 +91,7 @@ function determineIndex(filterModel, gridConfig) {
|
|
|
90
91
|
return null;
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
// Find the first filter with an index
|
|
94
|
+
// Find the first filter with an explicit index configuration
|
|
94
95
|
for (const filterItem of filterModel.items) {
|
|
95
96
|
const fieldConfig = gridConfig[filterItem.field]?.filter;
|
|
96
97
|
if (fieldConfig && fieldConfig.index) {
|
|
@@ -101,6 +102,35 @@ function determineIndex(filterModel, gridConfig) {
|
|
|
101
102
|
return null;
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Find the index name where the given field is used as rangeKey
|
|
107
|
+
* @param {string} sortField - The field name to sort by
|
|
108
|
+
* @param {*} schema - Dynamoose schema
|
|
109
|
+
* @returns {string|null} Index name or null if no index found
|
|
110
|
+
*/
|
|
111
|
+
function findIndexForSortField(sortField, schema) {
|
|
112
|
+
const attributes = schema.getAttributes();
|
|
113
|
+
|
|
114
|
+
// Iterate through all attributes to find an index that uses sortField as rangeKey
|
|
115
|
+
for (const definition of Object.values(attributes)) {
|
|
116
|
+
// Check if this attribute has an index configuration
|
|
117
|
+
if (definition.index) {
|
|
118
|
+
const indexes = Array.isArray(definition.index)
|
|
119
|
+
? definition.index
|
|
120
|
+
: [definition.index];
|
|
121
|
+
|
|
122
|
+
for (const idx of indexes) {
|
|
123
|
+
// Check if this index has the sortField as its rangeKey
|
|
124
|
+
if (idx.rangeKey === sortField) {
|
|
125
|
+
return idx.name;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
104
134
|
/**
|
|
105
135
|
* Apply filters to the query
|
|
106
136
|
* @param {*} query - Dynamoose query object
|
|
@@ -114,30 +144,38 @@ function applyFilters(query, filterModel, gridConfig) {
|
|
|
114
144
|
}
|
|
115
145
|
|
|
116
146
|
for (const filterItem of filterModel.items) {
|
|
117
|
-
|
|
147
|
+
// Only validate operator restrictions if field has explicit filter config
|
|
118
148
|
validateFilterOperator(filterItem.field, filterItem.operator, gridConfig);
|
|
119
149
|
|
|
120
150
|
// Apply the filter using Dynamoose query syntax
|
|
121
151
|
query = query.where(filterItem.field);
|
|
122
152
|
|
|
123
|
-
// Map operators to Dynamoose methods
|
|
153
|
+
// Map operators to Dynamoose methods (support both short and long forms)
|
|
124
154
|
switch (filterItem.operator) {
|
|
155
|
+
case "equals":
|
|
125
156
|
case "eq":
|
|
126
157
|
query = query.eq(filterItem.value);
|
|
127
158
|
break;
|
|
159
|
+
case "notEquals":
|
|
128
160
|
case "ne":
|
|
129
161
|
query = query.ne(filterItem.value);
|
|
130
162
|
break;
|
|
163
|
+
case "greaterThan":
|
|
131
164
|
case "gt":
|
|
132
165
|
query = query.gt(filterItem.value);
|
|
133
166
|
break;
|
|
167
|
+
case "greaterThanOrEqualTo":
|
|
134
168
|
case "gte":
|
|
169
|
+
case "ge":
|
|
135
170
|
query = query.ge(filterItem.value);
|
|
136
171
|
break;
|
|
172
|
+
case "lessThan":
|
|
137
173
|
case "lt":
|
|
138
174
|
query = query.lt(filterItem.value);
|
|
139
175
|
break;
|
|
176
|
+
case "lessThanOrEqualTo":
|
|
140
177
|
case "lte":
|
|
178
|
+
case "le":
|
|
141
179
|
query = query.le(filterItem.value);
|
|
142
180
|
break;
|
|
143
181
|
case "between":
|
|
@@ -164,26 +202,57 @@ function applyFilters(query, filterModel, gridConfig) {
|
|
|
164
202
|
return query;
|
|
165
203
|
}
|
|
166
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Determine which index to use based on sort model
|
|
207
|
+
* @param {SortModel} sortModel - Sort model
|
|
208
|
+
* @param {ExtractedGridConfig} gridConfig - Grid configuration
|
|
209
|
+
* @param {*} schema - Dynamoose schema
|
|
210
|
+
* @returns {string|null} Index name or null if no index needed
|
|
211
|
+
*/
|
|
212
|
+
function determineSortIndex(sortModel, gridConfig, schema) {
|
|
213
|
+
if (!sortModel || sortModel.length === 0) {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const sortItem = sortModel[0];
|
|
218
|
+
validateSortField(sortItem.field, gridConfig);
|
|
219
|
+
|
|
220
|
+
// Find the index where this field is used as rangeKey
|
|
221
|
+
const indexName = findIndexForSortField(sortItem.field, schema);
|
|
222
|
+
|
|
223
|
+
if (!indexName) {
|
|
224
|
+
// Check if it's the main table's range key (no index needed)
|
|
225
|
+
const rangeKey = getRangeKey(schema);
|
|
226
|
+
if (sortItem.field === rangeKey) {
|
|
227
|
+
return null; // No index needed, it's the main range key
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
throw new Error(
|
|
231
|
+
`No index found for sorting by field '${sortItem.field}'. ` +
|
|
232
|
+
`Make sure an index exists with '${sortItem.field}' as rangeKey.`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return indexName;
|
|
237
|
+
}
|
|
238
|
+
|
|
167
239
|
/**
|
|
168
240
|
* Apply sorting to the query
|
|
169
241
|
* @param {*} query - Dynamoose query object
|
|
170
242
|
* @param {SortModel} sortModel - Sort model
|
|
171
|
-
* @param {ExtractedGridConfig} gridConfig - Grid configuration
|
|
172
243
|
* @returns {*} Updated query object
|
|
173
244
|
*/
|
|
174
|
-
function applySort(query, sortModel
|
|
245
|
+
function applySort(query, sortModel) {
|
|
175
246
|
if (!sortModel || sortModel.length === 0) {
|
|
176
247
|
return query;
|
|
177
248
|
}
|
|
178
249
|
|
|
179
250
|
const sortItem = sortModel[0];
|
|
180
|
-
validateSortField(sortItem.field, gridConfig);
|
|
181
251
|
|
|
182
|
-
//
|
|
252
|
+
// Only apply sort if descending (ascending is the default)
|
|
183
253
|
if (sortItem.sort === "desc") {
|
|
184
254
|
query = query.sort("descending");
|
|
185
255
|
}
|
|
186
|
-
// Default is ascending, no need to explicitly set
|
|
187
256
|
|
|
188
257
|
return query;
|
|
189
258
|
}
|
|
@@ -208,7 +277,7 @@ export function buildGridQuery({
|
|
|
208
277
|
if (!partitionKeyValue) {
|
|
209
278
|
throw new Error("partitionKeyValue is required");
|
|
210
279
|
}
|
|
211
|
-
|
|
280
|
+
|
|
212
281
|
// Validate and normalize limit
|
|
213
282
|
if (limit !== undefined && limit !== null) {
|
|
214
283
|
const numLimit = typeof limit === "string" ? Number(limit) : limit;
|
|
@@ -216,13 +285,13 @@ export function buildGridQuery({
|
|
|
216
285
|
throw new Error("limit must be a positive number");
|
|
217
286
|
}
|
|
218
287
|
if (numLimit > 1000) {
|
|
219
|
-
throw new Error("limit cannot exceed 1000
|
|
288
|
+
throw new Error("limit cannot exceed 1000");
|
|
220
289
|
}
|
|
221
290
|
limit = numLimit;
|
|
222
291
|
} else {
|
|
223
292
|
limit = 10;
|
|
224
293
|
}
|
|
225
|
-
|
|
294
|
+
|
|
226
295
|
validateSingleSort(sortModel);
|
|
227
296
|
|
|
228
297
|
// Extract grid config from schema
|
|
@@ -231,8 +300,23 @@ export function buildGridQuery({
|
|
|
231
300
|
// Get the hash key field name from schema
|
|
232
301
|
const hashKeyField = getHashKey(model.schema);
|
|
233
302
|
|
|
234
|
-
// Determine which index to use
|
|
235
|
-
|
|
303
|
+
// Determine which index to use - prioritize sort index
|
|
304
|
+
// Prefer sort index when sorting is requested, as DynamoDB returns results efficiently
|
|
305
|
+
// and consistently ordered by the sort key.
|
|
306
|
+
// Use filter index only when no sorting is needed for optimal query performance and consistency.
|
|
307
|
+
const sortIndex = determineSortIndex(sortModel, gridConfig, model.schema);
|
|
308
|
+
const filterIndex = determineIndex(filterModel, gridConfig);
|
|
309
|
+
|
|
310
|
+
// Decide which index to use - sort takes priority
|
|
311
|
+
let index = sortIndex || filterIndex;
|
|
312
|
+
|
|
313
|
+
// Validate that filter and sort are compatible if both exist
|
|
314
|
+
if (sortIndex && filterIndex && sortIndex !== filterIndex) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
`Cannot use different indexes for filtering ('${filterIndex}') and sorting ('${sortIndex}'). ` +
|
|
317
|
+
`Ensure your filters and sort use compatible indexes.`
|
|
318
|
+
);
|
|
319
|
+
}
|
|
236
320
|
|
|
237
321
|
// Generate query hash for cursor validation
|
|
238
322
|
const queryHash = hashQuery({
|
|
@@ -253,7 +337,7 @@ export function buildGridQuery({
|
|
|
253
337
|
query = applyFilters(query, filterModel, gridConfig);
|
|
254
338
|
|
|
255
339
|
// Apply sort
|
|
256
|
-
query = applySort(query, sortModel
|
|
340
|
+
query = applySort(query, sortModel);
|
|
257
341
|
|
|
258
342
|
// Apply limit
|
|
259
343
|
query = query.limit(limit);
|
|
@@ -267,3 +351,120 @@ export function buildGridQuery({
|
|
|
267
351
|
|
|
268
352
|
return { query, queryHash };
|
|
269
353
|
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Execute grid query with automatic page filling when filters reduce results
|
|
357
|
+
* This function handles DynamoDB's behavior of applying limit before filters,
|
|
358
|
+
* which can result in pages with fewer items than requested.
|
|
359
|
+
*
|
|
360
|
+
* @param {BuildGridQueryOptions} options - Query build options
|
|
361
|
+
* @returns {Promise<ExecuteGridQueryResult>} Query results with items and cursor
|
|
362
|
+
*/
|
|
363
|
+
export async function executeGridQuery(options) {
|
|
364
|
+
const {
|
|
365
|
+
model,
|
|
366
|
+
partitionKeyValue,
|
|
367
|
+
filterModel,
|
|
368
|
+
sortModel,
|
|
369
|
+
limit = 10,
|
|
370
|
+
cursor,
|
|
371
|
+
} = options;
|
|
372
|
+
|
|
373
|
+
const results = [];
|
|
374
|
+
let currentCursor = cursor;
|
|
375
|
+
let lastKey = null;
|
|
376
|
+
let hasMorePages = true;
|
|
377
|
+
const maxIterations = 10; // Safety limit to prevent infinite loops
|
|
378
|
+
let iterations = 0;
|
|
379
|
+
|
|
380
|
+
// Generate the original query hash once (with the original limit)
|
|
381
|
+
const originalQueryHash = hashQuery({
|
|
382
|
+
filterModel,
|
|
383
|
+
sortModel,
|
|
384
|
+
limit,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// Continue fetching until we have enough results or no more data
|
|
388
|
+
while (results.length < limit && hasMorePages && iterations < maxIterations) {
|
|
389
|
+
iterations++;
|
|
390
|
+
|
|
391
|
+
// Calculate how many more items we need
|
|
392
|
+
const remainingNeeded = limit - results.length;
|
|
393
|
+
|
|
394
|
+
// Build query - we'll manually apply the cursor to avoid hash mismatch
|
|
395
|
+
const gridConfig = extractGridConfig(model.schema);
|
|
396
|
+
const hashKeyField = getHashKey(model.schema);
|
|
397
|
+
const sortIndex = determineSortIndex(sortModel, gridConfig, model.schema);
|
|
398
|
+
const filterIndex = determineIndex(filterModel, gridConfig);
|
|
399
|
+
let index = sortIndex || filterIndex;
|
|
400
|
+
|
|
401
|
+
// Validate that filter and sort are compatible if both exist
|
|
402
|
+
if (sortIndex && filterIndex && sortIndex !== filterIndex) {
|
|
403
|
+
throw new Error(
|
|
404
|
+
`Cannot use different indexes for filtering ('${filterIndex}') and sorting ('${sortIndex}'). ` +
|
|
405
|
+
`Ensure your filters and sort use compatible indexes.`
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Initialize query with partition key
|
|
410
|
+
let query = model.query(hashKeyField).eq(partitionKeyValue);
|
|
411
|
+
|
|
412
|
+
// Use GSI if needed
|
|
413
|
+
if (index) {
|
|
414
|
+
query = query.using(index);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Apply filters
|
|
418
|
+
query = applyFilters(query, filterModel, gridConfig);
|
|
419
|
+
|
|
420
|
+
// Apply sort
|
|
421
|
+
query = applySort(query, sortModel);
|
|
422
|
+
|
|
423
|
+
// Apply limit
|
|
424
|
+
query = query.limit(remainingNeeded);
|
|
425
|
+
|
|
426
|
+
// Handle cursor for pagination
|
|
427
|
+
if (currentCursor) {
|
|
428
|
+
const decoded = decodeCursor(currentCursor);
|
|
429
|
+
validateCursor(decoded.queryHash, originalQueryHash);
|
|
430
|
+
query = query.startAt(decoded.lastKey);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Execute query
|
|
434
|
+
const response = await query.exec();
|
|
435
|
+
|
|
436
|
+
// Add results
|
|
437
|
+
if (response && response.length > 0) {
|
|
438
|
+
results.push(...response);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Get lastKey from the response
|
|
442
|
+
lastKey = response.lastKey;
|
|
443
|
+
|
|
444
|
+
// Check if there are more pages
|
|
445
|
+
hasMorePages = !!lastKey;
|
|
446
|
+
|
|
447
|
+
// If we have more pages, prepare cursor for next iteration
|
|
448
|
+
if (hasMorePages && results.length < limit) {
|
|
449
|
+
currentCursor = encodeCursor(lastKey, originalQueryHash);
|
|
450
|
+
} else {
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Trim results to exact limit if we fetched more
|
|
456
|
+
const finalResults = results.slice(0, limit);
|
|
457
|
+
|
|
458
|
+
// Generate final cursor
|
|
459
|
+
let nextCursor = null;
|
|
460
|
+
if (lastKey && results.length >= limit) {
|
|
461
|
+
nextCursor = encodeCursor(lastKey, originalQueryHash);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
items: finalResults,
|
|
466
|
+
cursor: nextCursor,
|
|
467
|
+
hasMore: !!nextCursor,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
package/src/core/index.js
CHANGED
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
export { modelRegistry, ModelRegistry } from "./modelRegistry.js";
|
|
6
|
-
export {
|
|
6
|
+
export {
|
|
7
|
+
buildGridQuery,
|
|
8
|
+
executeGridQuery,
|
|
9
|
+
extractGridConfig,
|
|
10
|
+
getHashKey,
|
|
11
|
+
getRangeKey
|
|
12
|
+
} from "./gridQueryBuilder.js";
|
|
7
13
|
export {
|
|
8
14
|
resolveExpand,
|
|
9
15
|
resolveExpands,
|
package/src/types/jsdoc.js
CHANGED
|
@@ -87,6 +87,14 @@
|
|
|
87
87
|
* @property {string} queryHash - Hash of the query parameters
|
|
88
88
|
*/
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Execute grid query result
|
|
92
|
+
* @typedef {Object} ExecuteGridQueryResult
|
|
93
|
+
* @property {Array} items - Query result items
|
|
94
|
+
* @property {string|null} cursor - Base64 encoded cursor for next page
|
|
95
|
+
* @property {boolean} hasMore - Whether there are more pages available
|
|
96
|
+
*/
|
|
97
|
+
|
|
90
98
|
/**
|
|
91
99
|
* Expand arguments from GraphQL
|
|
92
100
|
* @typedef {Object} ExpandArgs
|
package/src/utils/validation.js
CHANGED
|
@@ -19,17 +19,14 @@ export function validateSingleSort(sortModel) {
|
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Validates that a field can be filtered based on grid config
|
|
22
|
+
* This function now only checks if a field has filter configuration, but doesn't throw
|
|
22
23
|
* @param {string} field - Field name to filter
|
|
23
24
|
* @param {Object.<string, import("../types/jsdoc.js").GridConfig>} gridConfig - Extracted grid configuration
|
|
24
|
-
* @
|
|
25
|
+
* @returns {boolean} True if field has filter config, false otherwise
|
|
25
26
|
*/
|
|
26
27
|
export function validateFilterField(field, gridConfig) {
|
|
27
28
|
const config = gridConfig[field];
|
|
28
|
-
|
|
29
|
-
throw new Error(
|
|
30
|
-
`Filtering not allowed on field '${field}'. Only fields with grid.filter config can be filtered.`
|
|
31
|
-
);
|
|
32
|
-
}
|
|
29
|
+
return !!(config && config.filter);
|
|
33
30
|
}
|
|
34
31
|
|
|
35
32
|
/**
|
|
@@ -42,28 +39,55 @@ export function validateSortField(field, gridConfig) {
|
|
|
42
39
|
const config = gridConfig[field];
|
|
43
40
|
if (!config || !config.sort) {
|
|
44
41
|
throw new Error(
|
|
45
|
-
`Sorting not allowed on field '${field}'. Only fields with
|
|
42
|
+
`Sorting not allowed on field '${field}'. Only fields with query.sort config can be sorted.`
|
|
46
43
|
);
|
|
47
44
|
}
|
|
48
45
|
}
|
|
49
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Operator alias mapping (short form to long form)
|
|
49
|
+
*/
|
|
50
|
+
const OPERATOR_ALIASES = {
|
|
51
|
+
eq: "equals",
|
|
52
|
+
ne: "notEquals",
|
|
53
|
+
gt: "greaterThan",
|
|
54
|
+
gte: "greaterThanOrEqualTo",
|
|
55
|
+
ge: "greaterThanOrEqualTo",
|
|
56
|
+
lt: "lessThan",
|
|
57
|
+
lte: "lessThanOrEqualTo",
|
|
58
|
+
le: "lessThanOrEqualTo",
|
|
59
|
+
};
|
|
60
|
+
|
|
50
61
|
/**
|
|
51
62
|
* Validates that operator is allowed for a filter field
|
|
63
|
+
* Only validates if the field has explicit operator restrictions in grid config
|
|
52
64
|
* @param {string} field - Field name
|
|
53
65
|
* @param {string} operator - Operator to validate
|
|
54
66
|
* @param {Object.<string, import("../types/jsdoc.js").GridConfig>} gridConfig - Extracted grid configuration
|
|
55
|
-
* @throws {Error} If operator is not allowed
|
|
67
|
+
* @throws {Error} If field has operator restrictions and operator is not allowed
|
|
56
68
|
*/
|
|
57
69
|
export function validateFilterOperator(field, operator, gridConfig) {
|
|
58
70
|
const config = gridConfig[field]?.filter;
|
|
71
|
+
|
|
72
|
+
// If no filter config exists, allow any operator
|
|
59
73
|
if (!config) {
|
|
60
|
-
|
|
74
|
+
return;
|
|
61
75
|
}
|
|
62
76
|
|
|
63
|
-
if (config.operators
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
77
|
+
if (config.operators) {
|
|
78
|
+
// Normalize operator to long form if it's an alias
|
|
79
|
+
const normalizedOperator = OPERATOR_ALIASES[operator] || operator;
|
|
80
|
+
|
|
81
|
+
// Check if either the original operator or normalized operator is allowed
|
|
82
|
+
const isAllowed =
|
|
83
|
+
config.operators.includes(operator) ||
|
|
84
|
+
config.operators.includes(normalizedOperator);
|
|
85
|
+
|
|
86
|
+
if (!isAllowed) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Operator '${operator}' not allowed for field '${field}'. Allowed operators: ${config.operators.join(", ")}`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
67
91
|
}
|
|
68
92
|
}
|
|
69
93
|
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
* @fileoverview Tests for grid query builder
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import crypto from "crypto";
|
|
5
6
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
6
7
|
import {
|
|
7
8
|
buildGridQuery,
|
|
9
|
+
executeGridQuery,
|
|
8
10
|
extractGridConfig,
|
|
9
11
|
getHashKey,
|
|
10
12
|
getRangeKey,
|
|
@@ -339,21 +341,6 @@ describe("GridQueryBuilder", () => {
|
|
|
339
341
|
expect(mockQuery.using).toHaveBeenCalledWith("status-createdAt-index");
|
|
340
342
|
});
|
|
341
343
|
|
|
342
|
-
it("should throw error for invalid field", () => {
|
|
343
|
-
const filterModel = {
|
|
344
|
-
items: [{ field: "invalidField", operator: "eq", value: "test" }],
|
|
345
|
-
};
|
|
346
|
-
|
|
347
|
-
expect(() => {
|
|
348
|
-
buildGridQuery({
|
|
349
|
-
model: mockModel,
|
|
350
|
-
partitionKeyValue: "ORG#123",
|
|
351
|
-
filterModel,
|
|
352
|
-
limit: 20,
|
|
353
|
-
});
|
|
354
|
-
}).toThrow("Filtering not allowed");
|
|
355
|
-
});
|
|
356
|
-
|
|
357
344
|
it("should throw error for invalid operator", () => {
|
|
358
345
|
const filterModel = {
|
|
359
346
|
items: [{ field: "status", operator: "contains", value: "test" }],
|
|
@@ -436,6 +423,76 @@ describe("GridQueryBuilder", () => {
|
|
|
436
423
|
});
|
|
437
424
|
}).toThrow("Sorting not allowed");
|
|
438
425
|
});
|
|
426
|
+
|
|
427
|
+
it("should use index for sort field with rangeKey", () => {
|
|
428
|
+
// Create a schema with a field that has an index with rangeKey
|
|
429
|
+
const attributesWithSortIndex = {
|
|
430
|
+
...mockAttributes,
|
|
431
|
+
externalId: {
|
|
432
|
+
type: String,
|
|
433
|
+
query: {
|
|
434
|
+
sort: { type: "key" },
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
tenantId: {
|
|
438
|
+
type: String,
|
|
439
|
+
hashKey: true,
|
|
440
|
+
index: [
|
|
441
|
+
{
|
|
442
|
+
name: "byExternalId",
|
|
443
|
+
global: true,
|
|
444
|
+
rangeKey: "externalId",
|
|
445
|
+
},
|
|
446
|
+
],
|
|
447
|
+
},
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const schema = createMockSchema(attributesWithSortIndex);
|
|
451
|
+
const mockQuery = createMockQuery([]);
|
|
452
|
+
const mockModel = createMockModel(schema, []);
|
|
453
|
+
mockModel.query = vi.fn(() => mockQuery);
|
|
454
|
+
|
|
455
|
+
const sortModel = [{ field: "externalId", sort: "desc" }];
|
|
456
|
+
|
|
457
|
+
buildGridQuery({
|
|
458
|
+
model: mockModel,
|
|
459
|
+
partitionKeyValue: "TENANT#123",
|
|
460
|
+
sortModel,
|
|
461
|
+
limit: 20,
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// Should use the index
|
|
465
|
+
expect(mockQuery.using).toHaveBeenCalledWith("byExternalId");
|
|
466
|
+
// Should apply descending sort
|
|
467
|
+
expect(mockQuery.sort).toHaveBeenCalledWith("descending");
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("should throw error when no index found for sort field", () => {
|
|
471
|
+
// Create a schema with a sort field but no index
|
|
472
|
+
const attributesWithoutIndex = {
|
|
473
|
+
...mockAttributes,
|
|
474
|
+
fieldWithoutIndex: {
|
|
475
|
+
type: String,
|
|
476
|
+
query: {
|
|
477
|
+
sort: { type: "key" },
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
const schema = createMockSchema(attributesWithoutIndex);
|
|
483
|
+
const mockModel = createMockModel(schema, []);
|
|
484
|
+
|
|
485
|
+
const sortModel = [{ field: "fieldWithoutIndex", sort: "desc" }];
|
|
486
|
+
|
|
487
|
+
expect(() => {
|
|
488
|
+
buildGridQuery({
|
|
489
|
+
model: mockModel,
|
|
490
|
+
partitionKeyValue: "ORG#123",
|
|
491
|
+
sortModel,
|
|
492
|
+
limit: 20,
|
|
493
|
+
});
|
|
494
|
+
}).toThrow(/No index found for sorting/);
|
|
495
|
+
});
|
|
439
496
|
});
|
|
440
497
|
|
|
441
498
|
describe("buildGridQuery - Limit and Cursor", () => {
|
|
@@ -617,4 +674,202 @@ describe("GridQueryBuilder", () => {
|
|
|
617
674
|
expect(mockQuery.limit).toHaveBeenCalledWith(20);
|
|
618
675
|
});
|
|
619
676
|
});
|
|
677
|
+
|
|
678
|
+
describe("executeGridQuery - Page Filling", () => {
|
|
679
|
+
let mockModel;
|
|
680
|
+
let mockQuery;
|
|
681
|
+
|
|
682
|
+
beforeEach(() => {
|
|
683
|
+
const schema = createMockSchema(mockAttributes);
|
|
684
|
+
mockQuery = createMockQuery([]);
|
|
685
|
+
mockModel = createMockModel(schema, []);
|
|
686
|
+
mockModel.query = vi.fn(() => mockQuery);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it("should return full page when no filters reduce results", async () => {
|
|
690
|
+
// Mock query to return exactly the requested amount
|
|
691
|
+
const mockItems = [
|
|
692
|
+
{ id: "1", name: "Item 1" },
|
|
693
|
+
{ id: "2", name: "Item 2" },
|
|
694
|
+
{ id: "3", name: "Item 3" },
|
|
695
|
+
];
|
|
696
|
+
|
|
697
|
+
mockQuery.exec = vi.fn().mockResolvedValue(
|
|
698
|
+
Object.assign(mockItems, { lastKey: null })
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
const result = await executeGridQuery({
|
|
702
|
+
model: mockModel,
|
|
703
|
+
partitionKeyValue: "ORG#123",
|
|
704
|
+
limit: 3,
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
expect(result.items).toHaveLength(3);
|
|
708
|
+
expect(result.hasMore).toBe(false);
|
|
709
|
+
expect(result.cursor).toBeNull();
|
|
710
|
+
expect(mockQuery.exec).toHaveBeenCalledTimes(1);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it("should make multiple requests to fill page when filters reduce results", async () => {
|
|
714
|
+
// First call returns 2 items (with more available)
|
|
715
|
+
const firstBatch = [
|
|
716
|
+
{ id: "1", name: "Item 1" },
|
|
717
|
+
{ id: "2", name: "Item 2" },
|
|
718
|
+
];
|
|
719
|
+
|
|
720
|
+
// Second call returns 3 more items
|
|
721
|
+
const secondBatch = [
|
|
722
|
+
{ id: "3", name: "Item 3" },
|
|
723
|
+
{ id: "4", name: "Item 4" },
|
|
724
|
+
{ id: "5", name: "Item 5" },
|
|
725
|
+
];
|
|
726
|
+
|
|
727
|
+
mockQuery.exec = vi.fn()
|
|
728
|
+
.mockResolvedValueOnce(
|
|
729
|
+
Object.assign(firstBatch, { lastKey: { pk: "ORG#123", sk: "2" } })
|
|
730
|
+
)
|
|
731
|
+
.mockResolvedValueOnce(
|
|
732
|
+
Object.assign(secondBatch, { lastKey: null })
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
const result = await executeGridQuery({
|
|
736
|
+
model: mockModel,
|
|
737
|
+
partitionKeyValue: "ORG#123",
|
|
738
|
+
limit: 5,
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
expect(result.items).toHaveLength(5);
|
|
742
|
+
expect(mockQuery.exec).toHaveBeenCalledTimes(2);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it("should stop at limit even if more data is available", async () => {
|
|
746
|
+
// First call returns 3 items (with more available)
|
|
747
|
+
const firstBatch = [
|
|
748
|
+
{ id: "1", name: "Item 1" },
|
|
749
|
+
{ id: "2", name: "Item 2" },
|
|
750
|
+
{ id: "3", name: "Item 3" },
|
|
751
|
+
];
|
|
752
|
+
|
|
753
|
+
// Second call returns 4 more items
|
|
754
|
+
const secondBatch = [
|
|
755
|
+
{ id: "4", name: "Item 4" },
|
|
756
|
+
{ id: "5", name: "Item 5" },
|
|
757
|
+
{ id: "6", name: "Item 6" },
|
|
758
|
+
{ id: "7", name: "Item 7" },
|
|
759
|
+
];
|
|
760
|
+
|
|
761
|
+
mockQuery.exec = vi.fn()
|
|
762
|
+
.mockResolvedValueOnce(
|
|
763
|
+
Object.assign(firstBatch, { lastKey: { pk: "ORG#123", sk: "3" } })
|
|
764
|
+
)
|
|
765
|
+
.mockResolvedValueOnce(
|
|
766
|
+
Object.assign(secondBatch, { lastKey: { pk: "ORG#123", sk: "7" } })
|
|
767
|
+
);
|
|
768
|
+
|
|
769
|
+
const result = await executeGridQuery({
|
|
770
|
+
model: mockModel,
|
|
771
|
+
partitionKeyValue: "ORG#123",
|
|
772
|
+
limit: 5,
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
expect(result.items).toHaveLength(5);
|
|
776
|
+
expect(result.hasMore).toBe(true);
|
|
777
|
+
expect(result.cursor).not.toBeNull();
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it("should return cursor when more pages are available", async () => {
|
|
781
|
+
const mockItems = [
|
|
782
|
+
{ id: "1", name: "Item 1" },
|
|
783
|
+
{ id: "2", name: "Item 2" },
|
|
784
|
+
{ id: "3", name: "Item 3" },
|
|
785
|
+
];
|
|
786
|
+
|
|
787
|
+
mockQuery.exec = vi.fn().mockResolvedValue(
|
|
788
|
+
Object.assign(mockItems, { lastKey: { pk: "ORG#123", sk: "3" } })
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
const result = await executeGridQuery({
|
|
792
|
+
model: mockModel,
|
|
793
|
+
partitionKeyValue: "ORG#123",
|
|
794
|
+
limit: 3,
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
expect(result.hasMore).toBe(true);
|
|
798
|
+
expect(result.cursor).not.toBeNull();
|
|
799
|
+
expect(typeof result.cursor).toBe("string");
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
it("should stop after max iterations to prevent infinite loops", async () => {
|
|
803
|
+
// Mock to always return empty results but with a lastKey
|
|
804
|
+
mockQuery.exec = vi.fn().mockResolvedValue(
|
|
805
|
+
Object.assign([], { lastKey: { pk: "ORG#123", sk: "1" } })
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
const result = await executeGridQuery({
|
|
809
|
+
model: mockModel,
|
|
810
|
+
partitionKeyValue: "ORG#123",
|
|
811
|
+
limit: 10,
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
expect(result.items).toHaveLength(0);
|
|
815
|
+
expect(mockQuery.exec).toHaveBeenCalledTimes(10); // maxIterations
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
it("should handle cursor-based pagination correctly", async () => {
|
|
819
|
+
const mockItems = [
|
|
820
|
+
{ id: "4", name: "Item 4" },
|
|
821
|
+
{ id: "5", name: "Item 5" },
|
|
822
|
+
];
|
|
823
|
+
|
|
824
|
+
mockQuery.exec = vi.fn().mockResolvedValue(
|
|
825
|
+
Object.assign(mockItems, { lastKey: null })
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
// Create a valid cursor with matching queryHash
|
|
829
|
+
const { encodeCursor } = await import("../../src/utils/cursor.js");
|
|
830
|
+
const queryHash = crypto
|
|
831
|
+
.createHash("sha256")
|
|
832
|
+
.update(JSON.stringify({ filterModel: undefined, sortModel: undefined, limit: 2 }))
|
|
833
|
+
.digest("hex");
|
|
834
|
+
const validCursor = encodeCursor({ pk: "ORG#123", sk: "3" }, queryHash);
|
|
835
|
+
|
|
836
|
+
const result = await executeGridQuery({
|
|
837
|
+
model: mockModel,
|
|
838
|
+
partitionKeyValue: "ORG#123",
|
|
839
|
+
limit: 2,
|
|
840
|
+
cursor: validCursor,
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
expect(result.items).toHaveLength(2);
|
|
844
|
+
expect(mockQuery.startAt).toHaveBeenCalled();
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it("should respect filters and sort in all requests", async () => {
|
|
848
|
+
const firstBatch = [{ id: "1", status: "active" }];
|
|
849
|
+
const secondBatch = [{ id: "2", status: "active" }];
|
|
850
|
+
|
|
851
|
+
mockQuery.exec = vi.fn()
|
|
852
|
+
.mockResolvedValueOnce(
|
|
853
|
+
Object.assign(firstBatch, { lastKey: { pk: "ORG#123", sk: "1" } })
|
|
854
|
+
)
|
|
855
|
+
.mockResolvedValueOnce(
|
|
856
|
+
Object.assign(secondBatch, { lastKey: null })
|
|
857
|
+
);
|
|
858
|
+
|
|
859
|
+
await executeGridQuery({
|
|
860
|
+
model: mockModel,
|
|
861
|
+
partitionKeyValue: "ORG#123",
|
|
862
|
+
filterModel: {
|
|
863
|
+
items: [{ field: "status", operator: "eq", value: "active" }],
|
|
864
|
+
},
|
|
865
|
+
sortModel: [{ field: "createdAt", sort: "desc" }],
|
|
866
|
+
limit: 2,
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
// Should apply filter on both queries
|
|
870
|
+
expect(mockQuery.where).toHaveBeenCalledWith("status");
|
|
871
|
+
expect(mockQuery.eq).toHaveBeenCalledWith("active");
|
|
872
|
+
expect(mockQuery.sort).toHaveBeenCalledWith("descending");
|
|
873
|
+
});
|
|
874
|
+
});
|
|
620
875
|
});
|
|
@@ -79,34 +79,30 @@ describe("Validation Utils", () => {
|
|
|
79
79
|
},
|
|
80
80
|
};
|
|
81
81
|
|
|
82
|
-
it("should
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}).not.toThrow();
|
|
82
|
+
it("should return true for filtering on configured field", () => {
|
|
83
|
+
const result = validateFilterField("status", gridConfig);
|
|
84
|
+
expect(result).toBe(true);
|
|
86
85
|
});
|
|
87
86
|
|
|
88
|
-
it("should
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}).toThrow("Filtering not allowed on field 'name'");
|
|
87
|
+
it("should return false for non-configured field", () => {
|
|
88
|
+
const result = validateFilterField("name", gridConfig);
|
|
89
|
+
expect(result).toBe(false);
|
|
92
90
|
});
|
|
93
91
|
|
|
94
|
-
it("should
|
|
92
|
+
it("should return false for field without filter config", () => {
|
|
95
93
|
const configWithoutFilter = {
|
|
96
94
|
name: {
|
|
97
95
|
sort: { type: "key" },
|
|
98
96
|
},
|
|
99
97
|
};
|
|
100
98
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}).toThrow("Filtering not allowed");
|
|
99
|
+
const result = validateFilterField("name", configWithoutFilter);
|
|
100
|
+
expect(result).toBe(false);
|
|
104
101
|
});
|
|
105
102
|
|
|
106
|
-
it("should
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}).toThrow(/grid\.filter config/);
|
|
103
|
+
it("should return false for non-existent field", () => {
|
|
104
|
+
const result = validateFilterField("invalidField", gridConfig);
|
|
105
|
+
expect(result).toBe(false);
|
|
110
106
|
});
|
|
111
107
|
});
|
|
112
108
|
|
|
@@ -147,7 +143,7 @@ describe("Validation Utils", () => {
|
|
|
147
143
|
it("should provide helpful error message", () => {
|
|
148
144
|
expect(() => {
|
|
149
145
|
validateSortField("invalidField", gridConfig);
|
|
150
|
-
}).toThrow(/
|
|
146
|
+
}).toThrow(/query\.sort config/);
|
|
151
147
|
});
|
|
152
148
|
});
|
|
153
149
|
|
|
@@ -193,10 +189,14 @@ describe("Validation Utils", () => {
|
|
|
193
189
|
}).toThrow("Operator 'gte' not allowed for field 'status'");
|
|
194
190
|
});
|
|
195
191
|
|
|
196
|
-
it("should
|
|
192
|
+
it("should allow any operator for field without filter config", () => {
|
|
197
193
|
expect(() => {
|
|
198
194
|
validateFilterOperator("nonexistent", "eq", gridConfig);
|
|
199
|
-
}).toThrow(
|
|
195
|
+
}).not.toThrow();
|
|
196
|
+
|
|
197
|
+
expect(() => {
|
|
198
|
+
validateFilterOperator("nonexistent", "contains", gridConfig);
|
|
199
|
+
}).not.toThrow();
|
|
200
200
|
});
|
|
201
201
|
|
|
202
202
|
it("should list allowed operators in error message", () => {
|
|
@@ -378,9 +378,8 @@ describe("Validation Utils", () => {
|
|
|
378
378
|
it("should handle empty grid config", () => {
|
|
379
379
|
const emptyConfig = {};
|
|
380
380
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
}).toThrow();
|
|
381
|
+
const filterResult = validateFilterField("anyField", emptyConfig);
|
|
382
|
+
expect(filterResult).toBe(false);
|
|
384
383
|
|
|
385
384
|
expect(() => {
|
|
386
385
|
validateSortField("anyField", emptyConfig);
|
|
@@ -400,11 +399,11 @@ describe("Validation Utils", () => {
|
|
|
400
399
|
},
|
|
401
400
|
};
|
|
402
401
|
|
|
403
|
-
expect(
|
|
402
|
+
expect(validateFilterField("field1", mixedConfig)).toBe(true);
|
|
404
403
|
expect(() => validateSortField("field2", mixedConfig)).not.toThrow();
|
|
405
404
|
expect(() => validateExpandField("field3", mixedConfig)).not.toThrow();
|
|
406
405
|
|
|
407
|
-
expect(
|
|
406
|
+
expect(validateFilterField("field2", mixedConfig)).toBe(false);
|
|
408
407
|
expect(() => validateSortField("field1", mixedConfig)).toThrow();
|
|
409
408
|
});
|
|
410
409
|
});
|