dynamo-query-engine 1.0.2 → 1.0.4
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 +49 -0
- package/index.js +2 -0
- package/package.json +1 -1
- package/src/core/gridQueryBuilder.js +38 -1
- package/src/core/index.js +1 -1
- package/src/utils/validation.js +46 -3
- package/tests/unit/gridQueryBuilder.test.js +145 -0
- package/tests/unit/validation.test.js +61 -0
package/README.md
CHANGED
|
@@ -28,6 +28,8 @@ npm install dynamoose graphql graphql-parse-resolve-info
|
|
|
28
28
|
|
|
29
29
|
### 1. Define Models with Grid Configuration
|
|
30
30
|
|
|
31
|
+
> **Note**: The library supports any hash key field name (e.g., `pk`, `tenantId`, `userId`, etc.). It automatically detects the hash key from your schema.
|
|
32
|
+
|
|
31
33
|
```javascript
|
|
32
34
|
import dynamoose from "dynamoose";
|
|
33
35
|
|
|
@@ -79,6 +81,39 @@ const UserSchema = new dynamoose.Schema({
|
|
|
79
81
|
export const UserModel = dynamoose.model("Users", UserSchema);
|
|
80
82
|
```
|
|
81
83
|
|
|
84
|
+
**Example with custom hash key name:**
|
|
85
|
+
|
|
86
|
+
```javascript
|
|
87
|
+
// The library works with any hash key field name
|
|
88
|
+
const ReservationSchema = new dynamoose.Schema({
|
|
89
|
+
tenantId: {
|
|
90
|
+
type: String,
|
|
91
|
+
hashKey: true, // Automatically detected by getHashKey()
|
|
92
|
+
},
|
|
93
|
+
reservationId: {
|
|
94
|
+
type: String,
|
|
95
|
+
rangeKey: true, // Automatically detected by getRangeKey()
|
|
96
|
+
},
|
|
97
|
+
// ... other fields
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
export const ReservationModel = dynamoose.model("Reservations", ReservationSchema);
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**Extracting keys programmatically:**
|
|
104
|
+
|
|
105
|
+
```javascript
|
|
106
|
+
import { getHashKey, getRangeKey } from "dynamo-query-engine";
|
|
107
|
+
|
|
108
|
+
// Get the hash key field name from your schema
|
|
109
|
+
const hashKeyField = getHashKey(ReservationModel.schema);
|
|
110
|
+
console.log(hashKeyField); // "tenantId"
|
|
111
|
+
|
|
112
|
+
// Get the range key field name (or null if none exists)
|
|
113
|
+
const rangeKeyField = getRangeKey(ReservationModel.schema);
|
|
114
|
+
console.log(rangeKeyField); // "reservationId"
|
|
115
|
+
```
|
|
116
|
+
|
|
82
117
|
### 2. Register Models
|
|
83
118
|
|
|
84
119
|
```javascript
|
|
@@ -265,6 +300,20 @@ Registers a Dynamoose model.
|
|
|
265
300
|
|
|
266
301
|
Retrieves a registered model.
|
|
267
302
|
|
|
303
|
+
#### `getHashKey(schema)`
|
|
304
|
+
|
|
305
|
+
Extracts the hash key (partition key) field name from a Dynamoose schema.
|
|
306
|
+
|
|
307
|
+
**Returns:** `string` - The hash key field name
|
|
308
|
+
|
|
309
|
+
**Throws:** Error if no hash key is found in the schema
|
|
310
|
+
|
|
311
|
+
#### `getRangeKey(schema)`
|
|
312
|
+
|
|
313
|
+
Extracts the range key (sort key) field name from a Dynamoose schema.
|
|
314
|
+
|
|
315
|
+
**Returns:** `string | null` - The range key field name, or null if no range key exists
|
|
316
|
+
|
|
268
317
|
## Architecture
|
|
269
318
|
|
|
270
319
|
```
|
package/index.js
CHANGED
package/package.json
CHANGED
|
@@ -46,6 +46,40 @@ export function extractGridConfig(schema) {
|
|
|
46
46
|
return gridConfig;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Get the hash key (partition key) field name from Dynamoose schema
|
|
51
|
+
* @param {*} schema - Dynamoose schema
|
|
52
|
+
* @returns {string} Hash key field name
|
|
53
|
+
*/
|
|
54
|
+
export function getHashKey(schema) {
|
|
55
|
+
const attributes = schema.getAttributes();
|
|
56
|
+
|
|
57
|
+
for (const [field, definition] of Object.entries(attributes)) {
|
|
58
|
+
if (definition.hashKey === true) {
|
|
59
|
+
return field;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
throw new Error("No hash key found in schema");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get the range key (sort key) field name from Dynamoose schema
|
|
68
|
+
* @param {*} schema - Dynamoose schema
|
|
69
|
+
* @returns {string|null} Range key field name, or null if no range key exists
|
|
70
|
+
*/
|
|
71
|
+
export function getRangeKey(schema) {
|
|
72
|
+
const attributes = schema.getAttributes();
|
|
73
|
+
|
|
74
|
+
for (const [field, definition] of Object.entries(attributes)) {
|
|
75
|
+
if (definition.rangeKey === true) {
|
|
76
|
+
return field;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
49
83
|
/**
|
|
50
84
|
* Determine which GSI to use based on filter model
|
|
51
85
|
* @param {FilterModel} filterModel - Filter model
|
|
@@ -181,6 +215,9 @@ export function buildGridQuery({
|
|
|
181
215
|
// Extract grid config from schema
|
|
182
216
|
const gridConfig = extractGridConfig(model.schema);
|
|
183
217
|
|
|
218
|
+
// Get the hash key field name from schema
|
|
219
|
+
const hashKeyField = getHashKey(model.schema);
|
|
220
|
+
|
|
184
221
|
// Determine which index to use
|
|
185
222
|
const index = determineIndex(filterModel, gridConfig);
|
|
186
223
|
|
|
@@ -192,7 +229,7 @@ export function buildGridQuery({
|
|
|
192
229
|
});
|
|
193
230
|
|
|
194
231
|
// Initialize query with partition key
|
|
195
|
-
let query = model.query(
|
|
232
|
+
let query = model.query(hashKeyField).eq(partitionKeyValue);
|
|
196
233
|
|
|
197
234
|
// Use GSI if needed
|
|
198
235
|
if (index) {
|
package/src/core/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
export { modelRegistry, ModelRegistry } from "./modelRegistry.js";
|
|
6
|
-
export { buildGridQuery, extractGridConfig } from "./gridQueryBuilder.js";
|
|
6
|
+
export { buildGridQuery, extractGridConfig, getHashKey, getRangeKey } from "./gridQueryBuilder.js";
|
|
7
7
|
export {
|
|
8
8
|
resolveExpand,
|
|
9
9
|
resolveExpands,
|
package/src/utils/validation.js
CHANGED
|
@@ -83,22 +83,65 @@ export function validateExpandField(field, gridConfig) {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
/**
|
|
86
|
-
* Validates pagination model
|
|
86
|
+
* Validates pagination model and ensures numeric types
|
|
87
87
|
* @param {PaginationModel} paginationModel - Pagination model to validate
|
|
88
88
|
* @throws {Error} If pagination model is invalid
|
|
89
|
+
* @returns {PaginationModel} Normalized pagination model with numeric values
|
|
89
90
|
*/
|
|
90
91
|
export function validatePaginationModel(paginationModel) {
|
|
91
92
|
if (!paginationModel) {
|
|
92
93
|
throw new Error("paginationModel is required");
|
|
93
94
|
}
|
|
94
95
|
|
|
95
|
-
|
|
96
|
+
// Check if pageSize exists first
|
|
97
|
+
if (!paginationModel.pageSize) {
|
|
96
98
|
throw new Error("paginationModel.pageSize must be a positive number");
|
|
97
99
|
}
|
|
98
100
|
|
|
99
|
-
|
|
101
|
+
// Convert pageSize to number if it's a string
|
|
102
|
+
let pageSize = paginationModel.pageSize;
|
|
103
|
+
if (typeof pageSize === "string") {
|
|
104
|
+
pageSize = Number(pageSize);
|
|
105
|
+
if (isNaN(pageSize)) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`paginationModel.pageSize must be a valid number, received '${paginationModel.pageSize}'`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
// Mutate the original object to ensure downstream code uses the number
|
|
111
|
+
paginationModel.pageSize = pageSize;
|
|
112
|
+
} else if (typeof pageSize !== "number") {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`paginationModel.pageSize must be a number, received ${typeof pageSize}`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (pageSize < 1) {
|
|
119
|
+
throw new Error("paginationModel.pageSize must be a positive number");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (pageSize > 1000) {
|
|
100
123
|
throw new Error(
|
|
101
124
|
"paginationModel.pageSize cannot exceed 1000 (DynamoDB limit)"
|
|
102
125
|
);
|
|
103
126
|
}
|
|
127
|
+
|
|
128
|
+
// Convert page to number if it exists and is a string
|
|
129
|
+
if (paginationModel.page !== undefined) {
|
|
130
|
+
let page = paginationModel.page;
|
|
131
|
+
if (typeof page === "string") {
|
|
132
|
+
page = Number(page);
|
|
133
|
+
if (isNaN(page)) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`paginationModel.page must be a valid number, received '${paginationModel.page}'`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
paginationModel.page = page;
|
|
139
|
+
} else if (typeof page !== "number") {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`paginationModel.page must be a number, received ${typeof page}`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return paginationModel;
|
|
104
147
|
}
|
|
@@ -6,6 +6,8 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
|
6
6
|
import {
|
|
7
7
|
buildGridQuery,
|
|
8
8
|
extractGridConfig,
|
|
9
|
+
getHashKey,
|
|
10
|
+
getRangeKey,
|
|
9
11
|
} from "../../src/core/gridQueryBuilder.js";
|
|
10
12
|
import {
|
|
11
13
|
createMockSchema,
|
|
@@ -58,6 +60,120 @@ describe("GridQueryBuilder", () => {
|
|
|
58
60
|
});
|
|
59
61
|
});
|
|
60
62
|
|
|
63
|
+
describe("getHashKey", () => {
|
|
64
|
+
it("should extract hash key field named 'pk'", () => {
|
|
65
|
+
const attributes = {
|
|
66
|
+
pk: { type: String, hashKey: true },
|
|
67
|
+
sk: { type: String, rangeKey: true },
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const schema = createMockSchema(attributes);
|
|
71
|
+
const hashKey = getHashKey(schema);
|
|
72
|
+
|
|
73
|
+
expect(hashKey).toBe("pk");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should extract hash key field named 'tenantId'", () => {
|
|
77
|
+
const attributes = {
|
|
78
|
+
tenantId: { type: String, hashKey: true },
|
|
79
|
+
reservationId: { type: String, rangeKey: true },
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const schema = createMockSchema(attributes);
|
|
83
|
+
const hashKey = getHashKey(schema);
|
|
84
|
+
|
|
85
|
+
expect(hashKey).toBe("tenantId");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("should extract hash key field with any name", () => {
|
|
89
|
+
const attributes = {
|
|
90
|
+
customHashKey: { type: String, hashKey: true },
|
|
91
|
+
otherField: { type: String },
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const schema = createMockSchema(attributes);
|
|
95
|
+
const hashKey = getHashKey(schema);
|
|
96
|
+
|
|
97
|
+
expect(hashKey).toBe("customHashKey");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("should throw error when no hash key found", () => {
|
|
101
|
+
const attributes = {
|
|
102
|
+
field1: { type: String },
|
|
103
|
+
field2: { type: String },
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const schema = createMockSchema(attributes);
|
|
107
|
+
|
|
108
|
+
expect(() => {
|
|
109
|
+
getHashKey(schema);
|
|
110
|
+
}).toThrow("No hash key found in schema");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("getRangeKey", () => {
|
|
115
|
+
it("should extract range key field named 'sk'", () => {
|
|
116
|
+
const attributes = {
|
|
117
|
+
pk: { type: String, hashKey: true },
|
|
118
|
+
sk: { type: String, rangeKey: true },
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const schema = createMockSchema(attributes);
|
|
122
|
+
const rangeKey = getRangeKey(schema);
|
|
123
|
+
|
|
124
|
+
expect(rangeKey).toBe("sk");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("should extract range key field named 'createdAt'", () => {
|
|
128
|
+
const attributes = {
|
|
129
|
+
pk: { type: String, hashKey: true },
|
|
130
|
+
createdAt: { type: String, rangeKey: true },
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const schema = createMockSchema(attributes);
|
|
134
|
+
const rangeKey = getRangeKey(schema);
|
|
135
|
+
|
|
136
|
+
expect(rangeKey).toBe("createdAt");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("should extract range key field named 'reservationId'", () => {
|
|
140
|
+
const attributes = {
|
|
141
|
+
tenantId: { type: String, hashKey: true },
|
|
142
|
+
reservationId: { type: String, rangeKey: true },
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const schema = createMockSchema(attributes);
|
|
146
|
+
const rangeKey = getRangeKey(schema);
|
|
147
|
+
|
|
148
|
+
expect(rangeKey).toBe("reservationId");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("should return null when no range key exists", () => {
|
|
152
|
+
const attributes = {
|
|
153
|
+
pk: { type: String, hashKey: true },
|
|
154
|
+
field1: { type: String },
|
|
155
|
+
field2: { type: String },
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const schema = createMockSchema(attributes);
|
|
159
|
+
const rangeKey = getRangeKey(schema);
|
|
160
|
+
|
|
161
|
+
expect(rangeKey).toBeNull();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("should work with custom range key names", () => {
|
|
165
|
+
const attributes = {
|
|
166
|
+
customHashKey: { type: String, hashKey: true },
|
|
167
|
+
customSortKey: { type: String, rangeKey: true },
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const schema = createMockSchema(attributes);
|
|
171
|
+
const rangeKey = getRangeKey(schema);
|
|
172
|
+
|
|
173
|
+
expect(rangeKey).toBe("customSortKey");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
61
177
|
describe("buildGridQuery - Basic", () => {
|
|
62
178
|
let mockModel;
|
|
63
179
|
let mockQuery;
|
|
@@ -110,6 +226,35 @@ describe("GridQueryBuilder", () => {
|
|
|
110
226
|
});
|
|
111
227
|
}).toThrow("paginationModel is required");
|
|
112
228
|
});
|
|
229
|
+
|
|
230
|
+
it("should use custom hash key name from schema", () => {
|
|
231
|
+
// Create schema with custom hash key name (tenantId instead of pk)
|
|
232
|
+
const customAttributes = {
|
|
233
|
+
tenantId: { type: String, hashKey: true },
|
|
234
|
+
reservationId: { type: String, rangeKey: true },
|
|
235
|
+
status: {
|
|
236
|
+
type: String,
|
|
237
|
+
query: {
|
|
238
|
+
filter: { type: "attribute", operators: ["eq"] },
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const customSchema = createMockSchema(customAttributes);
|
|
244
|
+
const customMockQuery = createMockQuery([]);
|
|
245
|
+
const customMockModel = createMockModel(customSchema, []);
|
|
246
|
+
customMockModel.query = vi.fn(() => customMockQuery);
|
|
247
|
+
|
|
248
|
+
buildGridQuery({
|
|
249
|
+
model: customMockModel,
|
|
250
|
+
partitionKeyValue: "tenant-123",
|
|
251
|
+
paginationModel: { pageSize: 20 },
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Should query with 'tenantId' not 'pk'
|
|
255
|
+
expect(customMockModel.query).toHaveBeenCalledWith("tenantId");
|
|
256
|
+
expect(customMockQuery.eq).toHaveBeenCalledWith("tenant-123");
|
|
257
|
+
});
|
|
113
258
|
});
|
|
114
259
|
|
|
115
260
|
describe("buildGridQuery - Filtering", () => {
|
|
@@ -311,6 +311,67 @@ describe("Validation Utils", () => {
|
|
|
311
311
|
validatePaginationModel({ pageSize: 2000 });
|
|
312
312
|
}).toThrow(/DynamoDB limit/);
|
|
313
313
|
});
|
|
314
|
+
|
|
315
|
+
describe("String to Number Conversion", () => {
|
|
316
|
+
it("should convert pageSize string to number", () => {
|
|
317
|
+
const paginationModel = { pageSize: "25" };
|
|
318
|
+
validatePaginationModel(paginationModel);
|
|
319
|
+
expect(paginationModel.pageSize).toBe(25);
|
|
320
|
+
expect(typeof paginationModel.pageSize).toBe("number");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("should convert page string to number", () => {
|
|
324
|
+
const paginationModel = { pageSize: 25, page: "1" };
|
|
325
|
+
validatePaginationModel(paginationModel);
|
|
326
|
+
expect(paginationModel.page).toBe(1);
|
|
327
|
+
expect(typeof paginationModel.page).toBe("number");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("should convert both pageSize and page strings to numbers", () => {
|
|
331
|
+
const paginationModel = { pageSize: "50", page: "2" };
|
|
332
|
+
validatePaginationModel(paginationModel);
|
|
333
|
+
expect(paginationModel.pageSize).toBe(50);
|
|
334
|
+
expect(paginationModel.page).toBe(2);
|
|
335
|
+
expect(typeof paginationModel.pageSize).toBe("number");
|
|
336
|
+
expect(typeof paginationModel.page).toBe("number");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("should throw error for invalid pageSize string", () => {
|
|
340
|
+
expect(() => {
|
|
341
|
+
validatePaginationModel({ pageSize: "abc" });
|
|
342
|
+
}).toThrow("must be a valid number");
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("should throw error for invalid page string", () => {
|
|
346
|
+
expect(() => {
|
|
347
|
+
validatePaginationModel({ pageSize: 25, page: "invalid" });
|
|
348
|
+
}).toThrow("must be a valid number");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("should throw error for non-string, non-number pageSize", () => {
|
|
352
|
+
expect(() => {
|
|
353
|
+
validatePaginationModel({ pageSize: true });
|
|
354
|
+
}).toThrow("must be a number");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("should throw error for object pageSize", () => {
|
|
358
|
+
expect(() => {
|
|
359
|
+
validatePaginationModel({ pageSize: { value: 25 } });
|
|
360
|
+
}).toThrow("must be a number");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("should validate converted string pageSize against limits", () => {
|
|
364
|
+
expect(() => {
|
|
365
|
+
validatePaginationModel({ pageSize: "1001" });
|
|
366
|
+
}).toThrow("cannot exceed 1000");
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("should validate converted string pageSize is positive", () => {
|
|
370
|
+
expect(() => {
|
|
371
|
+
validatePaginationModel({ pageSize: "0" });
|
|
372
|
+
}).toThrow("must be a positive number");
|
|
373
|
+
});
|
|
374
|
+
});
|
|
314
375
|
});
|
|
315
376
|
|
|
316
377
|
describe("Edge Cases", () => {
|