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 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
@@ -9,6 +9,8 @@ export { modelRegistry, ModelRegistry } from "./src/core/modelRegistry.js";
9
9
  export {
10
10
  buildGridQuery,
11
11
  extractGridConfig,
12
+ getHashKey,
13
+ getRangeKey,
12
14
  } from "./src/core/gridQueryBuilder.js";
13
15
  export {
14
16
  resolveExpand,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dynamo-query-engine",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Type-safe DynamoDB query builder for GraphQL with support for filtering, sorting, pagination, and relation expansion",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -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("pk").eq(partitionKeyValue);
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,
@@ -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
- if (!paginationModel.pageSize || paginationModel.pageSize < 1) {
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
- if (paginationModel.pageSize > 1000) {
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", () => {