dynamo-query-engine 1.0.5 → 1.0.6

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