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 +1 -0
- package/package.json +1 -1
- package/src/core/expandResolver.js +107 -10
- 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,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
|
-
//
|
|
81
|
-
const
|
|
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: "
|
|
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
|
-
|
|
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
|
});
|