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 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.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": "echo \"Error: no test specified\" && exit 1"
13
+ "test": "vitest",
14
+ "test:ui": "vitest --ui",
15
+ "test:run": "vitest run",
16
+ "coverage": "vitest --coverage"
8
17
  },
9
- "author": "",
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
- "description": ""
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
+ }