dynamo-query-engine 1.0.6 → 1.0.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dynamo-query-engine",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
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,11 @@ export async function resolveExpand({
35
35
  field,
36
36
  args = {},
37
37
  }) {
38
+ console.log(`[expandResolver] resolveExpand called for field: ${field}`);
39
+ console.log(`[expandResolver] parentItems count: ${parentItems?.length || 0}`);
40
+
38
41
  if (!parentItems || parentItems.length === 0) {
42
+ console.log(`[expandResolver] No parent items, skipping expand for field: ${field}`);
39
43
  return;
40
44
  }
41
45
 
@@ -52,11 +56,13 @@ export async function resolveExpand({
52
56
  const policy = expandPolicy[field];
53
57
 
54
58
  if (!policy) {
59
+ console.error(`[expandResolver] Field '${field}' does not have expand configuration`);
55
60
  throw new Error(
56
61
  `Field '${field}' does not have expand configuration in parent model`
57
62
  );
58
63
  }
59
64
 
65
+
60
66
  // Get target model from registry
61
67
  const targetModel = modelRegistry.get(policy.type);
62
68
 
@@ -76,30 +82,87 @@ export async function resolveExpand({
76
82
  // Resolve relations for each parent item
77
83
  const expandPromises = parentItems.map(async (parent) => {
78
84
  try {
79
- // For MANY relations, we need a partition key value
80
- // This should be either parent.pk or a specific field based on the relation
81
- const partitionKeyValue = parent.pk || parent[Object.keys(parent)[0]];
85
+ // Get partition key value from parent
86
+ // First, determine the target model's hash key name
87
+ const targetAttributes = targetModel.schema.getAttributes();
88
+ const targetHashKeyName = Object.keys(targetAttributes).find(
89
+ (key) => targetAttributes[key].hashKey === true
90
+ );
91
+
92
+
93
+ // Get the partition key value from the parent using the target's hash key name
94
+ const partitionKeyValue = parent[targetHashKeyName];
82
95
 
96
+
83
97
  if (!partitionKeyValue) {
84
98
  console.warn(
85
- `Cannot expand '${field}' for parent: missing partition key value`
99
+ `[expandResolver] Cannot expand '${field}' for parent: missing partition key value`
86
100
  );
87
- parent[expandPropertyName] = [];
101
+ parent[expandPropertyName] = policy.relation === "ONE" ? null : [];
88
102
  return;
89
103
  }
90
104
 
91
- // Build query for expanded items
92
- const { query } = buildGridQuery({
93
- model: targetModel,
94
- partitionKeyValue,
95
- filterModel: args.filter,
96
- sortModel: args.sort,
97
- paginationModel: { pageSize: limit },
98
- // No cursor for nested expands
99
- });
105
+ let results;
106
+
107
+ // For ONE relations with a foreignKey, we can optimize by directly querying for that specific item
108
+ if (policy.relation === "ONE" && policy.foreignKey) {
109
+ const foreignKeyValue = parent[policy.foreignKey];
110
+
111
+ if (!foreignKeyValue) {
112
+ console.warn(
113
+ `[expandResolver] Cannot expand '${field}' for parent: missing foreign key value '${policy.foreignKey}'`
114
+ );
115
+ parent[expandPropertyName] = null;
116
+ return;
117
+ }
118
+
119
+ // Build a query that filters for the specific range key value
120
+ const targetAttributes = targetModel.schema.getAttributes();
121
+ const rangeKeyName = Object.keys(targetAttributes).find(
122
+ (key) => targetAttributes[key].rangeKey === true
123
+ );
124
+
125
+ if (!rangeKeyName) {
126
+ console.warn(
127
+ `[expandResolver] Cannot expand '${field}': target model '${policy.type}' has no range key defined`
128
+ );
129
+ parent[expandPropertyName] = null;
130
+ return;
131
+ }
100
132
 
101
- // Execute query
102
- const results = await query.exec();
133
+ // Build query with range key filter
134
+ const filterModel = JSON.stringify({
135
+ items: [
136
+ {
137
+ field: rangeKeyName,
138
+ operator: "eq",
139
+ value: foreignKeyValue,
140
+ },
141
+ ],
142
+ });
143
+
144
+
145
+ const { query } = buildGridQuery({
146
+ model: targetModel,
147
+ partitionKeyValue,
148
+ filterModel,
149
+ limit: 1,
150
+ });
151
+
152
+ results = await query.exec();
153
+ } else {
154
+ // For MANY relations or ONE without foreignKey, use standard query
155
+ const { query } = buildGridQuery({
156
+ model: targetModel,
157
+ partitionKeyValue,
158
+ filterModel: args.filter,
159
+ sortModel: args.sort,
160
+ paginationModel: { pageSize: limit },
161
+ // No cursor for nested expands
162
+ });
163
+
164
+ results = await query.exec();
165
+ }
103
166
 
104
167
  // Add __typename to each result
105
168
  const resultsWithTypename = results.map((item) => ({
@@ -107,6 +170,7 @@ export async function resolveExpand({
107
170
  __typename: graphqlTypeName,
108
171
  }));
109
172
 
173
+
110
174
  // Assign results to parent
111
175
  if (policy.relation === "ONE") {
112
176
  parent[expandPropertyName] = resultsWithTypename.length > 0 ? resultsWithTypename[0] : null;
@@ -114,7 +178,8 @@ export async function resolveExpand({
114
178
  parent[expandPropertyName] = resultsWithTypename;
115
179
  }
116
180
  } catch (error) {
117
- console.error(`Error expanding '${field}' for parent:`, error);
181
+ console.error(`[expandResolver] Error expanding '${field}' for parent:`, error);
182
+ console.error(`[expandResolver] Error stack:`, error.stack);
118
183
  // Gracefully handle errors - set empty result
119
184
  parent[expandPropertyName] = policy.relation === "ONE" ? null : [];
120
185
  }
@@ -145,19 +210,102 @@ function createGraphQLToSchemaFieldMap(schema) {
145
210
  return map;
146
211
  }
147
212
 
213
+ /**
214
+ * Parse AppSync selectionSetList to extract requested expand fields
215
+ * @param {string[]} selectionSetList - AppSync selectionSetList array
216
+ * @param {string} parentPath - Parent path prefix (e.g., "rows")
217
+ * @returns {Set<string>} Set of field names that were requested
218
+ */
219
+ function parseAppSyncSelectionSet(selectionSetList, parentPath = "rows") {
220
+ const requestedFields = new Set();
221
+ const prefix = parentPath + "/";
222
+
223
+ for (const path of selectionSetList) {
224
+ if (path.startsWith(prefix)) {
225
+ const afterPrefix = path.substring(prefix.length);
226
+ const firstSlash = afterPrefix.indexOf("/");
227
+
228
+ if (firstSlash === -1) {
229
+ // This is a direct child field (e.g., "rows/unitId")
230
+ requestedFields.add(afterPrefix);
231
+ } else {
232
+ // This is a nested field (e.g., "rows/Unit/name")
233
+ const fieldName = afterPrefix.substring(0, firstSlash);
234
+ requestedFields.add(fieldName);
235
+ }
236
+ }
237
+ }
238
+
239
+ return requestedFields;
240
+ }
241
+
148
242
  /**
149
243
  * Resolve multiple expands from GraphQL resolve info
150
244
  * @param {Array} parentItems - Parent items to expand
151
245
  * @param {*} parentModel - Parent Dynamoose model
152
- * @param {Object} fieldsByTypeName - Fields by type name from graphql-parse-resolve-info
246
+ * @param {Object|string[]} fieldsByTypeName - Fields by type name from graphql-parse-resolve-info OR AppSync selectionSetList
247
+ * @param {Object} [options] - Additional options
248
+ * @param {boolean} [options.isAppSync] - Whether to parse AppSync selectionSetList format
249
+ * @param {string} [options.parentPath] - Parent path for AppSync (default: "rows")
153
250
  * @returns {Promise<void>}
154
251
  */
155
252
  export async function resolveExpands(
156
253
  parentItems,
157
254
  parentModel,
158
- fieldsByTypeName
255
+ fieldsByTypeName,
256
+ options = {}
159
257
  ) {
258
+ const { isAppSync = false, parentPath = "rows" } = options;
259
+
260
+ // Handle AppSync format
261
+ if (isAppSync) {
262
+ const selectionSetList = fieldsByTypeName;
263
+
264
+ if (!selectionSetList || selectionSetList.length === 0) {
265
+ console.log(`[expandResolver] No selectionSetList, skipping`);
266
+ return;
267
+ }
268
+
269
+ // Parse AppSync selection set to find requested fields
270
+ const requestedFields = parseAppSyncSelectionSet(selectionSetList, parentPath);
271
+
272
+ // Get expand policy to filter only expandable fields
273
+ const expandPolicy = extractExpandPolicy(parentModel.schema);
274
+
275
+ // Create mapping from GraphQL field names to schema field names
276
+ const graphqlToSchemaMap = createGraphQLToSchemaFieldMap(parentModel.schema);
277
+
278
+ // Resolve all expands in parallel
279
+ const expandPromises = Array.from(requestedFields)
280
+ .map((graphqlFieldName) => {
281
+ // Map GraphQL field name to schema field name
282
+ const schemaFieldName = graphqlToSchemaMap[graphqlFieldName];
283
+
284
+ // Check if this field is expandable
285
+ if (!schemaFieldName || !expandPolicy[schemaFieldName]) {
286
+ console.log(`[expandResolver] Field '${graphqlFieldName}' is not expandable (schemaFieldName: ${schemaFieldName}, has policy: ${!!expandPolicy[schemaFieldName]})`);
287
+ return null;
288
+ }
289
+
290
+ console.log(`[expandResolver] Expanding field '${graphqlFieldName}' (schema: ${schemaFieldName})`);
291
+ return resolveExpand({
292
+ parentItems,
293
+ parentModel,
294
+ field: schemaFieldName,
295
+ args: {},
296
+ });
297
+ })
298
+ .filter(Boolean); // Remove null entries
299
+
300
+ console.log(`[expandResolver] Executing ${expandPromises.length} expand promises`);
301
+ await Promise.all(expandPromises);
302
+ console.log(`[expandResolver] All expands completed`);
303
+ return;
304
+ }
305
+
306
+ // Handle standard graphql-parse-resolve-info format
160
307
  if (!fieldsByTypeName || Object.keys(fieldsByTypeName).length === 0) {
308
+ console.log(`[expandResolver] No fieldsByTypeName, skipping`);
161
309
  return;
162
310
  }
163
311
 
@@ -166,12 +314,13 @@ export async function resolveExpands(
166
314
  const fields = fieldsByTypeName[typeName];
167
315
 
168
316
  if (!fields) {
317
+ console.log(`[expandResolver] No fields found for type ${typeName}`);
169
318
  return;
170
319
  }
171
320
 
172
321
  // Get expand policy to filter only expandable fields
173
322
  const expandPolicy = extractExpandPolicy(parentModel.schema);
174
-
323
+
175
324
  // Create mapping from GraphQL field names to schema field names
176
325
  const graphqlToSchemaMap = createGraphQLToSchemaFieldMap(parentModel.schema);
177
326
 
@@ -183,6 +332,7 @@ export async function resolveExpands(
183
332
 
184
333
  // Check if this field is expandable
185
334
  if (!schemaFieldName || !expandPolicy[schemaFieldName]) {
335
+ console.log(`[expandResolver] Field '${graphqlFieldName}' is not expandable (schemaFieldName: ${schemaFieldName}, has policy: ${!!expandPolicy[schemaFieldName]})`);
186
336
  return null;
187
337
  }
188
338
 
@@ -33,6 +33,7 @@ describe("ExpandResolver", () => {
33
33
  expand: {
34
34
  type: "Unit",
35
35
  relation: "ONE",
36
+ foreignKey: "unitId", // Field in parent that references the child
36
37
  defaultLimit: 1,
37
38
  maxLimit: 1,
38
39
  },
@@ -177,6 +178,22 @@ describe("ExpandResolver", () => {
177
178
  expect(parentItems[0].Units[1].__typename).toBe("Units");
178
179
  });
179
180
 
181
+ it("should use foreignKey to filter for specific item in ONE relation", async () => {
182
+ await resolveExpand({
183
+ parentItems,
184
+ parentModel,
185
+ field: "unitId",
186
+ });
187
+
188
+ // Should have expanded exactly one unit
189
+ expect(parentItems[0].Unit).toBeDefined();
190
+ expect(parentItems[0].Unit.unitId).toBe("unit-1");
191
+ expect(parentItems[0].Unit.name).toBe("Unit A");
192
+
193
+ // Verify that buildGridQuery was called with a filterModel
194
+ // (This would require spy/mock inspection in a more detailed test)
195
+ });
196
+
180
197
  it("should fallback to field name when graphql.type is not defined", async () => {
181
198
  // Clear and re-register with different config
182
199
  modelRegistry.clear();