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.
@@ -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 {Object.<string, import("../types/jsdoc.js").GridConfig>} 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.query) {
42
+ gridConfig[field] = definition.query;
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 {Object.<string, import("../types/jsdoc.js").GridConfig>} 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 {Object.<string, import("../types/jsdoc.js").GridConfig>} 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 {Object.<string, import("../types/jsdoc.js").GridConfig>} 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 {Object.<string, import("../types/jsdoc.js").GridConfig>} 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
+ }