dynamo-query-engine 1.0.5 → 1.0.7

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.5",
3
+ "version": "1.0.7",
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",
@@ -4,9 +4,7 @@
4
4
 
5
5
  import "../types/jsdoc.js";
6
6
  import { modelRegistry } from "./modelRegistry.js";
7
- import { buildGridQuery } from "./gridQueryBuilder.js";
8
- import { validateExpandField } from "../utils/validation.js";
9
- import { extractGridConfig } from "./gridQueryBuilder.js";
7
+ import { buildGridQuery, extractGridConfig } from "./gridQueryBuilder.js";
10
8
 
11
9
  /**
12
10
  * Extract expand policy from schema
@@ -62,6 +60,15 @@ export async function resolveExpand({
62
60
  // Get target model from registry
63
61
  const targetModel = modelRegistry.get(policy.type);
64
62
 
63
+ // Get the GraphQL type name from the schema (if defined)
64
+ const attributes = parentModel.schema.getAttributes();
65
+ const fieldConfig = attributes[field];
66
+ const graphqlTypeName = fieldConfig?.graphql?.type || policy.type;
67
+
68
+ // Determine which property name to use for storing the expanded data
69
+ // If graphql.type is defined, use it as the property name, otherwise use the field name
70
+ const expandPropertyName = fieldConfig?.graphql?.type || field;
71
+
65
72
  // Determine limit based on args and policy
66
73
  const requestedLimit = args.pagination?.pageSize ?? policy.defaultLimit;
67
74
  const limit = Math.min(requestedLimit, policy.maxLimit);
@@ -69,41 +76,95 @@ export async function resolveExpand({
69
76
  // Resolve relations for each parent item
70
77
  const expandPromises = parentItems.map(async (parent) => {
71
78
  try {
72
- // For MANY relations, we need a partition key value
73
- // This should be either parent.pk or a specific field based on the relation
79
+ // Get partition key value from parent
80
+ // This should be either parent.pk or the first field (usually tenantId)
74
81
  const partitionKeyValue = parent.pk || parent[Object.keys(parent)[0]];
75
82
 
76
83
  if (!partitionKeyValue) {
77
84
  console.warn(
78
85
  `Cannot expand '${field}' for parent: missing partition key value`
79
86
  );
80
- parent[field] = [];
87
+ parent[expandPropertyName] = policy.relation === "ONE" ? null : [];
81
88
  return;
82
89
  }
83
90
 
84
- // Build query for expanded items
85
- const { query } = buildGridQuery({
86
- model: targetModel,
87
- partitionKeyValue,
88
- filterModel: args.filter,
89
- sortModel: args.sort,
90
- paginationModel: { pageSize: limit },
91
- // No cursor for nested expands
92
- });
91
+ let results;
92
+
93
+ // For ONE relations with a foreignKey, we can optimize by directly querying for that specific item
94
+ if (policy.relation === "ONE" && policy.foreignKey) {
95
+ const foreignKeyValue = parent[policy.foreignKey];
96
+
97
+ if (!foreignKeyValue) {
98
+ console.warn(
99
+ `Cannot expand '${field}' for parent: missing foreign key value '${policy.foreignKey}'`
100
+ );
101
+ parent[expandPropertyName] = null;
102
+ return;
103
+ }
104
+
105
+ // Build a query that filters for the specific range key value
106
+ const targetAttributes = targetModel.schema.getAttributes();
107
+ const rangeKeyName = Object.keys(targetAttributes).find(
108
+ (key) => targetAttributes[key].rangeKey === true
109
+ );
93
110
 
94
- // Execute query
95
- const results = await query.exec();
111
+ if (!rangeKeyName) {
112
+ console.warn(
113
+ `Cannot expand '${field}': target model '${policy.type}' has no range key defined`
114
+ );
115
+ parent[expandPropertyName] = null;
116
+ return;
117
+ }
118
+
119
+ // Build query with range key filter
120
+ const filterModel = JSON.stringify({
121
+ items: [
122
+ {
123
+ field: rangeKeyName,
124
+ operator: "equals",
125
+ value: foreignKeyValue,
126
+ },
127
+ ],
128
+ });
129
+
130
+ const { query } = buildGridQuery({
131
+ model: targetModel,
132
+ partitionKeyValue,
133
+ filterModel,
134
+ limit: 1,
135
+ });
136
+
137
+ results = await query.exec();
138
+ } else {
139
+ // For MANY relations or ONE without foreignKey, use standard query
140
+ const { query } = buildGridQuery({
141
+ model: targetModel,
142
+ partitionKeyValue,
143
+ filterModel: args.filter,
144
+ sortModel: args.sort,
145
+ paginationModel: { pageSize: limit },
146
+ // No cursor for nested expands
147
+ });
148
+
149
+ results = await query.exec();
150
+ }
151
+
152
+ // Add __typename to each result
153
+ const resultsWithTypename = results.map((item) => ({
154
+ ...item,
155
+ __typename: graphqlTypeName,
156
+ }));
96
157
 
97
158
  // Assign results to parent
98
159
  if (policy.relation === "ONE") {
99
- parent[field] = results.length > 0 ? results[0] : null;
160
+ parent[expandPropertyName] = resultsWithTypename.length > 0 ? resultsWithTypename[0] : null;
100
161
  } else {
101
- parent[field] = results;
162
+ parent[expandPropertyName] = resultsWithTypename;
102
163
  }
103
164
  } catch (error) {
104
165
  console.error(`Error expanding '${field}' for parent:`, error);
105
166
  // Gracefully handle errors - set empty result
106
- parent[field] = policy.relation === "ONE" ? null : [];
167
+ parent[expandPropertyName] = policy.relation === "ONE" ? null : [];
107
168
  }
108
169
  });
109
170
 
@@ -111,6 +172,27 @@ export async function resolveExpand({
111
172
  await Promise.all(expandPromises);
112
173
  }
113
174
 
175
+ /**
176
+ * Create a mapping from GraphQL field names to schema field names
177
+ * @param {*} schema - Dynamoose schema
178
+ * @returns {Object.<string, string>} Map of GraphQL field name to schema field name
179
+ */
180
+ function createGraphQLToSchemaFieldMap(schema) {
181
+ const attributes = schema.getAttributes();
182
+ const map = {};
183
+
184
+ for (const [fieldName, config] of Object.entries(attributes)) {
185
+ // If the field has a graphql.type config, map from graphql type to field name
186
+ if (config?.graphql?.type) {
187
+ map[config.graphql.type] = fieldName;
188
+ }
189
+ // Also keep the original field name mapping (for backward compatibility)
190
+ map[fieldName] = fieldName;
191
+ }
192
+
193
+ return map;
194
+ }
195
+
114
196
  /**
115
197
  * Resolve multiple expands from GraphQL resolve info
116
198
  * @param {Array} parentItems - Parent items to expand
@@ -137,18 +219,29 @@ export async function resolveExpands(
137
219
 
138
220
  // Get expand policy to filter only expandable fields
139
221
  const expandPolicy = extractExpandPolicy(parentModel.schema);
222
+
223
+ // Create mapping from GraphQL field names to schema field names
224
+ const graphqlToSchemaMap = createGraphQLToSchemaFieldMap(parentModel.schema);
140
225
 
141
226
  // Resolve all expands in parallel
142
227
  const expandPromises = Object.entries(fields)
143
- .filter(([fieldName]) => expandPolicy[fieldName])
144
- .map(([fieldName, fieldInfo]) =>
145
- resolveExpand({
228
+ .map(([graphqlFieldName, fieldInfo]) => {
229
+ // Map GraphQL field name to schema field name
230
+ const schemaFieldName = graphqlToSchemaMap[graphqlFieldName];
231
+
232
+ // Check if this field is expandable
233
+ if (!schemaFieldName || !expandPolicy[schemaFieldName]) {
234
+ return null;
235
+ }
236
+
237
+ return resolveExpand({
146
238
  parentItems,
147
239
  parentModel,
148
- field: fieldName,
240
+ field: schemaFieldName,
149
241
  args: fieldInfo.args || {},
150
- })
151
- );
242
+ });
243
+ })
244
+ .filter(Boolean); // Remove null entries
152
245
 
153
246
  await Promise.all(expandPromises);
154
247
  }
@@ -0,0 +1,356 @@
1
+ /**
2
+ * @fileoverview Tests for expand resolver with GraphQL type names
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, vi } from "vitest";
6
+ import { resolveExpand, resolveExpands } from "../../src/core/expandResolver.js";
7
+ import { modelRegistry } from "../../src/core/modelRegistry.js";
8
+ import { createMockSchema, createMockModel, createMockQuery } from "../mocks/dynamoose.mock.js";
9
+
10
+ describe("ExpandResolver", () => {
11
+ describe("resolveExpand with GraphQL type names", () => {
12
+ let parentModel;
13
+ let targetModel;
14
+ let parentItems;
15
+
16
+ beforeEach(() => {
17
+ // Clear registry
18
+ modelRegistry.clear();
19
+
20
+ // Create parent schema with unitId field that has graphql.type config
21
+ const parentSchema = createMockSchema({
22
+ tenantId: {
23
+ type: String,
24
+ hashKey: true,
25
+ },
26
+ reservationId: {
27
+ type: String,
28
+ rangeKey: true,
29
+ },
30
+ unitId: {
31
+ type: String,
32
+ query: {
33
+ expand: {
34
+ type: "Unit",
35
+ relation: "ONE",
36
+ foreignKey: "unitId", // Field in parent that references the child
37
+ defaultLimit: 1,
38
+ maxLimit: 1,
39
+ },
40
+ },
41
+ graphql: {
42
+ type: "Unit", // GraphQL type name
43
+ },
44
+ },
45
+ });
46
+
47
+ // Create target schema (Unit)
48
+ const targetSchema = createMockSchema({
49
+ tenantId: {
50
+ type: String,
51
+ hashKey: true,
52
+ },
53
+ unitId: {
54
+ type: String,
55
+ rangeKey: true,
56
+ },
57
+ name: {
58
+ type: String,
59
+ },
60
+ });
61
+
62
+ // Create parent model
63
+ parentModel = createMockModel(parentSchema);
64
+
65
+ // Create target model with mock query result
66
+ const unitData = [
67
+ {
68
+ tenantId: "tenant-1",
69
+ unitId: "unit-1",
70
+ name: "Unit A",
71
+ },
72
+ ];
73
+ targetModel = createMockModel(targetSchema, unitData);
74
+
75
+ // Register target model
76
+ modelRegistry.register("Unit", targetModel);
77
+
78
+ // Create parent items
79
+ parentItems = [
80
+ {
81
+ tenantId: "tenant-1",
82
+ reservationId: "res-1",
83
+ unitId: "unit-1",
84
+ },
85
+ ];
86
+ });
87
+
88
+ it("should store expanded data under GraphQL type name (Unit) not field name (unitId)", async () => {
89
+ await resolveExpand({
90
+ parentItems,
91
+ parentModel,
92
+ field: "unitId",
93
+ });
94
+
95
+ // The expanded data should be under 'Unit', not 'unitId'
96
+ expect(parentItems[0].Unit).toBeDefined();
97
+ expect(parentItems[0].Unit).toEqual({
98
+ tenantId: "tenant-1",
99
+ unitId: "unit-1",
100
+ name: "Unit A",
101
+ __typename: "Unit",
102
+ });
103
+ });
104
+
105
+ it("should add __typename field to expanded data", async () => {
106
+ await resolveExpand({
107
+ parentItems,
108
+ parentModel,
109
+ field: "unitId",
110
+ });
111
+
112
+ expect(parentItems[0].Unit.__typename).toBe("Unit");
113
+ });
114
+
115
+ it("should handle ONE relation correctly", async () => {
116
+ await resolveExpand({
117
+ parentItems,
118
+ parentModel,
119
+ field: "unitId",
120
+ });
121
+
122
+ // ONE relation should return a single object, not an array
123
+ expect(parentItems[0].Unit).toBeDefined();
124
+ expect(Array.isArray(parentItems[0].Unit)).toBe(false);
125
+ });
126
+
127
+ it("should handle MANY relation correctly", async () => {
128
+ // Clear and re-register with different config
129
+ modelRegistry.clear();
130
+
131
+ // Update parent schema for MANY relation
132
+ const parentSchemaMany = createMockSchema({
133
+ tenantId: {
134
+ type: String,
135
+ hashKey: true,
136
+ },
137
+ reservationId: {
138
+ type: String,
139
+ rangeKey: true,
140
+ },
141
+ unitId: {
142
+ type: String,
143
+ query: {
144
+ expand: {
145
+ type: "Unit",
146
+ relation: "MANY",
147
+ defaultLimit: 10,
148
+ maxLimit: 100,
149
+ },
150
+ },
151
+ graphql: {
152
+ type: "Units", // Plural for MANY relation
153
+ },
154
+ },
155
+ });
156
+
157
+ const unitsData = [
158
+ { tenantId: "tenant-1", unitId: "unit-1", name: "Unit A" },
159
+ { tenantId: "tenant-1", unitId: "unit-2", name: "Unit B" },
160
+ ];
161
+
162
+ const parentModelMany = createMockModel(parentSchemaMany);
163
+ const targetModelMany = createMockModel(targetModel.schema, unitsData);
164
+
165
+ modelRegistry.register("Unit", targetModelMany);
166
+
167
+ await resolveExpand({
168
+ parentItems,
169
+ parentModel: parentModelMany,
170
+ field: "unitId",
171
+ });
172
+
173
+ // MANY relation should return an array
174
+ expect(parentItems[0].Units).toBeDefined();
175
+ expect(Array.isArray(parentItems[0].Units)).toBe(true);
176
+ expect(parentItems[0].Units).toHaveLength(2);
177
+ expect(parentItems[0].Units[0].__typename).toBe("Units");
178
+ expect(parentItems[0].Units[1].__typename).toBe("Units");
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
+
197
+ it("should fallback to field name when graphql.type is not defined", async () => {
198
+ // Clear and re-register with different config
199
+ modelRegistry.clear();
200
+
201
+ // Create schema without graphql.type config
202
+ const parentSchemaNoGraphQL = createMockSchema({
203
+ tenantId: {
204
+ type: String,
205
+ hashKey: true,
206
+ },
207
+ reservationId: {
208
+ type: String,
209
+ rangeKey: true,
210
+ },
211
+ unitId: {
212
+ type: String,
213
+ query: {
214
+ expand: {
215
+ type: "Unit",
216
+ relation: "ONE",
217
+ defaultLimit: 1,
218
+ maxLimit: 1,
219
+ },
220
+ },
221
+ // No graphql config
222
+ },
223
+ });
224
+
225
+ const unitData = [
226
+ { tenantId: "tenant-1", unitId: "unit-1", name: "Unit A" },
227
+ ];
228
+
229
+ const parentModelNoGraphQL = createMockModel(parentSchemaNoGraphQL);
230
+ const targetModelNoGraphQL = createMockModel(targetModel.schema, unitData);
231
+
232
+ modelRegistry.register("Unit", targetModelNoGraphQL);
233
+
234
+ await resolveExpand({
235
+ parentItems,
236
+ parentModel: parentModelNoGraphQL,
237
+ field: "unitId",
238
+ });
239
+
240
+ // Should fallback to field name 'unitId'
241
+ expect(parentItems[0].unitId).toBeDefined();
242
+ expect(parentItems[0].unitId.__typename).toBe("Unit");
243
+ });
244
+ });
245
+
246
+ describe("resolveExpands with GraphQL field mapping", () => {
247
+ let parentModel;
248
+ let targetModel;
249
+ let parentItems;
250
+
251
+ beforeEach(() => {
252
+ modelRegistry.clear();
253
+
254
+ const parentSchema = createMockSchema({
255
+ tenantId: {
256
+ type: String,
257
+ hashKey: true,
258
+ },
259
+ reservationId: {
260
+ type: String,
261
+ rangeKey: true,
262
+ },
263
+ unitId: {
264
+ type: String,
265
+ query: {
266
+ expand: {
267
+ type: "Unit",
268
+ relation: "ONE",
269
+ defaultLimit: 1,
270
+ maxLimit: 1,
271
+ },
272
+ },
273
+ graphql: {
274
+ type: "Unit",
275
+ },
276
+ },
277
+ });
278
+
279
+ const targetSchema = createMockSchema({
280
+ tenantId: {
281
+ type: String,
282
+ hashKey: true,
283
+ },
284
+ unitId: {
285
+ type: String,
286
+ rangeKey: true,
287
+ },
288
+ name: {
289
+ type: String,
290
+ },
291
+ });
292
+
293
+ const unitData = [
294
+ { tenantId: "tenant-1", unitId: "unit-1", name: "Unit A" },
295
+ ];
296
+
297
+ parentModel = createMockModel(parentSchema);
298
+ targetModel = createMockModel(targetSchema, unitData);
299
+
300
+ modelRegistry.register("Unit", targetModel);
301
+
302
+ parentItems = [
303
+ {
304
+ tenantId: "tenant-1",
305
+ reservationId: "res-1",
306
+ unitId: "unit-1",
307
+ },
308
+ ];
309
+ });
310
+
311
+ it("should map GraphQL field name (Unit) to schema field name (unitId)", async () => {
312
+ // Simulate GraphQL resolve info with field name 'Unit'
313
+ const fieldsByTypeName = {
314
+ Reservation: {
315
+ Unit: {
316
+ // GraphQL field name
317
+ fieldsByTypeName: {
318
+ Unit: {
319
+ unitId: {},
320
+ name: {},
321
+ },
322
+ },
323
+ },
324
+ },
325
+ };
326
+
327
+ await resolveExpands(parentItems, parentModel, fieldsByTypeName);
328
+
329
+ // Should map 'Unit' -> 'unitId' and store result under 'Unit'
330
+ expect(parentItems[0].Unit).toBeDefined();
331
+ expect(parentItems[0].Unit.__typename).toBe("Unit");
332
+ });
333
+
334
+ it("should skip non-expandable fields", async () => {
335
+ const fieldsByTypeName = {
336
+ Reservation: {
337
+ reservationId: {}, // Not an expandable field
338
+ Unit: {
339
+ fieldsByTypeName: {
340
+ Unit: {
341
+ unitId: {},
342
+ name: {},
343
+ },
344
+ },
345
+ },
346
+ },
347
+ };
348
+
349
+ await resolveExpands(parentItems, parentModel, fieldsByTypeName);
350
+
351
+ // Only Unit should be expanded
352
+ expect(parentItems[0].Unit).toBeDefined();
353
+ expect(parentItems[0].reservationId).toBe("res-1"); // Original value unchanged
354
+ });
355
+ });
356
+ });