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 +1 -1
- package/src/core/expandResolver.js +58 -13
- package/tests/unit/expandResolver.test.js +339 -0
package/package.json
CHANGED
|
@@ -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[
|
|
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[
|
|
112
|
+
parent[expandPropertyName] = resultsWithTypename.length > 0 ? resultsWithTypename[0] : null;
|
|
100
113
|
} else {
|
|
101
|
-
parent[
|
|
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[
|
|
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
|
-
.
|
|
144
|
-
|
|
145
|
-
|
|
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:
|
|
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
|
+
});
|