dynamo-query-engine 1.0.0 → 1.0.2
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/README.md +434 -0
- package/index.js +31 -0
- package/package.json +44 -4
- package/src/core/expandResolver.js +154 -0
- package/src/core/gridQueryBuilder.js +219 -0
- package/src/core/index.js +11 -0
- package/src/core/modelRegistry.js +87 -0
- package/src/types/jsdoc.js +125 -0
- package/src/utils/cursor.js +65 -0
- package/src/utils/index.js +13 -0
- package/src/utils/validation.js +104 -0
- package/tests/README.md +279 -0
- package/tests/mocks/dynamoose.mock.js +124 -0
- package/tests/unit/cursor.test.js +167 -0
- package/tests/unit/gridQueryBuilder.test.js +474 -0
- package/tests/unit/validation.test.js +350 -0
- package/vitest.config.js +20 -0
package/README.md
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
# DynamoDB Query Engine
|
|
2
|
+
|
|
3
|
+
Type-safe DynamoDB query builder for GraphQL with support for filtering, sorting, pagination, and relation expansion. Built on top of Dynamoose for seamless integration with AWS Lambda and serverless applications.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **DynamoDB-native**: Only keys can be filtered/sorted (following DynamoDB best practices)
|
|
8
|
+
- **Cursor-based pagination**: Efficient pagination with query hash validation
|
|
9
|
+
- **Type-safe**: JSDoc type definitions for excellent IntelliSense support
|
|
10
|
+
- **Expand support**: Declarative relation expansion with configurable limits
|
|
11
|
+
- **Zero external config**: All metadata defined in your Dynamoose schemas
|
|
12
|
+
- **AWS Lambda ready**: Optimized for serverless environments
|
|
13
|
+
- **GraphQL integration**: Works seamlessly with GraphQL resolvers
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install dynamo-query-engine
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Peer Dependencies
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install dynamoose graphql graphql-parse-resolve-info
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick Start
|
|
28
|
+
|
|
29
|
+
### 1. Define Models with Grid Configuration
|
|
30
|
+
|
|
31
|
+
```javascript
|
|
32
|
+
import dynamoose from "dynamoose";
|
|
33
|
+
|
|
34
|
+
const UserSchema = new dynamoose.Schema({
|
|
35
|
+
pk: {
|
|
36
|
+
type: String,
|
|
37
|
+
hashKey: true,
|
|
38
|
+
},
|
|
39
|
+
createdAt: {
|
|
40
|
+
type: String,
|
|
41
|
+
rangeKey: true,
|
|
42
|
+
query: {
|
|
43
|
+
sort: { type: "key" },
|
|
44
|
+
filter: {
|
|
45
|
+
type: "key",
|
|
46
|
+
operators: ["between", "gte", "lte"],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
status: {
|
|
51
|
+
type: String,
|
|
52
|
+
index: {
|
|
53
|
+
name: "status-createdAt-index",
|
|
54
|
+
global: true,
|
|
55
|
+
rangeKey: "createdAt",
|
|
56
|
+
},
|
|
57
|
+
query: {
|
|
58
|
+
filter: {
|
|
59
|
+
type: "key",
|
|
60
|
+
operators: ["eq"],
|
|
61
|
+
index: "status-createdAt-index",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
teamIds: {
|
|
66
|
+
type: Array,
|
|
67
|
+
schema: [String],
|
|
68
|
+
query: {
|
|
69
|
+
expand: {
|
|
70
|
+
type: "Team",
|
|
71
|
+
relation: "MANY",
|
|
72
|
+
defaultLimit: 5,
|
|
73
|
+
maxLimit: 20,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export const UserModel = dynamoose.model("Users", UserSchema);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### 2. Register Models
|
|
83
|
+
|
|
84
|
+
```javascript
|
|
85
|
+
import { modelRegistry } from "dynamo-query-engine";
|
|
86
|
+
import { UserModel } from "./models/user.model.js";
|
|
87
|
+
import { TeamModel } from "./models/team.model.js";
|
|
88
|
+
|
|
89
|
+
modelRegistry.register("User", UserModel);
|
|
90
|
+
modelRegistry.register("Team", TeamModel);
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 3. Use in Lambda Resolver
|
|
94
|
+
|
|
95
|
+
```javascript
|
|
96
|
+
import {
|
|
97
|
+
buildGridQuery,
|
|
98
|
+
resolveExpands,
|
|
99
|
+
encodeCursor
|
|
100
|
+
} from "dynamo-query-engine";
|
|
101
|
+
import { parseResolveInfo } from "graphql-parse-resolve-info";
|
|
102
|
+
|
|
103
|
+
export const handler = async (event) => {
|
|
104
|
+
const args = event.arguments;
|
|
105
|
+
|
|
106
|
+
// Build query
|
|
107
|
+
const { query, queryHash } = buildGridQuery({
|
|
108
|
+
model: UserModel,
|
|
109
|
+
partitionKeyValue: "ORG#123",
|
|
110
|
+
filterModel: args.filterModel,
|
|
111
|
+
sortModel: args.sortModel,
|
|
112
|
+
paginationModel: args.paginationModel,
|
|
113
|
+
cursor: args.cursor,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Execute
|
|
117
|
+
const users = await query.exec();
|
|
118
|
+
|
|
119
|
+
// Resolve relations
|
|
120
|
+
const parsed = parseResolveInfo(event.info);
|
|
121
|
+
await resolveExpands(users, UserModel, parsed?.fieldsByTypeName);
|
|
122
|
+
|
|
123
|
+
// Return with cursor
|
|
124
|
+
return {
|
|
125
|
+
rows: users,
|
|
126
|
+
nextCursor: users.lastKey
|
|
127
|
+
? encodeCursor(users.lastKey, queryHash)
|
|
128
|
+
: null,
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### 4. Query from Client
|
|
134
|
+
|
|
135
|
+
```graphql
|
|
136
|
+
query UsersGrid(
|
|
137
|
+
$paginationModel: PaginationInput!
|
|
138
|
+
$sortModel: [SortInput!]
|
|
139
|
+
$filterModel: FilterModelInput
|
|
140
|
+
$cursor: String
|
|
141
|
+
) {
|
|
142
|
+
usersGrid(
|
|
143
|
+
paginationModel: $paginationModel
|
|
144
|
+
sortModel: $sortModel
|
|
145
|
+
filterModel: $filterModel
|
|
146
|
+
cursor: $cursor
|
|
147
|
+
) {
|
|
148
|
+
rows {
|
|
149
|
+
firstName
|
|
150
|
+
lastName
|
|
151
|
+
email
|
|
152
|
+
teamIds {
|
|
153
|
+
name
|
|
154
|
+
status
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
nextCursor
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
```javascript
|
|
163
|
+
{
|
|
164
|
+
paginationModel: { pageSize: 20 },
|
|
165
|
+
sortModel: [{ field: "createdAt", sort: "desc" }],
|
|
166
|
+
filterModel: {
|
|
167
|
+
items: [
|
|
168
|
+
{ field: "status", operator: "eq", value: "active" }
|
|
169
|
+
]
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Grid Configuration Options
|
|
175
|
+
|
|
176
|
+
### Filter Configuration
|
|
177
|
+
|
|
178
|
+
```javascript
|
|
179
|
+
query: {
|
|
180
|
+
filter: {
|
|
181
|
+
type: "key", // Only "key" supported
|
|
182
|
+
operators: ["eq", "gte", "lte"], // Allowed operators
|
|
183
|
+
index: "status-index" // Optional GSI
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**Supported Operators**: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `between`, `beginsWith`, `contains`
|
|
189
|
+
|
|
190
|
+
### Sort Configuration
|
|
191
|
+
|
|
192
|
+
```javascript
|
|
193
|
+
query: {
|
|
194
|
+
sort: { type: "key" } // Only range keys can be sorted
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
> **Note**: DynamoDB only supports sorting by one field (the range key).
|
|
199
|
+
|
|
200
|
+
### Expand Configuration
|
|
201
|
+
|
|
202
|
+
```javascript
|
|
203
|
+
query: {
|
|
204
|
+
expand: {
|
|
205
|
+
type: "Team", // Model name (must be registered)
|
|
206
|
+
relation: "MANY", // "ONE" or "MANY"
|
|
207
|
+
defaultLimit: 5, // Default fetch limit
|
|
208
|
+
maxLimit: 20 // Maximum allowed limit
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## API Reference
|
|
214
|
+
|
|
215
|
+
### Core Functions
|
|
216
|
+
|
|
217
|
+
#### `buildGridQuery(options)`
|
|
218
|
+
|
|
219
|
+
Builds a DynamoDB query from grid parameters.
|
|
220
|
+
|
|
221
|
+
**Parameters:**
|
|
222
|
+
- `model` - Dynamoose model
|
|
223
|
+
- `partitionKeyValue` - Partition key value
|
|
224
|
+
- `filterModel` - Filter configuration (optional)
|
|
225
|
+
- `sortModel` - Sort configuration (optional)
|
|
226
|
+
- `paginationModel` - Pagination configuration (required)
|
|
227
|
+
- `cursor` - Pagination cursor (optional)
|
|
228
|
+
|
|
229
|
+
**Returns:** `{ query, queryHash }`
|
|
230
|
+
|
|
231
|
+
#### `resolveExpand(options)`
|
|
232
|
+
|
|
233
|
+
Resolves a single relation expansion.
|
|
234
|
+
|
|
235
|
+
**Parameters:**
|
|
236
|
+
- `parentItems` - Array of parent items
|
|
237
|
+
- `parentModel` - Parent Dynamoose model
|
|
238
|
+
- `field` - Field name to expand
|
|
239
|
+
- `args` - Expansion arguments (filter, sort, pagination)
|
|
240
|
+
|
|
241
|
+
#### `resolveExpands(parentItems, parentModel, fieldsByTypeName)`
|
|
242
|
+
|
|
243
|
+
Resolves multiple expansions from GraphQL resolve info.
|
|
244
|
+
|
|
245
|
+
**Parameters:**
|
|
246
|
+
- `parentItems` - Array of parent items
|
|
247
|
+
- `parentModel` - Parent Dynamoose model
|
|
248
|
+
- `fieldsByTypeName` - Parsed GraphQL resolve info fields
|
|
249
|
+
|
|
250
|
+
### Utilities
|
|
251
|
+
|
|
252
|
+
#### `encodeCursor(lastKey, queryHash)`
|
|
253
|
+
|
|
254
|
+
Encodes pagination cursor to Base64.
|
|
255
|
+
|
|
256
|
+
#### `decodeCursor(cursor)`
|
|
257
|
+
|
|
258
|
+
Decodes Base64 cursor.
|
|
259
|
+
|
|
260
|
+
#### `modelRegistry.register(name, model)`
|
|
261
|
+
|
|
262
|
+
Registers a Dynamoose model.
|
|
263
|
+
|
|
264
|
+
#### `modelRegistry.get(name)`
|
|
265
|
+
|
|
266
|
+
Retrieves a registered model.
|
|
267
|
+
|
|
268
|
+
## Architecture
|
|
269
|
+
|
|
270
|
+
```
|
|
271
|
+
┌─────────────┐
|
|
272
|
+
│ GraphQL │
|
|
273
|
+
│ Query │
|
|
274
|
+
└──────┬──────┘
|
|
275
|
+
│
|
|
276
|
+
▼
|
|
277
|
+
┌─────────────┐
|
|
278
|
+
│ Lambda │
|
|
279
|
+
│ Resolver │
|
|
280
|
+
└──────┬──────┘
|
|
281
|
+
│
|
|
282
|
+
├──────► buildGridQuery() ──► DynamoDB
|
|
283
|
+
│ │
|
|
284
|
+
│ ▼
|
|
285
|
+
│ ┌──────┐
|
|
286
|
+
│ │ Items│
|
|
287
|
+
│ └───┬──┘
|
|
288
|
+
│ │
|
|
289
|
+
└──────► resolveExpands() ◄────┘
|
|
290
|
+
│
|
|
291
|
+
├──► ModelRegistry
|
|
292
|
+
│
|
|
293
|
+
└──► buildGridQuery() ──► DynamoDB (nested)
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
## Examples
|
|
297
|
+
|
|
298
|
+
Check out the [examples directory](./examples) for complete working examples:
|
|
299
|
+
|
|
300
|
+
- **[User Model](./examples/models/user.model.js)** - Model with expand configuration
|
|
301
|
+
- **[Team Model](./examples/models/team.model.js)** - Model with filter/sort configuration
|
|
302
|
+
- **[Lambda Resolver](./examples/resolvers/usersGrid.resolver.js)** - Complete resolver implementation
|
|
303
|
+
- **[GraphQL Queries](./examples/queries/graphql.js)** - Query examples and variables
|
|
304
|
+
|
|
305
|
+
See the [examples README](./examples/README.md) for detailed documentation.
|
|
306
|
+
|
|
307
|
+
## Best Practices
|
|
308
|
+
|
|
309
|
+
### 1. Use Global Secondary Indexes for Filters
|
|
310
|
+
|
|
311
|
+
```javascript
|
|
312
|
+
status: {
|
|
313
|
+
type: String,
|
|
314
|
+
index: {
|
|
315
|
+
name: "status-createdAt-index",
|
|
316
|
+
global: true,
|
|
317
|
+
rangeKey: "createdAt",
|
|
318
|
+
},
|
|
319
|
+
query: {
|
|
320
|
+
filter: {
|
|
321
|
+
type: "key",
|
|
322
|
+
operators: ["eq"],
|
|
323
|
+
index: "status-createdAt-index", // Use the GSI
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### 2. Validate User Input
|
|
330
|
+
|
|
331
|
+
```javascript
|
|
332
|
+
if (args.paginationModel.pageSize > 100) {
|
|
333
|
+
throw new Error("Page size cannot exceed 100");
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### 3. Handle Errors Gracefully
|
|
338
|
+
|
|
339
|
+
```javascript
|
|
340
|
+
try {
|
|
341
|
+
const { query } = buildGridQuery({...});
|
|
342
|
+
return await query.exec();
|
|
343
|
+
} catch (error) {
|
|
344
|
+
console.error("Query failed:", error);
|
|
345
|
+
throw new Error(`Failed to fetch data: ${error.message}`);
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### 4. Limit Expand Depth
|
|
350
|
+
|
|
351
|
+
Don't allow deeply nested expansions as they can cause performance issues and high DynamoDB costs.
|
|
352
|
+
|
|
353
|
+
### 5. Monitor DynamoDB Costs
|
|
354
|
+
|
|
355
|
+
Each expand creates additional queries. Use CloudWatch to monitor your read capacity units.
|
|
356
|
+
|
|
357
|
+
## DynamoDB Limitations
|
|
358
|
+
|
|
359
|
+
This library respects DynamoDB's constraints:
|
|
360
|
+
|
|
361
|
+
- **Only key attributes** (hash key, range key, or GSI keys) can be filtered
|
|
362
|
+
- **Only range keys** can be sorted
|
|
363
|
+
- **Only one sort field** is allowed per query
|
|
364
|
+
- **No attribute-level filters** (use scan sparingly for those cases)
|
|
365
|
+
|
|
366
|
+
These limitations ensure efficient queries and predictable performance.
|
|
367
|
+
|
|
368
|
+
## Error Handling
|
|
369
|
+
|
|
370
|
+
The library provides descriptive errors:
|
|
371
|
+
|
|
372
|
+
- `"Filtering not allowed on field 'X'"` - Field doesn't have filter config
|
|
373
|
+
- `"Sorting not allowed on field 'X'"` - Field doesn't have sort config
|
|
374
|
+
- `"Operator 'X' not allowed for field 'Y'"` - Operator not in allowed list
|
|
375
|
+
- `"Only one sort field supported"` - Multiple sort fields specified
|
|
376
|
+
- `"Cursor does not match query"` - Query params changed between pages
|
|
377
|
+
- `"Model 'X' not found in registry"` - Model not registered
|
|
378
|
+
|
|
379
|
+
## Testing
|
|
380
|
+
|
|
381
|
+
The package includes comprehensive unit tests with Vitest.
|
|
382
|
+
|
|
383
|
+
### Run Tests
|
|
384
|
+
|
|
385
|
+
```bash
|
|
386
|
+
# Run all tests
|
|
387
|
+
npm test
|
|
388
|
+
|
|
389
|
+
# Run tests with UI
|
|
390
|
+
npm run test:ui
|
|
391
|
+
|
|
392
|
+
# Run tests once (CI mode)
|
|
393
|
+
npm run test:run
|
|
394
|
+
|
|
395
|
+
# Generate coverage report
|
|
396
|
+
npm run coverage
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
### Test Coverage
|
|
400
|
+
|
|
401
|
+
- **Cursor Utils**: ~80% coverage
|
|
402
|
+
- **Validation**: ~70% coverage
|
|
403
|
+
- **GridQueryBuilder**: ~50% coverage
|
|
404
|
+
- **Overall**: ~50-60% coverage
|
|
405
|
+
|
|
406
|
+
See [tests/README.md](./tests/README.md) for detailed testing documentation.
|
|
407
|
+
|
|
408
|
+
## Contributing
|
|
409
|
+
|
|
410
|
+
Contributions are welcome! Please open an issue or pull request.
|
|
411
|
+
|
|
412
|
+
### Development Setup
|
|
413
|
+
|
|
414
|
+
```bash
|
|
415
|
+
# Install dependencies
|
|
416
|
+
npm install
|
|
417
|
+
|
|
418
|
+
# Run tests
|
|
419
|
+
npm test
|
|
420
|
+
|
|
421
|
+
# Check coverage
|
|
422
|
+
npm run coverage
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
## License
|
|
426
|
+
|
|
427
|
+
MIT © Sascha Eckstein
|
|
428
|
+
|
|
429
|
+
## Links
|
|
430
|
+
|
|
431
|
+
- [GitHub Repository](https://github.com/saschaeckstein/dynamo-query-engine)
|
|
432
|
+
- [Examples](./examples)
|
|
433
|
+
- [Dynamoose Documentation](https://dynamoosejs.com/)
|
|
434
|
+
- [AWS DynamoDB Best Practices](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/best-practices.html)
|
package/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview DynamoDB Query Engine - Main entry point
|
|
3
|
+
* Type-safe DynamoDB query builder for GraphQL with support for
|
|
4
|
+
* filtering, sorting, pagination, and relation expansion
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Core functionality
|
|
8
|
+
export { modelRegistry, ModelRegistry } from "./src/core/modelRegistry.js";
|
|
9
|
+
export {
|
|
10
|
+
buildGridQuery,
|
|
11
|
+
extractGridConfig,
|
|
12
|
+
} from "./src/core/gridQueryBuilder.js";
|
|
13
|
+
export {
|
|
14
|
+
resolveExpand,
|
|
15
|
+
resolveExpands,
|
|
16
|
+
extractExpandPolicy,
|
|
17
|
+
} from "./src/core/expandResolver.js";
|
|
18
|
+
|
|
19
|
+
// Utilities
|
|
20
|
+
export { encodeCursor, decodeCursor, validateCursor } from "./src/utils/cursor.js";
|
|
21
|
+
export {
|
|
22
|
+
validateSingleSort,
|
|
23
|
+
validateFilterField,
|
|
24
|
+
validateSortField,
|
|
25
|
+
validateFilterOperator,
|
|
26
|
+
validateExpandField,
|
|
27
|
+
validatePaginationModel,
|
|
28
|
+
} from "./src/utils/validation.js";
|
|
29
|
+
|
|
30
|
+
// Types (for JSDoc imports)
|
|
31
|
+
export {} from "./src/types/jsdoc.js";
|
package/package.json
CHANGED
|
@@ -1,12 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dynamo-query-engine",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Type-safe DynamoDB query builder for GraphQL with support for filtering, sorting, pagination, and relation expansion",
|
|
4
5
|
"type": "module",
|
|
5
6
|
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./core": "./src/core/index.js",
|
|
10
|
+
"./utils": "./src/utils/index.js"
|
|
11
|
+
},
|
|
6
12
|
"scripts": {
|
|
7
|
-
"test": "
|
|
13
|
+
"test": "vitest",
|
|
14
|
+
"test:ui": "vitest --ui",
|
|
15
|
+
"test:run": "vitest run",
|
|
16
|
+
"coverage": "vitest --coverage"
|
|
8
17
|
},
|
|
9
|
-
"
|
|
18
|
+
"keywords": [
|
|
19
|
+
"dynamodb",
|
|
20
|
+
"graphql",
|
|
21
|
+
"query-builder",
|
|
22
|
+
"dynamoose",
|
|
23
|
+
"pagination",
|
|
24
|
+
"filter",
|
|
25
|
+
"sort",
|
|
26
|
+
"expand",
|
|
27
|
+
"relations",
|
|
28
|
+
"aws",
|
|
29
|
+
"serverless",
|
|
30
|
+
"lambda"
|
|
31
|
+
],
|
|
32
|
+
"author": "Sascha Eckstein",
|
|
10
33
|
"license": "MIT",
|
|
11
|
-
"
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "https://github.com/saschaeckstein/dynamo-query-engine"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"dynamoose": "^4.0.0",
|
|
40
|
+
"graphql": "^16.0.0"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"graphql-parse-resolve-info": "^4.13.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@vitest/ui": "^2.1.8",
|
|
47
|
+
"vitest": "^2.1.8"
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=18.0.0"
|
|
51
|
+
}
|
|
12
52
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Expand resolver for handling relations/expansions
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import "../types/jsdoc.js";
|
|
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";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Extract expand policy from schema
|
|
13
|
+
* @param {*} schema - Dynamoose schema
|
|
14
|
+
* @returns {Object.<string, import("../types/jsdoc.js").ExpandConfig>} Expand policies by field name
|
|
15
|
+
*/
|
|
16
|
+
export function extractExpandPolicy(schema) {
|
|
17
|
+
const gridConfig = extractGridConfig(schema);
|
|
18
|
+
const expandPolicy = {};
|
|
19
|
+
|
|
20
|
+
for (const [field, config] of Object.entries(gridConfig)) {
|
|
21
|
+
if (config.expand) {
|
|
22
|
+
expandPolicy[field] = config.expand;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return expandPolicy;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve expand/relations for a field
|
|
31
|
+
* @param {ResolveExpandOptions} options - Resolve expand options
|
|
32
|
+
* @returns {Promise<void>}
|
|
33
|
+
*/
|
|
34
|
+
export async function resolveExpand({
|
|
35
|
+
parentItems,
|
|
36
|
+
parentModel,
|
|
37
|
+
field,
|
|
38
|
+
args = {},
|
|
39
|
+
}) {
|
|
40
|
+
if (!parentItems || parentItems.length === 0) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!parentModel) {
|
|
45
|
+
throw new Error("parentModel is required");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!field) {
|
|
49
|
+
throw new Error("field is required");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Extract expand policy from parent schema
|
|
53
|
+
const expandPolicy = extractExpandPolicy(parentModel.schema);
|
|
54
|
+
const policy = expandPolicy[field];
|
|
55
|
+
|
|
56
|
+
if (!policy) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Field '${field}' does not have expand configuration in parent model`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Get target model from registry
|
|
63
|
+
const targetModel = modelRegistry.get(policy.type);
|
|
64
|
+
|
|
65
|
+
// Determine limit based on args and policy
|
|
66
|
+
const requestedLimit = args.pagination?.pageSize ?? policy.defaultLimit;
|
|
67
|
+
const limit = Math.min(requestedLimit, policy.maxLimit);
|
|
68
|
+
|
|
69
|
+
// Resolve relations for each parent item
|
|
70
|
+
const expandPromises = parentItems.map(async (parent) => {
|
|
71
|
+
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
|
|
74
|
+
const partitionKeyValue = parent.pk || parent[Object.keys(parent)[0]];
|
|
75
|
+
|
|
76
|
+
if (!partitionKeyValue) {
|
|
77
|
+
console.warn(
|
|
78
|
+
`Cannot expand '${field}' for parent: missing partition key value`
|
|
79
|
+
);
|
|
80
|
+
parent[field] = [];
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
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
|
+
});
|
|
93
|
+
|
|
94
|
+
// Execute query
|
|
95
|
+
const results = await query.exec();
|
|
96
|
+
|
|
97
|
+
// Assign results to parent
|
|
98
|
+
if (policy.relation === "ONE") {
|
|
99
|
+
parent[field] = results.length > 0 ? results[0] : null;
|
|
100
|
+
} else {
|
|
101
|
+
parent[field] = results;
|
|
102
|
+
}
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.error(`Error expanding '${field}' for parent:`, error);
|
|
105
|
+
// Gracefully handle errors - set empty result
|
|
106
|
+
parent[field] = policy.relation === "ONE" ? null : [];
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Execute all expands in parallel for better performance
|
|
111
|
+
await Promise.all(expandPromises);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve multiple expands from GraphQL resolve info
|
|
116
|
+
* @param {Array} parentItems - Parent items to expand
|
|
117
|
+
* @param {*} parentModel - Parent Dynamoose model
|
|
118
|
+
* @param {Object} fieldsByTypeName - Fields by type name from graphql-parse-resolve-info
|
|
119
|
+
* @returns {Promise<void>}
|
|
120
|
+
*/
|
|
121
|
+
export async function resolveExpands(
|
|
122
|
+
parentItems,
|
|
123
|
+
parentModel,
|
|
124
|
+
fieldsByTypeName
|
|
125
|
+
) {
|
|
126
|
+
if (!fieldsByTypeName || Object.keys(fieldsByTypeName).length === 0) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Get fields for the parent type
|
|
131
|
+
const typeName = Object.keys(fieldsByTypeName)[0];
|
|
132
|
+
const fields = fieldsByTypeName[typeName];
|
|
133
|
+
|
|
134
|
+
if (!fields) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Get expand policy to filter only expandable fields
|
|
139
|
+
const expandPolicy = extractExpandPolicy(parentModel.schema);
|
|
140
|
+
|
|
141
|
+
// Resolve all expands in parallel
|
|
142
|
+
const expandPromises = Object.entries(fields)
|
|
143
|
+
.filter(([fieldName]) => expandPolicy[fieldName])
|
|
144
|
+
.map(([fieldName, fieldInfo]) =>
|
|
145
|
+
resolveExpand({
|
|
146
|
+
parentItems,
|
|
147
|
+
parentModel,
|
|
148
|
+
field: fieldName,
|
|
149
|
+
args: fieldInfo.args || {},
|
|
150
|
+
})
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
await Promise.all(expandPromises);
|
|
154
|
+
}
|