dynamo-query-engine 1.0.0 → 1.0.1
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 +392 -0
- package/index.js +31 -0
- package/package.json +36 -3
- 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/README.md
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
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
|
+
grid: {
|
|
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
|
+
grid: {
|
|
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
|
+
grid: {
|
|
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
|
+
grid: {
|
|
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
|
+
grid: {
|
|
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
|
+
grid: {
|
|
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
|
+
grid: {
|
|
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
|
+
## Contributing
|
|
380
|
+
|
|
381
|
+
Contributions are welcome! Please open an issue or pull request.
|
|
382
|
+
|
|
383
|
+
## License
|
|
384
|
+
|
|
385
|
+
MIT © Sascha Eckstein
|
|
386
|
+
|
|
387
|
+
## Links
|
|
388
|
+
|
|
389
|
+
- [GitHub Repository](https://github.com/saschaeckstein/dynamo-query-engine)
|
|
390
|
+
- [Examples](./examples)
|
|
391
|
+
- [Dynamoose Documentation](https://dynamoosejs.com/)
|
|
392
|
+
- [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,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dynamo-query-engine",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
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
13
|
"test": "echo \"Error: no test specified\" && exit 1"
|
|
8
14
|
},
|
|
9
|
-
"
|
|
15
|
+
"keywords": [
|
|
16
|
+
"dynamodb",
|
|
17
|
+
"graphql",
|
|
18
|
+
"query-builder",
|
|
19
|
+
"dynamoose",
|
|
20
|
+
"pagination",
|
|
21
|
+
"filter",
|
|
22
|
+
"sort",
|
|
23
|
+
"expand",
|
|
24
|
+
"relations",
|
|
25
|
+
"aws",
|
|
26
|
+
"serverless",
|
|
27
|
+
"lambda"
|
|
28
|
+
],
|
|
29
|
+
"author": "Sascha Eckstein",
|
|
10
30
|
"license": "MIT",
|
|
11
|
-
"
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/saschaeckstein/dynamo-query-engine"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"dynamoose": "^4.0.0",
|
|
37
|
+
"graphql": "^16.0.0"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"graphql-parse-resolve-info": "^4.13.0"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=18.0.0"
|
|
44
|
+
}
|
|
12
45
|
}
|
|
@@ -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, 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
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Grid query builder for DynamoDB queries with filtering, sorting, and pagination
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import crypto from "crypto";
|
|
6
|
+
import "../types/jsdoc.js";
|
|
7
|
+
import {
|
|
8
|
+
validateSingleSort,
|
|
9
|
+
validateFilterField,
|
|
10
|
+
validateSortField,
|
|
11
|
+
validateFilterOperator,
|
|
12
|
+
validatePaginationModel,
|
|
13
|
+
} from "../utils/validation.js";
|
|
14
|
+
import { decodeCursor, validateCursor } from "../utils/cursor.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Generate a hash for query parameters to validate cursor consistency
|
|
18
|
+
* @param {Object} params - Query parameters
|
|
19
|
+
* @param {FilterModel} params.filterModel - Filter model
|
|
20
|
+
* @param {SortModel} params.sortModel - Sort model
|
|
21
|
+
* @param {number} params.pageSize - Page size
|
|
22
|
+
* @returns {string} SHA256 hash of the query parameters
|
|
23
|
+
*/
|
|
24
|
+
function hashQuery({ filterModel, sortModel, pageSize }) {
|
|
25
|
+
return crypto
|
|
26
|
+
.createHash("sha256")
|
|
27
|
+
.update(JSON.stringify({ filterModel, sortModel, pageSize }))
|
|
28
|
+
.digest("hex");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Extract grid configuration from Dynamoose schema
|
|
33
|
+
* @param {*} schema - Dynamoose schema
|
|
34
|
+
* @returns {ExtractedGridConfig} Extracted grid configuration
|
|
35
|
+
*/
|
|
36
|
+
export function extractGridConfig(schema) {
|
|
37
|
+
const attributes = schema.getAttributes();
|
|
38
|
+
const gridConfig = {};
|
|
39
|
+
|
|
40
|
+
for (const [field, definition] of Object.entries(attributes)) {
|
|
41
|
+
if (definition.grid) {
|
|
42
|
+
gridConfig[field] = definition.grid;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return gridConfig;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Determine which GSI to use based on filter model
|
|
51
|
+
* @param {FilterModel} filterModel - Filter model
|
|
52
|
+
* @param {ExtractedGridConfig} gridConfig - Grid configuration
|
|
53
|
+
* @returns {string|null} GSI name or null
|
|
54
|
+
*/
|
|
55
|
+
function determineIndex(filterModel, gridConfig) {
|
|
56
|
+
if (!filterModel || !filterModel.items || filterModel.items.length === 0) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Find the first filter with an index
|
|
61
|
+
for (const filterItem of filterModel.items) {
|
|
62
|
+
const fieldConfig = gridConfig[filterItem.field]?.filter;
|
|
63
|
+
if (fieldConfig && fieldConfig.index) {
|
|
64
|
+
return fieldConfig.index;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Apply filters to the query
|
|
73
|
+
* @param {*} query - Dynamoose query object
|
|
74
|
+
* @param {FilterModel} filterModel - Filter model
|
|
75
|
+
* @param {ExtractedGridConfig} gridConfig - Grid configuration
|
|
76
|
+
* @returns {*} Updated query object
|
|
77
|
+
*/
|
|
78
|
+
function applyFilters(query, filterModel, gridConfig) {
|
|
79
|
+
if (!filterModel || !filterModel.items || filterModel.items.length === 0) {
|
|
80
|
+
return query;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const filterItem of filterModel.items) {
|
|
84
|
+
validateFilterField(filterItem.field, gridConfig);
|
|
85
|
+
validateFilterOperator(filterItem.field, filterItem.operator, gridConfig);
|
|
86
|
+
|
|
87
|
+
// Apply the filter using Dynamoose query syntax
|
|
88
|
+
query = query.where(filterItem.field);
|
|
89
|
+
|
|
90
|
+
// Map operators to Dynamoose methods
|
|
91
|
+
switch (filterItem.operator) {
|
|
92
|
+
case "eq":
|
|
93
|
+
query = query.eq(filterItem.value);
|
|
94
|
+
break;
|
|
95
|
+
case "ne":
|
|
96
|
+
query = query.ne(filterItem.value);
|
|
97
|
+
break;
|
|
98
|
+
case "gt":
|
|
99
|
+
query = query.gt(filterItem.value);
|
|
100
|
+
break;
|
|
101
|
+
case "gte":
|
|
102
|
+
query = query.ge(filterItem.value);
|
|
103
|
+
break;
|
|
104
|
+
case "lt":
|
|
105
|
+
query = query.lt(filterItem.value);
|
|
106
|
+
break;
|
|
107
|
+
case "lte":
|
|
108
|
+
query = query.le(filterItem.value);
|
|
109
|
+
break;
|
|
110
|
+
case "between":
|
|
111
|
+
if (!Array.isArray(filterItem.value) || filterItem.value.length !== 2) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`'between' operator requires an array of two values for field '${filterItem.field}'`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
query = query.between(filterItem.value[0], filterItem.value[1]);
|
|
117
|
+
break;
|
|
118
|
+
case "beginsWith":
|
|
119
|
+
query = query.beginsWith(filterItem.value);
|
|
120
|
+
break;
|
|
121
|
+
case "contains":
|
|
122
|
+
query = query.contains(filterItem.value);
|
|
123
|
+
break;
|
|
124
|
+
default:
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Unsupported operator '${filterItem.operator}' for field '${filterItem.field}'`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return query;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Apply sorting to the query
|
|
136
|
+
* @param {*} query - Dynamoose query object
|
|
137
|
+
* @param {SortModel} sortModel - Sort model
|
|
138
|
+
* @param {ExtractedGridConfig} gridConfig - Grid configuration
|
|
139
|
+
* @returns {*} Updated query object
|
|
140
|
+
*/
|
|
141
|
+
function applySort(query, sortModel, gridConfig) {
|
|
142
|
+
if (!sortModel || sortModel.length === 0) {
|
|
143
|
+
return query;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const sortItem = sortModel[0];
|
|
147
|
+
validateSortField(sortItem.field, gridConfig);
|
|
148
|
+
|
|
149
|
+
// Apply descending sort if specified
|
|
150
|
+
if (sortItem.sort === "desc") {
|
|
151
|
+
query = query.sort("descending");
|
|
152
|
+
}
|
|
153
|
+
// Default is ascending, no need to explicitly set
|
|
154
|
+
|
|
155
|
+
return query;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Build a DynamoDB query from grid parameters
|
|
160
|
+
* @param {BuildGridQueryOptions} options - Query build options
|
|
161
|
+
* @returns {QueryBuildResult} Query build result with query and hash
|
|
162
|
+
*/
|
|
163
|
+
export function buildGridQuery({
|
|
164
|
+
model,
|
|
165
|
+
partitionKeyValue,
|
|
166
|
+
filterModel,
|
|
167
|
+
sortModel,
|
|
168
|
+
paginationModel,
|
|
169
|
+
cursor,
|
|
170
|
+
}) {
|
|
171
|
+
// Validate inputs
|
|
172
|
+
if (!model) {
|
|
173
|
+
throw new Error("model is required");
|
|
174
|
+
}
|
|
175
|
+
if (!partitionKeyValue) {
|
|
176
|
+
throw new Error("partitionKeyValue is required");
|
|
177
|
+
}
|
|
178
|
+
validatePaginationModel(paginationModel);
|
|
179
|
+
validateSingleSort(sortModel);
|
|
180
|
+
|
|
181
|
+
// Extract grid config from schema
|
|
182
|
+
const gridConfig = extractGridConfig(model.schema);
|
|
183
|
+
|
|
184
|
+
// Determine which index to use
|
|
185
|
+
const index = determineIndex(filterModel, gridConfig);
|
|
186
|
+
|
|
187
|
+
// Generate query hash for cursor validation
|
|
188
|
+
const queryHash = hashQuery({
|
|
189
|
+
filterModel,
|
|
190
|
+
sortModel,
|
|
191
|
+
pageSize: paginationModel.pageSize,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Initialize query with partition key
|
|
195
|
+
let query = model.query("pk").eq(partitionKeyValue);
|
|
196
|
+
|
|
197
|
+
// Use GSI if needed
|
|
198
|
+
if (index) {
|
|
199
|
+
query = query.using(index);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Apply filters
|
|
203
|
+
query = applyFilters(query, filterModel, gridConfig);
|
|
204
|
+
|
|
205
|
+
// Apply sort
|
|
206
|
+
query = applySort(query, sortModel, gridConfig);
|
|
207
|
+
|
|
208
|
+
// Apply pagination limit
|
|
209
|
+
query = query.limit(paginationModel.pageSize);
|
|
210
|
+
|
|
211
|
+
// Handle cursor for pagination
|
|
212
|
+
if (cursor) {
|
|
213
|
+
const decoded = decodeCursor(cursor);
|
|
214
|
+
validateCursor(decoded.queryHash, queryHash);
|
|
215
|
+
query = query.startAt(decoded.lastKey);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { query, queryHash };
|
|
219
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Core module exports
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { modelRegistry, ModelRegistry } from "./modelRegistry.js";
|
|
6
|
+
export { buildGridQuery, extractGridConfig } from "./gridQueryBuilder.js";
|
|
7
|
+
export {
|
|
8
|
+
resolveExpand,
|
|
9
|
+
resolveExpands,
|
|
10
|
+
extractExpandPolicy,
|
|
11
|
+
} from "./expandResolver.js";
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Model registry for managing Dynamoose models
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Singleton registry for Dynamoose models
|
|
7
|
+
*/
|
|
8
|
+
class ModelRegistry {
|
|
9
|
+
constructor() {
|
|
10
|
+
/** @type {Map<string, *>} */
|
|
11
|
+
this.models = new Map();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Register a model in the registry
|
|
16
|
+
* @param {string} name - Model name (should match the type in expand config)
|
|
17
|
+
* @param {*} model - Dynamoose model instance
|
|
18
|
+
* @throws {Error} If model name or model is invalid
|
|
19
|
+
*/
|
|
20
|
+
register(name, model) {
|
|
21
|
+
if (!name || typeof name !== "string") {
|
|
22
|
+
throw new Error("Model name must be a non-empty string");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!model) {
|
|
26
|
+
throw new Error("Model is required");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!model.schema) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
"Invalid model: must be a Dynamoose model with a schema property"
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (this.models.has(name)) {
|
|
36
|
+
throw new Error(`Model '${name}' is already registered`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
this.models.set(name, model);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get a model from the registry
|
|
44
|
+
* @param {string} name - Model name
|
|
45
|
+
* @returns {*} Dynamoose model instance
|
|
46
|
+
* @throws {Error} If model is not found
|
|
47
|
+
*/
|
|
48
|
+
get(name) {
|
|
49
|
+
if (!this.models.has(name)) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Model '${name}' not found in registry. Did you forget to register it?`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return this.models.get(name);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if a model is registered
|
|
60
|
+
* @param {string} name - Model name
|
|
61
|
+
* @returns {boolean} True if model is registered
|
|
62
|
+
*/
|
|
63
|
+
has(name) {
|
|
64
|
+
return this.models.has(name);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get all registered model names
|
|
69
|
+
* @returns {string[]} Array of model names
|
|
70
|
+
*/
|
|
71
|
+
getRegisteredModels() {
|
|
72
|
+
return Array.from(this.models.keys());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Clear all registered models (useful for testing)
|
|
77
|
+
*/
|
|
78
|
+
clear() {
|
|
79
|
+
this.models.clear();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Export singleton instance
|
|
84
|
+
export const modelRegistry = new ModelRegistry();
|
|
85
|
+
|
|
86
|
+
// Also export the class for custom registries if needed
|
|
87
|
+
export { ModelRegistry };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Type definitions for DynamoDB Query Engine
|
|
3
|
+
* These JSDoc types provide IntelliSense support throughout the library
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Filter configuration for a field
|
|
8
|
+
* @typedef {Object} FilterConfig
|
|
9
|
+
* @property {"key"|"attribute"} type - Filter type (only "key" is supported for DynamoDB queries)
|
|
10
|
+
* @property {string[]} operators - Allowed comparison operators (e.g., ["eq", "between", "gte", "lte"])
|
|
11
|
+
* @property {string} [index] - Optional GSI name to use for this filter
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Sort configuration for a field
|
|
16
|
+
* @typedef {Object} SortConfig
|
|
17
|
+
* @property {"key"} type - Sort type (only "key" is supported for DynamoDB)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Expand/relation configuration for a field
|
|
22
|
+
* @typedef {Object} ExpandConfig
|
|
23
|
+
* @property {string} type - Target model name to expand (must be registered in ModelRegistry)
|
|
24
|
+
* @property {"ONE"|"MANY"} relation - Relation type
|
|
25
|
+
* @property {number} defaultLimit - Default number of items to fetch
|
|
26
|
+
* @property {number} maxLimit - Maximum allowed items to fetch
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Grid configuration for a field
|
|
31
|
+
* @typedef {Object} GridConfig
|
|
32
|
+
* @property {FilterConfig} [filter] - Filter configuration
|
|
33
|
+
* @property {SortConfig} [sort] - Sort configuration
|
|
34
|
+
* @property {ExpandConfig} [expand] - Expand/relation configuration
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Complete grid configuration extracted from schema
|
|
39
|
+
* @typedef {Object.<string, GridConfig>} ExtractedGridConfig
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Single filter item
|
|
44
|
+
* @typedef {Object} FilterItem
|
|
45
|
+
* @property {string} field - Field name to filter on
|
|
46
|
+
* @property {string} operator - Comparison operator (eq, ne, gt, gte, lt, lte, between, contains, etc.)
|
|
47
|
+
* @property {*} value - Filter value
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Filter model following AG Grid format
|
|
52
|
+
* @typedef {Object} FilterModel
|
|
53
|
+
* @property {"AND"|"OR"} [logicOperator] - Logic operator for combining filters
|
|
54
|
+
* @property {FilterItem[]} items - Array of filter items
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Single sort item
|
|
59
|
+
* @typedef {Object} SortItem
|
|
60
|
+
* @property {string} field - Field name to sort by
|
|
61
|
+
* @property {"asc"|"desc"} sort - Sort direction
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Sort model (array of sort items)
|
|
66
|
+
* @typedef {SortItem[]} SortModel
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Pagination model
|
|
71
|
+
* @typedef {Object} PaginationModel
|
|
72
|
+
* @property {number} pageSize - Number of items per page
|
|
73
|
+
* @property {number} [page] - Current page number (not used with cursor pagination)
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Decoded cursor data
|
|
78
|
+
* @typedef {Object} CursorData
|
|
79
|
+
* @property {Object} lastKey - DynamoDB lastKey for pagination
|
|
80
|
+
* @property {string} queryHash - Hash of the query parameters for validation
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Query build result
|
|
85
|
+
* @typedef {Object} QueryBuildResult
|
|
86
|
+
* @property {*} query - Dynamoose query object
|
|
87
|
+
* @property {string} queryHash - Hash of the query parameters
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Expand arguments from GraphQL
|
|
92
|
+
* @typedef {Object} ExpandArgs
|
|
93
|
+
* @property {FilterModel} [filter] - Filter model for expanded items
|
|
94
|
+
* @property {SortModel} [sort] - Sort model for expanded items
|
|
95
|
+
* @property {PaginationModel} [pagination] - Pagination for expanded items
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Query result with pagination
|
|
100
|
+
* @typedef {Object} QueryResult
|
|
101
|
+
* @property {Array} rows - Query result items
|
|
102
|
+
* @property {string|null} nextCursor - Base64 encoded cursor for next page
|
|
103
|
+
*/
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build grid query options
|
|
107
|
+
* @typedef {Object} BuildGridQueryOptions
|
|
108
|
+
* @property {*} model - Dynamoose model
|
|
109
|
+
* @property {string} partitionKeyValue - Value for the partition key
|
|
110
|
+
* @property {FilterModel} [filterModel] - Filter model
|
|
111
|
+
* @property {SortModel} [sortModel] - Sort model
|
|
112
|
+
* @property {PaginationModel} paginationModel - Pagination model
|
|
113
|
+
* @property {string} [cursor] - Cursor for pagination
|
|
114
|
+
*/
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Resolve expand options
|
|
118
|
+
* @typedef {Object} ResolveExpandOptions
|
|
119
|
+
* @property {Array} parentItems - Parent items to expand relations for
|
|
120
|
+
* @property {*} parentModel - Dynamoose parent model
|
|
121
|
+
* @property {string} field - Field name to expand
|
|
122
|
+
* @property {ExpandArgs} [args] - Expand arguments from GraphQL
|
|
123
|
+
*/
|
|
124
|
+
|
|
125
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Cursor encoding/decoding utilities for pagination
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import "../types/jsdoc.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Encodes cursor data to Base64 string
|
|
9
|
+
* @param {Object} lastKey - DynamoDB lastKey object
|
|
10
|
+
* @param {string} queryHash - Hash of the query parameters
|
|
11
|
+
* @returns {string} Base64 encoded cursor
|
|
12
|
+
*/
|
|
13
|
+
export function encodeCursor(lastKey, queryHash) {
|
|
14
|
+
if (!lastKey) {
|
|
15
|
+
throw new Error("lastKey is required to encode cursor");
|
|
16
|
+
}
|
|
17
|
+
if (!queryHash) {
|
|
18
|
+
throw new Error("queryHash is required to encode cursor");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const cursorData = {
|
|
22
|
+
lastKey,
|
|
23
|
+
queryHash,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return Buffer.from(JSON.stringify(cursorData)).toString("base64");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Decodes Base64 cursor string to cursor data
|
|
31
|
+
* @param {string} cursor - Base64 encoded cursor
|
|
32
|
+
* @returns {CursorData} Decoded cursor data
|
|
33
|
+
*/
|
|
34
|
+
export function decodeCursor(cursor) {
|
|
35
|
+
if (!cursor || typeof cursor !== "string") {
|
|
36
|
+
throw new Error("Invalid cursor: must be a non-empty string");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const decoded = Buffer.from(cursor, "base64").toString("utf-8");
|
|
41
|
+
const cursorData = JSON.parse(decoded);
|
|
42
|
+
|
|
43
|
+
if (!cursorData.lastKey || !cursorData.queryHash) {
|
|
44
|
+
throw new Error("Invalid cursor structure");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return cursorData;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
throw new Error(`Failed to decode cursor: ${error.message}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Validates that cursor matches the current query
|
|
55
|
+
* @param {string} decodedQueryHash - Query hash from decoded cursor
|
|
56
|
+
* @param {string} currentQueryHash - Hash of current query
|
|
57
|
+
* @throws {Error} If cursor does not match query
|
|
58
|
+
*/
|
|
59
|
+
export function validateCursor(decodedQueryHash, currentQueryHash) {
|
|
60
|
+
if (decodedQueryHash !== currentQueryHash) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
"Cursor does not match current query. Filters, sort, or page size have changed."
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Utilities module exports
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { encodeCursor, decodeCursor, validateCursor } from "./cursor.js";
|
|
6
|
+
export {
|
|
7
|
+
validateSingleSort,
|
|
8
|
+
validateFilterField,
|
|
9
|
+
validateSortField,
|
|
10
|
+
validateFilterOperator,
|
|
11
|
+
validateExpandField,
|
|
12
|
+
validatePaginationModel,
|
|
13
|
+
} from "./validation.js";
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Validation utilities for grid configurations
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import "../types/jsdoc.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Validates that only one sort field is specified
|
|
9
|
+
* @param {SortModel} sortModel - Sort model to validate
|
|
10
|
+
* @throws {Error} If more than one sort field is specified
|
|
11
|
+
*/
|
|
12
|
+
export function validateSingleSort(sortModel) {
|
|
13
|
+
if (sortModel && sortModel.length > 1) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
"DynamoDB only supports sorting by one field (range key). Multiple sort fields are not allowed."
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validates that a field can be filtered based on grid config
|
|
22
|
+
* @param {string} field - Field name to filter
|
|
23
|
+
* @param {ExtractedGridConfig} gridConfig - Extracted grid configuration
|
|
24
|
+
* @throws {Error} If filtering is not allowed on this field
|
|
25
|
+
*/
|
|
26
|
+
export function validateFilterField(field, gridConfig) {
|
|
27
|
+
const config = gridConfig[field];
|
|
28
|
+
if (!config || !config.filter) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Filtering not allowed on field '${field}'. Only fields with grid.filter config can be filtered.`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validates that a field can be sorted based on grid config
|
|
37
|
+
* @param {string} field - Field name to sort
|
|
38
|
+
* @param {ExtractedGridConfig} gridConfig - Extracted grid configuration
|
|
39
|
+
* @throws {Error} If sorting is not allowed on this field
|
|
40
|
+
*/
|
|
41
|
+
export function validateSortField(field, gridConfig) {
|
|
42
|
+
const config = gridConfig[field];
|
|
43
|
+
if (!config || !config.sort) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Sorting not allowed on field '${field}'. Only fields with grid.sort config can be sorted.`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Validates that operator is allowed for a filter field
|
|
52
|
+
* @param {string} field - Field name
|
|
53
|
+
* @param {string} operator - Operator to validate
|
|
54
|
+
* @param {ExtractedGridConfig} gridConfig - Extracted grid configuration
|
|
55
|
+
* @throws {Error} If operator is not allowed for this field
|
|
56
|
+
*/
|
|
57
|
+
export function validateFilterOperator(field, operator, gridConfig) {
|
|
58
|
+
const config = gridConfig[field]?.filter;
|
|
59
|
+
if (!config) {
|
|
60
|
+
throw new Error(`Field '${field}' does not have filter configuration`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (config.operators && !config.operators.includes(operator)) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Operator '${operator}' not allowed for field '${field}'. Allowed operators: ${config.operators.join(", ")}`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Validates that a field can be expanded based on grid config
|
|
72
|
+
* @param {string} field - Field name to expand
|
|
73
|
+
* @param {ExtractedGridConfig} gridConfig - Extracted grid configuration
|
|
74
|
+
* @throws {Error} If expand is not allowed on this field
|
|
75
|
+
*/
|
|
76
|
+
export function validateExpandField(field, gridConfig) {
|
|
77
|
+
const config = gridConfig[field];
|
|
78
|
+
if (!config || !config.expand) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Expand not allowed on field '${field}'. Only fields with grid.expand config can be expanded.`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Validates pagination model
|
|
87
|
+
* @param {PaginationModel} paginationModel - Pagination model to validate
|
|
88
|
+
* @throws {Error} If pagination model is invalid
|
|
89
|
+
*/
|
|
90
|
+
export function validatePaginationModel(paginationModel) {
|
|
91
|
+
if (!paginationModel) {
|
|
92
|
+
throw new Error("paginationModel is required");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!paginationModel.pageSize || paginationModel.pageSize < 1) {
|
|
96
|
+
throw new Error("paginationModel.pageSize must be a positive number");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (paginationModel.pageSize > 1000) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
"paginationModel.pageSize cannot exceed 1000 (DynamoDB limit)"
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|