dynamo-query-engine 1.0.3 → 1.0.5

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.3",
3
+ "version": "1.0.5",
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",
@@ -9,7 +9,6 @@ import {
9
9
  validateFilterField,
10
10
  validateSortField,
11
11
  validateFilterOperator,
12
- validatePaginationModel,
13
12
  } from "../utils/validation.js";
14
13
  import { decodeCursor, validateCursor } from "../utils/cursor.js";
15
14
 
@@ -18,13 +17,13 @@ import { decodeCursor, validateCursor } from "../utils/cursor.js";
18
17
  * @param {Object} params - Query parameters
19
18
  * @param {FilterModel} params.filterModel - Filter model
20
19
  * @param {SortModel} params.sortModel - Sort model
21
- * @param {number} params.pageSize - Page size
20
+ * @param {number} params.limit - Query limit
22
21
  * @returns {string} SHA256 hash of the query parameters
23
22
  */
24
- function hashQuery({ filterModel, sortModel, pageSize }) {
23
+ function hashQuery({ filterModel, sortModel, limit }) {
25
24
  return crypto
26
25
  .createHash("sha256")
27
- .update(JSON.stringify({ filterModel, sortModel, pageSize }))
26
+ .update(JSON.stringify({ filterModel, sortModel, limit }))
28
27
  .digest("hex");
29
28
  }
30
29
 
@@ -46,6 +45,40 @@ export function extractGridConfig(schema) {
46
45
  return gridConfig;
47
46
  }
48
47
 
48
+ /**
49
+ * Get the hash key (partition key) field name from Dynamoose schema
50
+ * @param {*} schema - Dynamoose schema
51
+ * @returns {string} Hash key field name
52
+ */
53
+ export function getHashKey(schema) {
54
+ const attributes = schema.getAttributes();
55
+
56
+ for (const [field, definition] of Object.entries(attributes)) {
57
+ if (definition.hashKey === true) {
58
+ return field;
59
+ }
60
+ }
61
+
62
+ throw new Error("No hash key found in schema");
63
+ }
64
+
65
+ /**
66
+ * Get the range key (sort key) field name from Dynamoose schema
67
+ * @param {*} schema - Dynamoose schema
68
+ * @returns {string|null} Range key field name, or null if no range key exists
69
+ */
70
+ export function getRangeKey(schema) {
71
+ const attributes = schema.getAttributes();
72
+
73
+ for (const [field, definition] of Object.entries(attributes)) {
74
+ if (definition.rangeKey === true) {
75
+ return field;
76
+ }
77
+ }
78
+
79
+ return null;
80
+ }
81
+
49
82
  /**
50
83
  * Determine which GSI to use based on filter model
51
84
  * @param {FilterModel} filterModel - Filter model
@@ -165,7 +198,7 @@ export function buildGridQuery({
165
198
  partitionKeyValue,
166
199
  filterModel,
167
200
  sortModel,
168
- paginationModel,
201
+ limit = 10,
169
202
  cursor,
170
203
  }) {
171
204
  // Validate inputs
@@ -175,12 +208,29 @@ export function buildGridQuery({
175
208
  if (!partitionKeyValue) {
176
209
  throw new Error("partitionKeyValue is required");
177
210
  }
178
- validatePaginationModel(paginationModel);
211
+
212
+ // Validate and normalize limit
213
+ if (limit !== undefined && limit !== null) {
214
+ const numLimit = typeof limit === "string" ? Number(limit) : limit;
215
+ if (Number.isNaN(numLimit) || numLimit < 1) {
216
+ throw new Error("limit must be a positive number");
217
+ }
218
+ if (numLimit > 1000) {
219
+ throw new Error("limit cannot exceed 1000 (DynamoDB limit)");
220
+ }
221
+ limit = numLimit;
222
+ } else {
223
+ limit = 10;
224
+ }
225
+
179
226
  validateSingleSort(sortModel);
180
227
 
181
228
  // Extract grid config from schema
182
229
  const gridConfig = extractGridConfig(model.schema);
183
230
 
231
+ // Get the hash key field name from schema
232
+ const hashKeyField = getHashKey(model.schema);
233
+
184
234
  // Determine which index to use
185
235
  const index = determineIndex(filterModel, gridConfig);
186
236
 
@@ -188,11 +238,11 @@ export function buildGridQuery({
188
238
  const queryHash = hashQuery({
189
239
  filterModel,
190
240
  sortModel,
191
- pageSize: paginationModel.pageSize,
241
+ limit,
192
242
  });
193
243
 
194
244
  // Initialize query with partition key
195
- let query = model.query("pk").eq(partitionKeyValue);
245
+ let query = model.query(hashKeyField).eq(partitionKeyValue);
196
246
 
197
247
  // Use GSI if needed
198
248
  if (index) {
@@ -205,8 +255,8 @@ export function buildGridQuery({
205
255
  // Apply sort
206
256
  query = applySort(query, sortModel, gridConfig);
207
257
 
208
- // Apply pagination limit
209
- query = query.limit(paginationModel.pageSize);
258
+ // Apply limit
259
+ query = query.limit(limit);
210
260
 
211
261
  // Handle cursor for pagination
212
262
  if (cursor) {
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,
@@ -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;
@@ -73,7 +189,7 @@ describe("GridQueryBuilder", () => {
73
189
  const { query } = buildGridQuery({
74
190
  model: mockModel,
75
191
  partitionKeyValue: "ORG#123",
76
- paginationModel: { pageSize: 20 },
192
+ limit: 20,
77
193
  });
78
194
 
79
195
  expect(mockModel.query).toHaveBeenCalledWith("pk");
@@ -87,7 +203,7 @@ describe("GridQueryBuilder", () => {
87
203
  buildGridQuery({
88
204
  model: null,
89
205
  partitionKeyValue: "ORG#123",
90
- paginationModel: { pageSize: 20 },
206
+ limit: 20,
91
207
  });
92
208
  }).toThrow("model is required");
93
209
  });
@@ -97,18 +213,48 @@ describe("GridQueryBuilder", () => {
97
213
  buildGridQuery({
98
214
  model: mockModel,
99
215
  partitionKeyValue: null,
100
- paginationModel: { pageSize: 20 },
216
+ limit: 20,
101
217
  });
102
218
  }).toThrow("partitionKeyValue is required");
103
219
  });
104
220
 
105
- it("should throw error when paginationModel is missing", () => {
106
- expect(() => {
107
- buildGridQuery({
108
- model: mockModel,
109
- partitionKeyValue: "ORG#123",
110
- });
111
- }).toThrow("paginationModel is required");
221
+ it("should use default limit when not provided", () => {
222
+ const { query } = buildGridQuery({
223
+ model: mockModel,
224
+ partitionKeyValue: "ORG#123",
225
+ });
226
+
227
+ expect(mockQuery.limit).toHaveBeenCalledWith(10);
228
+ expect(query).toBe(mockQuery);
229
+ });
230
+
231
+ it("should use custom hash key name from schema", () => {
232
+ // Create schema with custom hash key name (tenantId instead of pk)
233
+ const customAttributes = {
234
+ tenantId: { type: String, hashKey: true },
235
+ reservationId: { type: String, rangeKey: true },
236
+ status: {
237
+ type: String,
238
+ query: {
239
+ filter: { type: "attribute", operators: ["eq"] },
240
+ },
241
+ },
242
+ };
243
+
244
+ const customSchema = createMockSchema(customAttributes);
245
+ const customMockQuery = createMockQuery([]);
246
+ const customMockModel = createMockModel(customSchema, []);
247
+ customMockModel.query = vi.fn(() => customMockQuery);
248
+
249
+ buildGridQuery({
250
+ model: customMockModel,
251
+ partitionKeyValue: "tenant-123",
252
+ limit: 20,
253
+ });
254
+
255
+ // Should query with 'tenantId' not 'pk'
256
+ expect(customMockModel.query).toHaveBeenCalledWith("tenantId");
257
+ expect(customMockQuery.eq).toHaveBeenCalledWith("tenant-123");
112
258
  });
113
259
  });
114
260
 
@@ -132,7 +278,7 @@ describe("GridQueryBuilder", () => {
132
278
  model: mockModel,
133
279
  partitionKeyValue: "ORG#123",
134
280
  filterModel,
135
- paginationModel: { pageSize: 20 },
281
+ limit: 20,
136
282
  });
137
283
 
138
284
  expect(mockQuery.where).toHaveBeenCalledWith("status");
@@ -150,7 +296,7 @@ describe("GridQueryBuilder", () => {
150
296
  model: mockModel,
151
297
  partitionKeyValue: "ORG#123",
152
298
  filterModel,
153
- paginationModel: { pageSize: 20 },
299
+ limit: 20,
154
300
  });
155
301
 
156
302
  expect(mockQuery.where).toHaveBeenCalledWith("createdAt");
@@ -172,7 +318,7 @@ describe("GridQueryBuilder", () => {
172
318
  model: mockModel,
173
319
  partitionKeyValue: "ORG#123",
174
320
  filterModel,
175
- paginationModel: { pageSize: 20 },
321
+ limit: 20,
176
322
  });
177
323
 
178
324
  expect(mockQuery.between).toHaveBeenCalledWith("2024-01-01", "2024-12-31");
@@ -187,7 +333,7 @@ describe("GridQueryBuilder", () => {
187
333
  model: mockModel,
188
334
  partitionKeyValue: "ORG#123",
189
335
  filterModel,
190
- paginationModel: { pageSize: 20 },
336
+ limit: 20,
191
337
  });
192
338
 
193
339
  expect(mockQuery.using).toHaveBeenCalledWith("status-createdAt-index");
@@ -203,7 +349,7 @@ describe("GridQueryBuilder", () => {
203
349
  model: mockModel,
204
350
  partitionKeyValue: "ORG#123",
205
351
  filterModel,
206
- paginationModel: { pageSize: 20 },
352
+ limit: 20,
207
353
  });
208
354
  }).toThrow("Filtering not allowed");
209
355
  });
@@ -218,7 +364,7 @@ describe("GridQueryBuilder", () => {
218
364
  model: mockModel,
219
365
  partitionKeyValue: "ORG#123",
220
366
  filterModel,
221
- paginationModel: { pageSize: 20 },
367
+ limit: 20,
222
368
  });
223
369
  }).toThrow("not allowed");
224
370
  });
@@ -242,7 +388,7 @@ describe("GridQueryBuilder", () => {
242
388
  model: mockModel,
243
389
  partitionKeyValue: "ORG#123",
244
390
  sortModel,
245
- paginationModel: { pageSize: 20 },
391
+ limit: 20,
246
392
  });
247
393
 
248
394
  expect(mockQuery.sort).toHaveBeenCalledWith("descending");
@@ -255,7 +401,7 @@ describe("GridQueryBuilder", () => {
255
401
  model: mockModel,
256
402
  partitionKeyValue: "ORG#123",
257
403
  sortModel,
258
- paginationModel: { pageSize: 20 },
404
+ limit: 20,
259
405
  });
260
406
 
261
407
  // Ascending is default, so sort() should not be called
@@ -273,7 +419,7 @@ describe("GridQueryBuilder", () => {
273
419
  model: mockModel,
274
420
  partitionKeyValue: "ORG#123",
275
421
  sortModel,
276
- paginationModel: { pageSize: 20 },
422
+ limit: 20,
277
423
  });
278
424
  }).toThrow(/sorting by one field/);
279
425
  });
@@ -286,13 +432,13 @@ describe("GridQueryBuilder", () => {
286
432
  model: mockModel,
287
433
  partitionKeyValue: "ORG#123",
288
434
  sortModel,
289
- paginationModel: { pageSize: 20 },
435
+ limit: 20,
290
436
  });
291
437
  }).toThrow("Sorting not allowed");
292
438
  });
293
439
  });
294
440
 
295
- describe("buildGridQuery - Pagination", () => {
441
+ describe("buildGridQuery - Limit and Cursor", () => {
296
442
  let mockModel;
297
443
  let mockQuery;
298
444
 
@@ -303,11 +449,11 @@ describe("GridQueryBuilder", () => {
303
449
  mockModel.query = vi.fn(() => mockQuery);
304
450
  });
305
451
 
306
- it("should apply page size limit", () => {
452
+ it("should apply limit", () => {
307
453
  buildGridQuery({
308
454
  model: mockModel,
309
455
  partitionKeyValue: "ORG#123",
310
- paginationModel: { pageSize: 50 },
456
+ limit: 50,
311
457
  });
312
458
 
313
459
  expect(mockQuery.limit).toHaveBeenCalledWith(50);
@@ -320,7 +466,7 @@ describe("GridQueryBuilder", () => {
320
466
  const firstResult = buildGridQuery({
321
467
  model: mockModel,
322
468
  partitionKeyValue: "ORG#123",
323
- paginationModel: { pageSize: 20 },
469
+ limit: 20,
324
470
  });
325
471
 
326
472
  // Create cursor with the actual queryHash
@@ -332,7 +478,7 @@ describe("GridQueryBuilder", () => {
332
478
  buildGridQuery({
333
479
  model: mockModel,
334
480
  partitionKeyValue: "ORG#123",
335
- paginationModel: { pageSize: 20 },
481
+ limit: 20,
336
482
  cursor,
337
483
  });
338
484
 
@@ -351,7 +497,7 @@ describe("GridQueryBuilder", () => {
351
497
  buildGridQuery({
352
498
  model: mockModel,
353
499
  partitionKeyValue: "ORG#123",
354
- paginationModel: { pageSize: 50 }, // Different page size
500
+ limit: 50, // Different limit
355
501
  cursor,
356
502
  });
357
503
  }).toThrow("Cursor does not match");
@@ -374,7 +520,7 @@ describe("GridQueryBuilder", () => {
374
520
  items: [{ field: "status", operator: "eq", value: "active" }],
375
521
  },
376
522
  sortModel: [{ field: "createdAt", sort: "desc" }],
377
- paginationModel: { pageSize: 20 },
523
+ limit: 20,
378
524
  };
379
525
 
380
526
  const result1 = buildGridQuery(params);
@@ -387,7 +533,7 @@ describe("GridQueryBuilder", () => {
387
533
  const baseParams = {
388
534
  model: mockModel,
389
535
  partitionKeyValue: "ORG#123",
390
- paginationModel: { pageSize: 20 },
536
+ limit: 20,
391
537
  };
392
538
 
393
539
  const result1 = buildGridQuery({
@@ -407,7 +553,7 @@ describe("GridQueryBuilder", () => {
407
553
  expect(result1.queryHash).not.toBe(result2.queryHash);
408
554
  });
409
555
 
410
- it("should return different hash when page size changes", () => {
556
+ it("should return different hash when limit changes", () => {
411
557
  const baseParams = {
412
558
  model: mockModel,
413
559
  partitionKeyValue: "ORG#123",
@@ -415,12 +561,12 @@ describe("GridQueryBuilder", () => {
415
561
 
416
562
  const result1 = buildGridQuery({
417
563
  ...baseParams,
418
- paginationModel: { pageSize: 20 },
564
+ limit: 20,
419
565
  });
420
566
 
421
567
  const result2 = buildGridQuery({
422
568
  ...baseParams,
423
- paginationModel: { pageSize: 50 },
569
+ limit: 50,
424
570
  });
425
571
 
426
572
  expect(result1.queryHash).not.toBe(result2.queryHash);
@@ -438,7 +584,7 @@ describe("GridQueryBuilder", () => {
438
584
  mockModel.query = vi.fn(() => mockQuery);
439
585
  });
440
586
 
441
- it("should handle query with filter, sort, and pagination", () => {
587
+ it("should handle query with filter, sort, and limit", () => {
442
588
  buildGridQuery({
443
589
  model: mockModel,
444
590
  partitionKeyValue: "ORG#123",
@@ -446,7 +592,7 @@ describe("GridQueryBuilder", () => {
446
592
  items: [{ field: "status", operator: "eq", value: "active" }],
447
593
  },
448
594
  sortModel: [{ field: "createdAt", sort: "desc" }],
449
- paginationModel: { pageSize: 25 },
595
+ limit: 25,
450
596
  });
451
597
 
452
598
  // Verify all operations were applied
@@ -464,7 +610,7 @@ describe("GridQueryBuilder", () => {
464
610
  partitionKeyValue: "ORG#123",
465
611
  filterModel: { items: [] },
466
612
  sortModel: [],
467
- paginationModel: { pageSize: 20 },
613
+ limit: 20,
468
614
  });
469
615
 
470
616
  expect(query).toBeDefined();