dynamo-query-engine 1.0.7 → 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 CHANGED
@@ -8,6 +8,7 @@
8
8
  export { modelRegistry, ModelRegistry } from "./src/core/modelRegistry.js";
9
9
  export {
10
10
  buildGridQuery,
11
+ executeGridQuery,
11
12
  extractGridConfig,
12
13
  getHashKey,
13
14
  getRangeKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dynamo-query-engine",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "Type-safe DynamoDB query builder for GraphQL with support for filtering, sorting, pagination, and relation expansion",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -35,7 +35,9 @@ export async function resolveExpand({
35
35
  field,
36
36
  args = {},
37
37
  }) {
38
+
38
39
  if (!parentItems || parentItems.length === 0) {
40
+ console.log(`[expandResolver] No parent items, skipping expand for field: ${field}`);
39
41
  return;
40
42
  }
41
43
 
@@ -52,11 +54,13 @@ export async function resolveExpand({
52
54
  const policy = expandPolicy[field];
53
55
 
54
56
  if (!policy) {
57
+ console.error(`[expandResolver] Field '${field}' does not have expand configuration`);
55
58
  throw new Error(
56
59
  `Field '${field}' does not have expand configuration in parent model`
57
60
  );
58
61
  }
59
62
 
63
+
60
64
  // Get target model from registry
61
65
  const targetModel = modelRegistry.get(policy.type);
62
66
 
@@ -77,12 +81,20 @@ export async function resolveExpand({
77
81
  const expandPromises = parentItems.map(async (parent) => {
78
82
  try {
79
83
  // Get partition key value from parent
80
- // This should be either parent.pk or the first field (usually tenantId)
81
- const partitionKeyValue = parent.pk || parent[Object.keys(parent)[0]];
84
+ // First, determine the target model's hash key name
85
+ const targetAttributes = targetModel.schema.getAttributes();
86
+ const targetHashKeyName = Object.keys(targetAttributes).find(
87
+ (key) => targetAttributes[key].hashKey === true
88
+ );
89
+
90
+
91
+ // Get the partition key value from the parent using the target's hash key name
92
+ const partitionKeyValue = parent[targetHashKeyName];
82
93
 
94
+
83
95
  if (!partitionKeyValue) {
84
96
  console.warn(
85
- `Cannot expand '${field}' for parent: missing partition key value`
97
+ `[expandResolver] Cannot expand '${field}' for parent: missing partition key value`
86
98
  );
87
99
  parent[expandPropertyName] = policy.relation === "ONE" ? null : [];
88
100
  return;
@@ -96,7 +108,7 @@ export async function resolveExpand({
96
108
 
97
109
  if (!foreignKeyValue) {
98
110
  console.warn(
99
- `Cannot expand '${field}' for parent: missing foreign key value '${policy.foreignKey}'`
111
+ `[expandResolver] Cannot expand '${field}' for parent: missing foreign key value '${policy.foreignKey}'`
100
112
  );
101
113
  parent[expandPropertyName] = null;
102
114
  return;
@@ -110,7 +122,7 @@ export async function resolveExpand({
110
122
 
111
123
  if (!rangeKeyName) {
112
124
  console.warn(
113
- `Cannot expand '${field}': target model '${policy.type}' has no range key defined`
125
+ `[expandResolver] Cannot expand '${field}': target model '${policy.type}' has no range key defined`
114
126
  );
115
127
  parent[expandPropertyName] = null;
116
128
  return;
@@ -121,11 +133,12 @@ export async function resolveExpand({
121
133
  items: [
122
134
  {
123
135
  field: rangeKeyName,
124
- operator: "equals",
136
+ operator: "eq",
125
137
  value: foreignKeyValue,
126
138
  },
127
139
  ],
128
140
  });
141
+
129
142
 
130
143
  const { query } = buildGridQuery({
131
144
  model: targetModel,
@@ -155,6 +168,7 @@ export async function resolveExpand({
155
168
  __typename: graphqlTypeName,
156
169
  }));
157
170
 
171
+
158
172
  // Assign results to parent
159
173
  if (policy.relation === "ONE") {
160
174
  parent[expandPropertyName] = resultsWithTypename.length > 0 ? resultsWithTypename[0] : null;
@@ -162,7 +176,8 @@ export async function resolveExpand({
162
176
  parent[expandPropertyName] = resultsWithTypename;
163
177
  }
164
178
  } catch (error) {
165
- console.error(`Error expanding '${field}' for parent:`, error);
179
+ console.error(`[expandResolver] Error expanding '${field}' for parent:`, error);
180
+ console.error(`[expandResolver] Error stack:`, error.stack);
166
181
  // Gracefully handle errors - set empty result
167
182
  parent[expandPropertyName] = policy.relation === "ONE" ? null : [];
168
183
  }
@@ -193,19 +208,99 @@ function createGraphQLToSchemaFieldMap(schema) {
193
208
  return map;
194
209
  }
195
210
 
211
+ /**
212
+ * Parse AppSync selectionSetList to extract requested expand fields
213
+ * @param {string[]} selectionSetList - AppSync selectionSetList array
214
+ * @param {string} parentPath - Parent path prefix (e.g., "rows")
215
+ * @returns {Set<string>} Set of field names that were requested
216
+ */
217
+ function parseAppSyncSelectionSet(selectionSetList, parentPath = "rows") {
218
+ const requestedFields = new Set();
219
+ const prefix = parentPath + "/";
220
+
221
+ for (const path of selectionSetList) {
222
+ if (path.startsWith(prefix)) {
223
+ const afterPrefix = path.substring(prefix.length);
224
+ const firstSlash = afterPrefix.indexOf("/");
225
+
226
+ if (firstSlash === -1) {
227
+ // This is a direct child field (e.g., "rows/unitId")
228
+ requestedFields.add(afterPrefix);
229
+ } else {
230
+ // This is a nested field (e.g., "rows/Unit/name")
231
+ const fieldName = afterPrefix.substring(0, firstSlash);
232
+ requestedFields.add(fieldName);
233
+ }
234
+ }
235
+ }
236
+
237
+ return requestedFields;
238
+ }
239
+
196
240
  /**
197
241
  * Resolve multiple expands from GraphQL resolve info
198
242
  * @param {Array} parentItems - Parent items to expand
199
243
  * @param {*} parentModel - Parent Dynamoose model
200
- * @param {Object} fieldsByTypeName - Fields by type name from graphql-parse-resolve-info
244
+ * @param {Object|string[]} fieldsByTypeName - Fields by type name from graphql-parse-resolve-info OR AppSync selectionSetList
245
+ * @param {Object} [options] - Additional options
246
+ * @param {boolean} [options.isAppSync] - Whether to parse AppSync selectionSetList format
247
+ * @param {string} [options.parentPath] - Parent path for AppSync (default: "rows")
201
248
  * @returns {Promise<void>}
202
249
  */
203
250
  export async function resolveExpands(
204
251
  parentItems,
205
252
  parentModel,
206
- fieldsByTypeName
253
+ fieldsByTypeName,
254
+ options = {}
207
255
  ) {
256
+ const { isAppSync = false, parentPath = "rows" } = options;
257
+
258
+ // Handle AppSync format
259
+ if (isAppSync) {
260
+ const selectionSetList = fieldsByTypeName;
261
+
262
+ if (!selectionSetList || selectionSetList.length === 0) {
263
+ console.log(`[expandResolver] No selectionSetList, skipping`);
264
+ return;
265
+ }
266
+
267
+ // Parse AppSync selection set to find requested fields
268
+ const requestedFields = parseAppSyncSelectionSet(selectionSetList, parentPath);
269
+
270
+ // Get expand policy to filter only expandable fields
271
+ const expandPolicy = extractExpandPolicy(parentModel.schema);
272
+
273
+ // Create mapping from GraphQL field names to schema field names
274
+ const graphqlToSchemaMap = createGraphQLToSchemaFieldMap(parentModel.schema);
275
+
276
+ // Resolve all expands in parallel
277
+ const expandPromises = Array.from(requestedFields)
278
+ .map((graphqlFieldName) => {
279
+ // Map GraphQL field name to schema field name
280
+ const schemaFieldName = graphqlToSchemaMap[graphqlFieldName];
281
+
282
+ // Check if this field is expandable
283
+ if (!schemaFieldName || !expandPolicy[schemaFieldName]) {
284
+ console.log(`[expandResolver] Field '${graphqlFieldName}' is not expandable (schemaFieldName: ${schemaFieldName}, has policy: ${!!expandPolicy[schemaFieldName]})`);
285
+ return null;
286
+ }
287
+
288
+ return resolveExpand({
289
+ parentItems,
290
+ parentModel,
291
+ field: schemaFieldName,
292
+ args: {},
293
+ });
294
+ })
295
+ .filter(Boolean); // Remove null entries
296
+
297
+ await Promise.all(expandPromises);
298
+ return;
299
+ }
300
+
301
+ // Handle standard graphql-parse-resolve-info format
208
302
  if (!fieldsByTypeName || Object.keys(fieldsByTypeName).length === 0) {
303
+ console.log(`[expandResolver] No fieldsByTypeName, skipping`);
209
304
  return;
210
305
  }
211
306
 
@@ -214,12 +309,13 @@ export async function resolveExpands(
214
309
  const fields = fieldsByTypeName[typeName];
215
310
 
216
311
  if (!fields) {
312
+ console.log(`[expandResolver] No fields found for type ${typeName}`);
217
313
  return;
218
314
  }
219
315
 
220
316
  // Get expand policy to filter only expandable fields
221
317
  const expandPolicy = extractExpandPolicy(parentModel.schema);
222
-
318
+
223
319
  // Create mapping from GraphQL field names to schema field names
224
320
  const graphqlToSchemaMap = createGraphQLToSchemaFieldMap(parentModel.schema);
225
321
 
@@ -231,6 +327,7 @@ export async function resolveExpands(
231
327
 
232
328
  // Check if this field is expandable
233
329
  if (!schemaFieldName || !expandPolicy[schemaFieldName]) {
330
+ console.log(`[expandResolver] Field '${graphqlFieldName}' is not expandable (schemaFieldName: ${schemaFieldName}, has policy: ${!!expandPolicy[schemaFieldName]})`);
234
331
  return null;
235
332
  }
236
333
 
@@ -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
- validateFilterField(filterItem.field, gridConfig);
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, gridConfig) {
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
- // Apply descending sort if specified
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 (DynamoDB limit)");
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
- const index = determineIndex(filterModel, gridConfig);
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, gridConfig);
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 { buildGridQuery, extractGridConfig, getHashKey, getRangeKey } from "./gridQueryBuilder.js";
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,
@@ -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
@@ -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
- * @throws {Error} If filtering is not allowed on this field
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
- if (!config || !config.filter) {
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 grid.sort config can be sorted.`
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 for this field
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
- throw new Error(`Field '${field}' does not have filter configuration`);
74
+ return;
61
75
  }
62
76
 
63
- if (config.operators && !config.operators.includes(operator)) {
64
- throw new Error(
65
- `Operator '${operator}' not allowed for field '${field}'. Allowed operators: ${config.operators.join(", ")}`
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 allow filtering on configured field", () => {
83
- expect(() => {
84
- validateFilterField("status", gridConfig);
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 throw error for non-configured field", () => {
89
- expect(() => {
90
- validateFilterField("name", gridConfig);
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 throw error for field without filter config", () => {
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
- expect(() => {
102
- validateFilterField("name", configWithoutFilter);
103
- }).toThrow("Filtering not allowed");
99
+ const result = validateFilterField("name", configWithoutFilter);
100
+ expect(result).toBe(false);
104
101
  });
105
102
 
106
- it("should provide helpful error message", () => {
107
- expect(() => {
108
- validateFilterField("invalidField", gridConfig);
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(/grid\.sort config/);
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 throw error for field without filter config", () => {
192
+ it("should allow any operator for field without filter config", () => {
197
193
  expect(() => {
198
194
  validateFilterOperator("nonexistent", "eq", gridConfig);
199
- }).toThrow("does not have filter configuration");
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
- expect(() => {
382
- validateFilterField("anyField", emptyConfig);
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(() => validateFilterField("field1", mixedConfig)).not.toThrow();
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(() => validateFilterField("field2", mixedConfig)).toThrow();
406
+ expect(validateFilterField("field2", mixedConfig)).toBe(false);
408
407
  expect(() => validateSortField("field1", mixedConfig)).toThrow();
409
408
  });
410
409
  });