@ftschopp/dynatable-core 1.2.0 → 1.2.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/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
1
+ ## @ftschopp/dynatable-core [1.2.2](https://github.com/ftschopp/dynatable/compare/@ftschopp/dynatable-core@1.2.1...@ftschopp/dynatable-core@1.2.2) (2026-04-15)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * add filter type scan entity ([dddff15](https://github.com/ftschopp/dynatable/commit/dddff15228f84fd11b0c6d2daa6f5cc8d77c641d))
7
+
8
+ ## @ftschopp/dynatable-core [1.2.1](https://github.com/ftschopp/dynatable/compare/@ftschopp/dynatable-core@1.2.0...@ftschopp/dynatable-core@1.2.1) (2026-04-15)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * resolve GSI key attributes to KeyConditionExpression when using useIndex ([8b2ee35](https://github.com/ftschopp/dynatable/commit/8b2ee35ca56bae9a32c8f1fec764365e8496674d))
14
+
15
+ ## @ftschopp/dynatable-core [1.2.1](https://github.com/ftschopp/dynatable/compare/@ftschopp/dynatable-core@1.2.0...@ftschopp/dynatable-core@1.2.1) (2026-04-15)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * resolve GSI key attributes to KeyConditionExpression when using useIndex ([8b2ee35](https://github.com/ftschopp/dynatable/commit/8b2ee35ca56bae9a32c8f1fec764365e8496674d))
21
+
1
22
  # @ftschopp/dynatable-core [1.2.0](https://github.com/ftschopp/dynatable/compare/@ftschopp/dynatable-core@1.1.0...@ftschopp/dynatable-core@1.2.0) (2026-03-20)
2
23
 
3
24
 
@@ -1 +1 @@
1
- {"version":3,"file":"create-query-builder.d.ts","sourceRoot":"","sources":["../../../src/builders/query/create-query-builder.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAK1D,OAAO,EAAE,YAAY,EAA8B,MAAM,SAAS,CAAC;AACnE,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAEnD,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAiT7D;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EACtC,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,cAAc,EACtB,KAAK,CAAC,EAAE,eAAe,EACvB,MAAM,CAAC,EAAE,cAAc,GACtB,YAAY,CAAC,KAAK,CAAC,CA4BrB"}
1
+ {"version":3,"file":"create-query-builder.d.ts","sourceRoot":"","sources":["../../../src/builders/query/create-query-builder.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAK1D,OAAO,EAAE,YAAY,EAA8B,MAAM,SAAS,CAAC;AACnE,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAEnD,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAuU7D;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EACtC,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,cAAc,EACtB,KAAK,CAAC,EAAE,eAAe,EACvB,MAAM,CAAC,EAAE,cAAc,GACtB,YAAY,CAAC,KAAK,CAAC,CA4BrB"}
@@ -6,11 +6,20 @@ const operators_1 = require("../shared/operators");
6
6
  const conditions_1 = require("../shared/conditions");
7
7
  const model_utils_1 = require("../../utils/model-utils");
8
8
  /**
9
- * Determines if a field is a key field (used in pk/sk templates)
9
+ * Determines if a field is a key field (used in pk/sk or index key templates)
10
10
  */
11
- function isKeyField(fieldName, model) {
11
+ function isKeyField(fieldName, model, indexName) {
12
12
  if (!model?.key)
13
13
  return false;
14
+ // When an index is specified, also check model.index for key templates
15
+ if (indexName && model.index) {
16
+ for (const [, keyDef] of Object.entries(model.index)) {
17
+ const templateVars = (0, model_utils_1.extractTemplateVars)(keyDef.value);
18
+ if (templateVars.includes(fieldName)) {
19
+ return true;
20
+ }
21
+ }
22
+ }
14
23
  const keyEntries = Object.entries(model.key);
15
24
  for (const [, keyDef] of keyEntries) {
16
25
  const templateVars = (0, model_utils_1.extractTemplateVars)(keyDef.value);
@@ -24,13 +33,22 @@ function isKeyField(fieldName, model) {
24
33
  * Maps a model attribute name to its corresponding DynamoDB key name (PK/SK)
25
34
  * Also returns the key template for value transformation
26
35
  */
27
- function getKeyNameForAttribute(fieldName, model) {
36
+ function getKeyNameForAttribute(fieldName, model, indexName) {
28
37
  if (!model?.key)
29
38
  return null;
39
+ // When an index is specified, check model.index first for key templates
40
+ if (indexName && model.index) {
41
+ for (const [keyName, keyDef] of Object.entries(model.index)) {
42
+ const templateVars = (0, model_utils_1.extractTemplateVars)(keyDef.value);
43
+ if (templateVars.includes(fieldName)) {
44
+ return { keyName, template: keyDef.value };
45
+ }
46
+ }
47
+ }
30
48
  for (const [keyName, keyDef] of Object.entries(model.key)) {
31
49
  const templateVars = (0, model_utils_1.extractTemplateVars)(keyDef.value);
32
50
  if (templateVars.includes(fieldName)) {
33
- return { keyName, template: keyDef.value }; // Returns "PK" and "UP#${username}"
51
+ return { keyName, template: keyDef.value };
34
52
  }
35
53
  }
36
54
  return null;
@@ -46,7 +64,7 @@ function applyKeyTemplate(template, fieldName, fieldValue) {
46
64
  * Separates a condition tree into key conditions and filter conditions
47
65
  * Key conditions are rewritten to use actual DynamoDB key names (PK/SK)
48
66
  */
49
- function separateConditions(condition, model) {
67
+ function separateConditions(condition, model, indexName) {
50
68
  const keyConditions = [];
51
69
  const filterConditions = [];
52
70
  function traverse(cond) {
@@ -76,7 +94,7 @@ function separateConditions(condition, model) {
76
94
  const fieldMatch = cond.expression.match(/#(\w+)/);
77
95
  if (fieldMatch && fieldMatch[1]) {
78
96
  const fieldName = fieldMatch[1];
79
- const keyInfo = getKeyNameForAttribute(fieldName, model);
97
+ const keyInfo = getKeyNameForAttribute(fieldName, model, indexName);
80
98
  if (keyInfo) {
81
99
  // This is a key field - rewrite the condition to use the actual key name
82
100
  // and apply the template to the value
@@ -178,7 +196,7 @@ function createQueryExecutor(state) {
178
196
  throw new Error('No where condition specified');
179
197
  }
180
198
  // Separate key conditions from filter conditions
181
- const { keyConditions, filterConditions } = separateConditions(state.condition, state.model);
199
+ const { keyConditions, filterConditions } = separateConditions(state.condition, state.model, state.indexName);
182
200
  // Build KeyConditionExpression (simple AND)
183
201
  const keyExpr = buildSimpleAndExpression(keyConditions);
184
202
  // Build FilterExpression (can be complex with OR, NOT, etc.)
@@ -1 +1 @@
1
- {"version":3,"file":"create-entity-api.d.ts","sourceRoot":"","sources":["../../src/entity/create-entity-api.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAC1D,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AActF,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAKtD;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,GAAI,KAAK,SAAS,eAAe,EAC3D,WAAW,MAAM,EACjB,WAAW,MAAM,EACjB,OAAO,KAAK,EACZ,QAAQ,cAAc,EACtB,UAAS,gBAAqB,KAC7B,SAAS,CAAC,UAAU,CAAC,KAAK,CAAC,EAAE,UAAU,CAAC,KAAK,CAAC,EAAE,aAAa,CAAC,KAAK,CAAC,CA8KtE,CAAC"}
1
+ {"version":3,"file":"create-entity-api.d.ts","sourceRoot":"","sources":["../../src/entity/create-entity-api.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAC1D,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AActF,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAMtD;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,GAAI,KAAK,SAAS,eAAe,EAC3D,WAAW,MAAM,EACjB,WAAW,MAAM,EACjB,OAAO,KAAK,EACZ,QAAQ,cAAc,EACtB,UAAS,gBAAqB,KAC7B,SAAS,CAAC,UAAU,CAAC,KAAK,CAAC,EAAE,UAAU,CAAC,KAAK,CAAC,EAAE,aAAa,CAAC,KAAK,CAAC,CAqLtE,CAAC"}
@@ -54,7 +54,13 @@ const createEntityAPI = (tableName, modelName, model, client, options = {}) => {
54
54
  return (0, builders_1.createQueryBuilder)(tableName, client, model, logger);
55
55
  },
56
56
  scan() {
57
- const builder = (0, builders_1.createScanBuilder)(tableName, client, [], [], undefined, false, undefined, undefined, undefined, logger);
57
+ // Auto-filter by entity type so scan only returns items for this entity
58
+ const typeFilter = {
59
+ expression: '#_type = :_type',
60
+ names: { '#_type': '_type' },
61
+ values: { ':_type': modelName },
62
+ };
63
+ const builder = (0, builders_1.createScanBuilder)(tableName, client, [typeFilter], [], undefined, false, undefined, undefined, undefined, logger);
58
64
  return (0, with_middleware_1.withMiddleware)(builder, (0, factories_1.createCleanKeysMiddleware)(cleanInternalKeys));
59
65
  },
60
66
  update(key) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ftschopp/dynatable-core",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Core library for DynamoDB single table design",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -228,3 +228,73 @@ describe('QueryBuilder - Pagination', () => {
228
228
  });
229
229
  });
230
230
  });
231
+
232
+ describe('QueryBuilder - GSI key recognition', () => {
233
+ const client = new DynamoDBClient({});
234
+ const tableName = 'TestTable';
235
+
236
+ interface UserModel {
237
+ id: string;
238
+ email: string;
239
+ name?: string;
240
+ }
241
+
242
+ const modelWithIndex: ModelDefinition = {
243
+ key: {
244
+ PK: { type: String, value: 'USER#${id}' },
245
+ SK: { type: String, value: 'USER#${id}' },
246
+ },
247
+ index: {
248
+ GSI1PK: { type: String, value: 'EMAIL#${email}' },
249
+ GSI1SK: { type: String, value: 'EMAIL#${email}' },
250
+ },
251
+ attributes: {
252
+ id: { type: String, required: true },
253
+ email: { type: String, required: true },
254
+ name: { type: String },
255
+ },
256
+ };
257
+
258
+ test('should place GSI index attribute into KeyConditionExpression when useIndex is set', () => {
259
+ const params = createQueryBuilder<UserModel>(tableName, client, modelWithIndex)
260
+ .where((attr, op) => op.eq(attr.email, 'alice@example.com'))
261
+ .useIndex('GSI1')
262
+ .dbParams();
263
+
264
+ // email should be recognized as a key field via model.index and placed in KeyConditionExpression
265
+ expect(params.KeyConditionExpression).toBeDefined();
266
+ expect(params.KeyConditionExpression).toContain('#GSI1PK');
267
+ expect(params.ExpressionAttributeValues).toBeDefined();
268
+ // The value should have the template applied
269
+ expect(Object.values(params.ExpressionAttributeValues!)).toContain('EMAIL#alice@example.com');
270
+ // It should NOT be in FilterExpression
271
+ expect(params.FilterExpression).toBeUndefined();
272
+ expect(params.IndexName).toBe('GSI1');
273
+ });
274
+
275
+ test('should place GSI attribute into FilterExpression when NO index is specified', () => {
276
+ const params = createQueryBuilder<UserModel>(tableName, client, modelWithIndex)
277
+ .where((attr, op) => op.and(op.eq(attr.id, '123'), op.eq(attr.email, 'alice@example.com')))
278
+ .dbParams();
279
+
280
+ // id maps to PK -> KeyConditionExpression
281
+ expect(params.KeyConditionExpression).toBeDefined();
282
+ expect(params.KeyConditionExpression).toContain('#PK');
283
+ // email is NOT a primary key field, so without useIndex it goes to filter
284
+ expect(params.FilterExpression).toBeDefined();
285
+ expect(params.FilterExpression).toContain('#email');
286
+ });
287
+
288
+ test('should handle both primary key and GSI key fields together with useIndex', () => {
289
+ const params = createQueryBuilder<UserModel>(tableName, client, modelWithIndex)
290
+ .where((attr, op) => op.and(op.eq(attr.email, 'alice@example.com'), op.eq(attr.id, '123')))
291
+ .useIndex('GSI1')
292
+ .dbParams();
293
+
294
+ // Both should be key conditions: email via index, id via primary key
295
+ expect(params.KeyConditionExpression).toBeDefined();
296
+ expect(params.KeyConditionExpression).toContain('#GSI1PK');
297
+ expect(params.KeyConditionExpression).toContain('#PK');
298
+ expect(params.FilterExpression).toBeUndefined();
299
+ });
300
+ });
@@ -27,11 +27,21 @@ type QueryState<Model> = {
27
27
  };
28
28
 
29
29
  /**
30
- * Determines if a field is a key field (used in pk/sk templates)
30
+ * Determines if a field is a key field (used in pk/sk or index key templates)
31
31
  */
32
- function isKeyField(fieldName: string, model?: ModelDefinition): boolean {
32
+ function isKeyField(fieldName: string, model?: ModelDefinition, indexName?: string): boolean {
33
33
  if (!model?.key) return false;
34
34
 
35
+ // When an index is specified, also check model.index for key templates
36
+ if (indexName && model.index) {
37
+ for (const [, keyDef] of Object.entries(model.index)) {
38
+ const templateVars = extractTemplateVars(keyDef.value);
39
+ if (templateVars.includes(fieldName)) {
40
+ return true;
41
+ }
42
+ }
43
+ }
44
+
35
45
  const keyEntries = Object.entries(model.key);
36
46
  for (const [, keyDef] of keyEntries) {
37
47
  const templateVars = extractTemplateVars(keyDef.value);
@@ -48,14 +58,25 @@ function isKeyField(fieldName: string, model?: ModelDefinition): boolean {
48
58
  */
49
59
  function getKeyNameForAttribute(
50
60
  fieldName: string,
51
- model?: ModelDefinition
61
+ model?: ModelDefinition,
62
+ indexName?: string
52
63
  ): { keyName: string; template: string } | null {
53
64
  if (!model?.key) return null;
54
65
 
66
+ // When an index is specified, check model.index first for key templates
67
+ if (indexName && model.index) {
68
+ for (const [keyName, keyDef] of Object.entries(model.index)) {
69
+ const templateVars = extractTemplateVars(keyDef.value);
70
+ if (templateVars.includes(fieldName)) {
71
+ return { keyName, template: keyDef.value };
72
+ }
73
+ }
74
+ }
75
+
55
76
  for (const [keyName, keyDef] of Object.entries(model.key)) {
56
77
  const templateVars = extractTemplateVars(keyDef.value);
57
78
  if (templateVars.includes(fieldName)) {
58
- return { keyName, template: keyDef.value }; // Returns "PK" and "UP#${username}"
79
+ return { keyName, template: keyDef.value };
59
80
  }
60
81
  }
61
82
  return null;
@@ -75,7 +96,8 @@ function applyKeyTemplate(template: string, fieldName: string, fieldValue: any):
75
96
  */
76
97
  function separateConditions(
77
98
  condition: Condition,
78
- model?: ModelDefinition
99
+ model?: ModelDefinition,
100
+ indexName?: string
79
101
  ): { keyConditions: Condition[]; filterConditions: Condition[] } {
80
102
  const keyConditions: Condition[] = [];
81
103
  const filterConditions: Condition[] = [];
@@ -106,7 +128,7 @@ function separateConditions(
106
128
  const fieldMatch = cond.expression.match(/#(\w+)/);
107
129
  if (fieldMatch && fieldMatch[1]) {
108
130
  const fieldName = fieldMatch[1];
109
- const keyInfo = getKeyNameForAttribute(fieldName, model);
131
+ const keyInfo = getKeyNameForAttribute(fieldName, model, indexName);
110
132
 
111
133
  if (keyInfo) {
112
134
  // This is a key field - rewrite the condition to use the actual key name
@@ -231,7 +253,7 @@ function createQueryExecutor<Model>(state: QueryState<Model>): QueryExecutor<Mod
231
253
  }
232
254
 
233
255
  // Separate key conditions from filter conditions
234
- const { keyConditions, filterConditions } = separateConditions(state.condition, state.model);
256
+ const { keyConditions, filterConditions } = separateConditions(state.condition, state.model, state.indexName);
235
257
 
236
258
  // Build KeyConditionExpression (simple AND)
237
259
  const keyExpr = buildSimpleAndExpression(keyConditions);
@@ -18,6 +18,7 @@ import { EntityAPI, EntityAPIOptions } from './types';
18
18
  import { withMiddleware } from './middleware/with-middleware';
19
19
  import { createCleanKeysMiddleware } from './middleware/factories';
20
20
  import { validateKeyFields } from './validation/key-validation';
21
+ import { Condition } from '@/builders/shared/types';
21
22
 
22
23
  /**
23
24
  * Creates an entity API instance with validation, key resolution, and builder creation.
@@ -90,10 +91,17 @@ export const createEntityAPI = <Model extends ModelDefinition>(
90
91
  },
91
92
 
92
93
  scan() {
94
+ // Auto-filter by entity type so scan only returns items for this entity
95
+ const typeFilter: Condition = {
96
+ expression: '#_type = :_type',
97
+ names: { '#_type': '_type' },
98
+ values: { ':_type': modelName },
99
+ };
100
+
93
101
  const builder = createScanBuilder<InferModel<Model>>(
94
102
  tableName,
95
103
  client,
96
- [],
104
+ [typeFilter],
97
105
  [],
98
106
  undefined,
99
107
  false,
@@ -726,7 +726,9 @@ describe('Should test dbParams function builder', () => {
726
726
  const params = await table.entities.User.scan().dbParams();
727
727
 
728
728
  expect(params.TableName).toBe('InstagramClone');
729
- expect(params.FilterExpression).toBeUndefined();
729
+ expect(params.FilterExpression).toBe('#_type = :_type');
730
+ expect(params.ExpressionAttributeNames).toMatchObject({ '#_type': '_type' });
731
+ expect(params.ExpressionAttributeValues).toMatchObject({ ':_type': 'User' });
730
732
  });
731
733
 
732
734
  test('SCAN Users with filter', async () => {
@@ -736,10 +738,12 @@ describe('Should test dbParams function builder', () => {
736
738
 
737
739
  expect(params.TableName).toBe('InstagramClone');
738
740
  expect(params.FilterExpression).toMatch(/#followerCount > :followerCount_\d+/);
739
- expect(params.ExpressionAttributeNames).toEqual({
741
+ expect(params.ExpressionAttributeNames).toMatchObject({
740
742
  '#followerCount': 'followerCount',
743
+ '#_type': '_type',
741
744
  });
742
745
  expect(Object.values(params.ExpressionAttributeValues || {})).toContain(100);
746
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain('User');
743
747
  });
744
748
 
745
749
  test('SCAN Users with multiple filters', async () => {
@@ -751,9 +755,10 @@ describe('Should test dbParams function builder', () => {
751
755
  expect(params.FilterExpression).toMatch(
752
756
  /\(#followerCount > :followerCount_\d+\) AND \(#followingCount > :followingCount_\d+\)/
753
757
  );
754
- expect(params.ExpressionAttributeNames).toEqual({
758
+ expect(params.ExpressionAttributeNames).toMatchObject({
755
759
  '#followerCount': 'followerCount',
756
760
  '#followingCount': 'followingCount',
761
+ '#_type': '_type',
757
762
  });
758
763
  });
759
764
 
@@ -763,6 +768,8 @@ describe('Should test dbParams function builder', () => {
763
768
  .dbParams();
764
769
 
765
770
  expect(params.ProjectionExpression).toBe('username, photoId, likesCount');
771
+ expect(params.FilterExpression).toBe('#_type = :_type');
772
+ expect(params.ExpressionAttributeValues).toMatchObject({ ':_type': 'Photo' });
766
773
  });
767
774
 
768
775
  test('SCAN Photos with limit', async () => {
@@ -773,6 +780,8 @@ describe('Should test dbParams function builder', () => {
773
780
 
774
781
  expect(params.Limit).toBe(10);
775
782
  expect(params.FilterExpression).toMatch(/#likesCount > :likesCount_\d+/);
783
+ expect(params.FilterExpression).toMatch(/#_type = :_type/);
784
+ expect(params.ExpressionAttributeValues).toMatchObject({ ':_type': 'Photo' });
776
785
  });
777
786
 
778
787
  test('SCAN with IN operator', async () => {
@@ -781,9 +790,11 @@ describe('Should test dbParams function builder', () => {
781
790
  .dbParams();
782
791
 
783
792
  expect(params.FilterExpression).toContain('IN');
793
+ expect(params.FilterExpression).toMatch(/#_type = :_type/);
784
794
  expect(Object.values(params.ExpressionAttributeValues || {})).toContain('alice');
785
795
  expect(Object.values(params.ExpressionAttributeValues || {})).toContain('bob');
786
796
  expect(Object.values(params.ExpressionAttributeValues || {})).toContain('charlie');
797
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain('User');
787
798
  });
788
799
 
789
800
  test('SCAN with size operator', async () => {
@@ -792,7 +803,9 @@ describe('Should test dbParams function builder', () => {
792
803
  .dbParams();
793
804
 
794
805
  expect(params.FilterExpression).toMatch(/size\(#name\) > :name_size_\d+/);
806
+ expect(params.FilterExpression).toMatch(/#_type = :_type/);
795
807
  expect(Object.values(params.ExpressionAttributeValues || {})).toContain(5);
808
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain('User');
796
809
  });
797
810
 
798
811
  test('SCAN Comments with complex filter', async () => {
@@ -804,6 +817,8 @@ describe('Should test dbParams function builder', () => {
804
817
 
805
818
  expect(params.FilterExpression).toMatch(/attribute_exists\(#content\)/);
806
819
  expect(params.FilterExpression).toMatch(/size\(#content\) > :content_size_\d+/);
820
+ expect(params.FilterExpression).toMatch(/#_type = :_type/);
821
+ expect(params.ExpressionAttributeValues).toMatchObject({ ':_type': 'Comment' });
807
822
  expect(params.ProjectionExpression).toBe('photoId, commentId, content');
808
823
  expect(params.Limit).toBe(50);
809
824
  });