From 1acb4cd2ba941172e7c6aa380c0a53193ec00b0f Mon Sep 17 00:00:00 2001 From: ty walch Date: Tue, 28 Jan 2025 14:27:03 -0500 Subject: [PATCH 1/4] Bumps version and updates changelog. Resolves an issue where updating an item with map attributes containing similar keys (after removing non-word characters) would generate conflicting expression attribute names. Now ensures unique attribute names for each key during update operations. --- CHANGELOG.md | 6 +++++- package.json | 2 +- test/ts_connected.update.spec.ts | 8 +++++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a253d51..01c87f2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -557,4 +557,8 @@ All notable changes to this project will be documented in this file. Breaking ch - [Issue #464](https://github.com/tywalch/electrodb/issues/464); When specifing return attributes on retrieval methods, ElectroDB would unexpectly return null or missing values if the options chosen resulted in an empty object being returned. This behavor could be confused with no results being found. ElectroDB now returns the empty object in these cases. ### Added -- ElectroDB Error objects no contain a `params()` method. If your operation resulted in an error thrown by the DynamoDB client, you can call the `params()` method to get the compiled parameters sent to DynamoDB. This can be helpful for debugging. Note, that if the error was thrown prior to parameter creation (validation errors, invalid query errors, etc) then the `params()` method will return the value `null`. \ No newline at end of file +- ElectroDB Error objects no contain a `params()` method. If your operation resulted in an error thrown by the DynamoDB client, you can call the `params()` method to get the compiled parameters sent to DynamoDB. This can be helpful for debugging. Note, that if the error was thrown prior to parameter creation (validation errors, invalid query errors, etc) then the `params()` method will return the value `null`. + +## [3.2.0] +### Fixed +- When updating an item with a map attribute, if you attempt to set multiple keys that are identical after removing non-word characters `(\w)`, Electro will generate the same expression attribute name for both keys. This occurs even though the original keys are different, leading to conflicts in the update operation. This update introduces a new change that ensures that each key will generate a unique expression attribute name. Contribution provided by [@anatolzak](https://github.com/anatolzak) via [PR #461](https://github.com/tywalch/electrodb/pull/461). Thank you for your contribution! diff --git a/package.json b/package.json index 110cfa6a..35debbc2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "electrodb", - "version": "3.1.0", + "version": "3.2.0", "description": "A library to more easily create and interact with multiple entities and heretical relationships in dynamodb", "main": "index.js", "scripts": { diff --git a/test/ts_connected.update.spec.ts b/test/ts_connected.update.spec.ts index 3ad4bee5..5917d748 100644 --- a/test/ts_connected.update.spec.ts +++ b/test/ts_connected.update.spec.ts @@ -2705,6 +2705,8 @@ describe("Update Item", () => { .delete({ tags: [updates.tags] }) .data((attr, op) => { op.set(attr.custom.prop1, updates.prop1); + op.set(attr.custom["prop1 "], updates.prop1); + op.set(attr.custom["prop1 "], updates.prop1); op.add(attr.views, op.name(attr.custom.prop3)); op.add(attr.recentCommits[0].views, updates.recentCommitsViews); op.remove(attr.recentCommits[1].message); @@ -2712,7 +2714,7 @@ describe("Update Item", () => { .params(); expect(params).to.deep.equal({ - UpdateExpression: + UpdateExpression: "SET #stars = (if_not_exists(#stars, :stars_default_value_u0) - :stars_u0), #files = list_append(if_not_exists(#files, :files_default_value_u0), :files_u0), #description = :description_u0, #custom.#prop1 = :custom_u0, #views = #views + #custom.#prop3, #repoOwner = :repoOwner_u0, #repoName = :repoName_u0, #__edb_e__ = :__edb_e___u0, #__edb_v__ = :__edb_v___u0 REMOVE #about, #recentCommits[1].#message ADD #followers :followers_u0, #recentCommits[0].#views :views_u0 DELETE #tags :tags_u0", ExpressionAttributeNames: { "#followers": "followers", @@ -2723,6 +2725,8 @@ describe("Update Item", () => { "#tags": "tags", "#custom": "custom", "#prop1": "prop1", + "#prop1_2": "prop1 ", + "#prop1_3": "prop1 ", "#views": "views", "#prop3": "prop3", "#recentCommits": "recentCommits", @@ -2741,6 +2745,8 @@ describe("Update Item", () => { ":description_u0": "updated description", ":tags_u0": params.ExpressionAttributeValues[":tags_u0"], ":custom_u0": "def", + ":custom_u1": "def", + ":custom_u2": "def", ":views_u0": 3, ":repoName_u0": repoName, ":repoOwner_u0": repoOwner, From c5cfbfde74eb8cf7946585480fb78e5c971d4a41 Mon Sep 17 00:00:00 2001 From: ty walch Date: Tue, 28 Jan 2025 14:52:21 -0500 Subject: [PATCH 2/4] fixes test --- test/ts_connected.update.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ts_connected.update.spec.ts b/test/ts_connected.update.spec.ts index 5917d748..7f0788ca 100644 --- a/test/ts_connected.update.spec.ts +++ b/test/ts_connected.update.spec.ts @@ -2715,7 +2715,7 @@ describe("Update Item", () => { expect(params).to.deep.equal({ UpdateExpression: - "SET #stars = (if_not_exists(#stars, :stars_default_value_u0) - :stars_u0), #files = list_append(if_not_exists(#files, :files_default_value_u0), :files_u0), #description = :description_u0, #custom.#prop1 = :custom_u0, #views = #views + #custom.#prop3, #repoOwner = :repoOwner_u0, #repoName = :repoName_u0, #__edb_e__ = :__edb_e___u0, #__edb_v__ = :__edb_v___u0 REMOVE #about, #recentCommits[1].#message ADD #followers :followers_u0, #recentCommits[0].#views :views_u0 DELETE #tags :tags_u0", + "SET #stars = (if_not_exists(#stars, :stars_default_value_u0) - :stars_u0), #files = list_append(if_not_exists(#files, :files_default_value_u0), :files_u0), #description = :description_u0, #custom.#prop1 = :custom_u0, #custom.#prop1_2 = :custom_u1, #custom.#prop1_3 = :custom_u2, #views = #views + #custom.#prop3, #repoOwner = :repoOwner_u0, #repoName = :repoName_u0, #__edb_e__ = :__edb_e___u0, #__edb_v__ = :__edb_v___u0 REMOVE #about, #recentCommits[1].#message ADD #followers :followers_u0, #recentCommits[0].#views :views_u0 DELETE #tags :tags_u0", ExpressionAttributeNames: { "#followers": "followers", "#stars": "stars", From 9ecfefefc72b6e286090d06577cf4a89b52fb6d6 Mon Sep 17 00:00:00 2001 From: ty walch Date: Tue, 11 Feb 2025 10:24:47 -0500 Subject: [PATCH 3/4] Additional fixes/changes - Fixed typing for "batchGet" where return type was not defined as a Promise in some cases. - Changes reverse index constraint when keys are defined with a `template`. Previously, ElectroDB would throw if your entity definition used a `pk` field as an `sk` field (and vice versa) across two indexes. This constraint has been lifted _if_ the impacted keys are defined with a `template`. Eventually I would like to allow this for indexes without the use of `template`, but until then, this change should help some users who have been impacted by this constraint. --- CHANGELOG.md | 6 +- index.d.ts | 66 +-- src/entity.js | 42 +- test/definitions/reverseindex.json | 42 ++ test/entity.test-d.ts | 61 +++ test/init.js | 3 +- test/ts_connected.entity.spec.ts | 664 +++++++++++++++++++++++++- test/ts_connected.validations.spec.ts | 2 +- 8 files changed, 845 insertions(+), 41 deletions(-) create mode 100644 test/definitions/reverseindex.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 01c87f2d..3fd7fdd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -554,7 +554,7 @@ All notable changes to this project will be documented in this file. Breaking ch ## [3.1.0] ### Fixed -- [Issue #464](https://github.com/tywalch/electrodb/issues/464); When specifing return attributes on retrieval methods, ElectroDB would unexpectly return null or missing values if the options chosen resulted in an empty object being returned. This behavor could be confused with no results being found. ElectroDB now returns the empty object in these cases. +- [Issue #464](https://github.com/tywalch/electrodb/issues/464); When specifying return attributes on retrieval methods, ElectroDB would unexpectedly return null or missing values if the options chosen resulted in an empty object being returned. This behavior could be confused with no results being found. ElectroDB now returns the empty object in these cases. ### Added - ElectroDB Error objects no contain a `params()` method. If your operation resulted in an error thrown by the DynamoDB client, you can call the `params()` method to get the compiled parameters sent to DynamoDB. This can be helpful for debugging. Note, that if the error was thrown prior to parameter creation (validation errors, invalid query errors, etc) then the `params()` method will return the value `null`. @@ -562,3 +562,7 @@ All notable changes to this project will be documented in this file. Breaking ch ## [3.2.0] ### Fixed - When updating an item with a map attribute, if you attempt to set multiple keys that are identical after removing non-word characters `(\w)`, Electro will generate the same expression attribute name for both keys. This occurs even though the original keys are different, leading to conflicts in the update operation. This update introduces a new change that ensures that each key will generate a unique expression attribute name. Contribution provided by [@anatolzak](https://github.com/anatolzak) via [PR #461](https://github.com/tywalch/electrodb/pull/461). Thank you for your contribution! +- Fixed typing for "batchGet" where return type was not defined as a Promise in some cases. + +### Changed +- [Issue #416](https://github.com/tywalch/electrodb/issues/416); You can now use reverse indexes on keys defined with a `template`. Previously, ElectroDB would throw if your entity definition used a `pk` field as an `sk` field (and vice versa) across two indexes. This constraint has been lifted _if_ the impacted keys are defined with a `template`. Eventually I would like to allow this for indexes without the use of `template`, but until then, this change should help some users who have been impacted by this constraint. diff --git a/index.d.ts b/index.d.ts index 472d653d..1b780e1a 100644 --- a/index.d.ts +++ b/index.d.ts @@ -2716,23 +2716,23 @@ type GoBatchGetTerminal< > = >( options?: Options, ) => Options extends GoBatchGetTerminalOptions - ? "preserveBatchOrder" extends keyof Options - ? Options["preserveBatchOrder"] extends true - ? Promise<{ - data: Array< - Resolve< - | { - [Name in keyof ResponseItem as Name extends Attr - ? Name - : never]: ResponseItem[Name]; - } - | null - > - >; - unprocessed: Array< - Resolve> - >; - }> + ? "preserveBatchOrder" extends keyof Options + ? Options["preserveBatchOrder"] extends true + ? Promise<{ + data: Array< + Resolve< + | { + [Name in keyof ResponseItem as Name extends Attr + ? Name + : never]: ResponseItem[Name]; + } + | null + > + >; + unprocessed: Array< + Resolve> + >; + }> : Promise<{ data: Array< Resolve<{ @@ -2758,23 +2758,23 @@ type GoBatchGetTerminal< >; }> : "preserveBatchOrder" extends keyof Options - ? Options["preserveBatchOrder"] extends true - ? { - data: Array>; - unprocessed: Array< - Resolve> - >; - } - : { + ? Options["preserveBatchOrder"] extends true + ? Promise<{ + data: Array>; + unprocessed: Array< + Resolve> + >; + }> + : Promise<{ + data: Array>; + unprocessed: Array< + Resolve> + >; + }> + : Promise<{ data: Array>; - unprocessed: Array< - Resolve> - >; - } - : { - data: Array>; - unprocessed: Array>>; - }; + unprocessed: Array>>; + }>; type GoGetTerminal< A extends string, diff --git a/src/entity.js b/src/entity.js index 318cc701..cea040f1 100644 --- a/src/entity.js +++ b/src/entity.js @@ -4276,6 +4276,7 @@ class Entity { facets: parsedPKAttributes.attributes, isCustom: parsedPKAttributes.isCustom, facetLabels: parsedPKAttributes.labels, + template: index.pk.template, }; let sk = {}; let parsedSKAttributes = {}; @@ -4294,6 +4295,7 @@ class Entity { facets: parsedSKAttributes.attributes, isCustom: parsedSKAttributes.isCustom, facetLabels: parsedSKAttributes.labels, + template: index.sk.template, }; facets.fields.push(sk.field); } @@ -4409,10 +4411,12 @@ class Entity { const definition = Object.values(facets.byField[pk.field]).find( (definition) => definition.index !== indexName, ); + const definitionsMatch = validations.stringArrayMatch( pk.facets, definition.facets, ); + if (!definitionsMatch) { throw new e.ElectroError( e.ErrorCodes.InconsistentIndexDefinition, @@ -4427,6 +4431,20 @@ class Entity { )}'. Key fields must have the same composite attribute definitions across all indexes they are involved with`, ); } + + const keyTemplatesMatch = pk.template === definition.template + + if (!keyTemplatesMatch) { + throw new e.ElectroError( + e.ErrorCodes.IncompatibleKeyCompositeAttributeTemplate, + `Partition Key (pk) on Access Pattern '${u.formatIndexNameForDisplay( + accessPattern, + )}' is defined with the template ${pk.template || '(undefined)'}, but the accessPattern '${u.formatIndexNameForDisplay( + definition.index, + )}' defines this field with the key labels ${definition.template || '(undefined)'}'. Key fields must have the same template definitions across all indexes they are involved with`, + ); + } + seenIndexFields[pk.field].push({ accessPattern, type: "pk" }); } else { seenIndexFields[pk.field] = []; @@ -4447,7 +4465,8 @@ class Entity { const isAlsoDefinedAsPK = seenIndexFields[sk.field].find( (field) => field.type === "pk", ); - if (isAlsoDefinedAsPK) { + + if (isAlsoDefinedAsPK && !sk.isCustom) { throw new e.ElectroError( e.ErrorCodes.InconsistentIndexDefinition, `The Sort Key (sk) on Access Pattern '${u.formatIndexNameForDisplay( @@ -4456,16 +4475,19 @@ class Entity { pk.field }' which is already referenced by the Access Pattern(s) '${u.formatIndexNameForDisplay( isAlsoDefinedAsPK.accessPattern, - )}' as a Partition Key. Fields mapped to Partition Keys cannot be also mapped to Sort Keys.`, + )}' as a Partition Key. Fields mapped to Partition Keys cannot be also mapped to Sort Keys unless their format is defined with a 'template'.`, ); } + const definition = Object.values(facets.byField[sk.field]).find( (definition) => definition.index !== indexName, ); + const definitionsMatch = validations.stringArrayMatch( sk.facets, definition.facets, - ); + ) + if (!definitionsMatch) { throw new e.ElectroError( e.ErrorCodes.DuplicateIndexFields, @@ -4480,6 +4502,20 @@ class Entity { )}'. Key fields must have the same composite attribute definitions across all indexes they are involved with`, ); } + + const keyTemplatesMatch = sk.template === definition.template + + if (!keyTemplatesMatch) { + throw new e.ElectroError( + e.ErrorCodes.IncompatibleKeyCompositeAttributeTemplate, + `Sort Key (sk) on Access Pattern '${u.formatIndexNameForDisplay( + accessPattern, + )}' is defined with the template ${sk.template || '(undefined)'}, but the accessPattern '${u.formatIndexNameForDisplay( + definition.index, + )}' defines this field with the key labels ${definition.template || '(undefined)'}'. Key fields must have the same template definitions across all indexes they are involved with`, + ); + } + seenIndexFields[sk.field].push({ accessPattern, type: "sk" }); } else { seenIndexFields[sk.field] = []; diff --git a/test/definitions/reverseindex.json b/test/definitions/reverseindex.json new file mode 100644 index 00000000..f5240c00 --- /dev/null +++ b/test/definitions/reverseindex.json @@ -0,0 +1,42 @@ +{ + "KeySchema": [ + { + "AttributeName": "pk", + "KeyType": "HASH" + }, + { + "AttributeName": "sk", + "KeyType": "RANGE" + } + ], + "AttributeDefinitions": [ + { + "AttributeName": "pk", + "AttributeType": "S" + }, + { + "AttributeName": "sk", + "AttributeType": "S" + } + ], + "GlobalSecondaryIndexes": [ + { + "IndexName": "reverse-index", + "KeySchema": [ + { + "AttributeName": "sk", + "KeyType": "HASH" + }, + { + "AttributeName": "pk", + "KeyType": "RANGE" + } + ], + "Projection": { + "ProjectionType": "ALL" + } + } + ], + "BillingMode": "PAY_PER_REQUEST" + } + \ No newline at end of file diff --git a/test/entity.test-d.ts b/test/entity.test-d.ts index c2d815d5..3d902443 100644 --- a/test/entity.test-d.ts +++ b/test/entity.test-d.ts @@ -11,6 +11,7 @@ const troubleshoot = ( fn: (...params: Params) => Response, response: Response, ) => {}; + const magnify = (value: T): Resolve => { return {} as Resolve; }; @@ -254,3 +255,63 @@ type CustomAttributeEntityItemType = EntityItem; const unionEntityItem = {} as CustomAttributeEntityItemType["union"]; expectType(magnify(unionEntityItem)); + +const batchGetWithoutAttributesNoPreserve = entityWithSK.get([{attr1: 'abc', attr2: 'def'}]).go(); +expectType>(batchGetWithoutAttributesNoPreserve); + +const batchGetWithoutAttributesPreserve = entityWithSK.get([{attr1: 'abc', attr2: 'def'}]).go({ preserveBatchOrder: true }); +expectType>(batchGetWithoutAttributesPreserve); + +const batchGetWithAttributesNoPreserve = entityWithSK.get([{attr1: 'abc', attr2: 'def'}]).go({ attributes: ['attr5', 'attr10'] }); +expectType; + unprocessed: { attr1: string; attr2: string; }[]; +}>>(magnify(batchGetWithAttributesNoPreserve)); + +const batchGetWithAttributesPreserve = entityWithSK.get([{attr1: 'abc', attr2: 'def'}]).go({ attributes: ['attr5', 'attr10'], preserveBatchOrder: true }); +expectType; + unprocessed: { attr1: string; attr2: string; }[]; +}>>(magnify(batchGetWithAttributesPreserve)); \ No newline at end of file diff --git a/test/init.js b/test/init.js index e8c5cb6a..1461c50f 100644 --- a/test/init.js +++ b/test/init.js @@ -10,7 +10,7 @@ const leadingUnderscoreKeys = require("./definitions/leadingunderscorekeys.json" const localSecondaryIndexes = require("./definitions/localsecondaryindexes.json"); const keysOnly = require("./definitions/keysonly.json"); const castKeys = require("./definitions/castkeys.json"); - +const reverseIndex = require("./definitions/reverseindex.json"); const shouldDestroy = process.argv.includes("--recreate"); if ( @@ -79,6 +79,7 @@ async function main() { createTable(dynamodb, "electro_localsecondaryindex", localSecondaryIndexes), createTable(dynamodb, "electro_keysonly", keysOnly), createTable(dynamodb, "electro_castkeys", castKeys), + createTable(dynamodb, "electro_reverseindex", reverseIndex), ]); } diff --git a/test/ts_connected.entity.spec.ts b/test/ts_connected.entity.spec.ts index e12ac053..cb0105fb 100644 --- a/test/ts_connected.entity.spec.ts +++ b/test/ts_connected.entity.spec.ts @@ -1,5 +1,5 @@ import { DocumentClient, PutItemInput } from "aws-sdk/clients/dynamodb"; -import { Entity, EntityRecord, createWriteTransaction, ElectroEvent, createConversions, Service } from "../"; +import { Entity, EntityRecord, EntityItem, createWriteTransaction, ElectroEvent, createConversions, Service } from "../"; import { expect } from "chai"; import { v4 as uuid } from "uuid"; const u = require("../src/util"); @@ -23,6 +23,16 @@ const client = new DocumentClient({ const table = "electro"; +function createParamPrinter(label: string) { + return (event: ElectroEvent) => { + if (event.type === 'query') { + console.log(label, JSON.stringify(event.params, null, 4)) + } else { + console.log(label, JSON.stringify(event.results, null, 4)) + } + } +} + describe("conversions", () => { const table = "electro"; const serviceName = uuid(); @@ -5445,4 +5455,654 @@ describe('execution option compare', () => { }); }); }); -}) +}); + +describe('when using reverse indexes', () => { + function expectToThrowMessage(fn: Function, message?: string) { + let err: any = null; + try { + fn(); + } catch(e) { + err = e; + } + expect(err).to.not.be.null; + if (message) { + expect(err?.message).to.be.equal(message); + } + } + + function expectNotToThrow(fn: Function) { + let err: any = null; + try { + fn(); + } catch(e) { + err = e; + } + expect(err).to.be.null; + } + + const entityName = uuid(); + const serviceName = uuid(); + const Thing = new Entity({ + model: { + entity: entityName, + service: serviceName, + version: '1', + }, + attributes: { + thingId: { + type: "string", + required: true + }, + locationId: { + type: "string", + required: true + }, + groupNumber: { + type: "number", + padding: { + length: 2, + char: '0', + }, + }, + count: { + type: 'number' + }, + color: { + type: ['green', 'yellow', 'blue'] as const, + } + }, + indexes: { + sectors: { + pk: { + field: "pk", + composite: ["locationId", "groupNumber"], + template: `$${serviceName}#location_id\${locationId}#groupnumber_\${groupNumber}`, + }, + sk: { + field: "sk", + composite: ["thingId"], + template: `${entityName}_1#thing_id_\${thingId}`, + } + }, + things: { + index: 'reverse-index', + pk: { + field: "sk", + composite: ["thingId"], + template: `${entityName}_1#thing_id_\${thingId}`, + }, + sk: { + field: "pk", + composite: ["locationId", "groupNumber"], + template: `$${serviceName}#location_id\${locationId}#groupnumber_\${groupNumber}`, + } + }, + } + }, { table: 'electro_reverseindex', client }); + + type ThingItem = EntityItem; + + async function expectItems(thingId: string, locationId: string, items: ThingItem[]) { + const things = await Thing.query.things({ thingId, locationId }).go(); + expect(things.data).to.deep.equal(items); + + const sectors = await Thing.query.sectors({ locationId, groupNumber: 2 }).go(); + expect(sectors.data).to.deep.equal([items[2]]); + + const gte = await Thing.query.things({ thingId, locationId }).gte({ groupNumber: 2 }).go(); + expect(gte.data).to.deep.equal(items.slice(2)); + + const lte = await Thing.query.things({ thingId, locationId }).lte({ groupNumber: 2 }).go(); + expect(lte.data).to.deep.equal(items.slice(0, 3)); + + const between = await Thing.query.things({ thingId, locationId }).between({ groupNumber: 1 }, { groupNumber: 3 }).go(); + expect(between.data).to.deep.equal(items.slice(1, 4)); + + const begins = await Thing.query.things({ thingId, locationId }).begins({ groupNumber: 1 }).go(); + expect(begins.data).to.deep.equal([items[1]]); + + const lt = await Thing.query.things({ thingId, locationId }).lt({ groupNumber: 2 }).go(); + expect(lt.data).to.deep.equal(items.slice(0, 2)); + + const gt = await Thing.query.things({ thingId, locationId }).gt({ groupNumber: 2 }).go(); + expect(gt.data).to.deep.equal(items.slice(3)); + + const eq = await Thing.query.things({ thingId, locationId, groupNumber: 2 }).go(); + expect(eq.data).to.deep.equal([items[2]]); + } + + it('should perform full crud cycle', async () => { + const thingId = uuid(); + const locationId = uuid(); + const colors = ['green', 'green', 'yellow', 'blue', 'yellow'] as const; + const items = Array + .from({ length: 5 }, () => ({ thingId, locationId })) + .map((item, groupNumber) => ({ + ...item, + groupNumber, + color: colors[groupNumber] + })); + + await Thing.put(items).go(); + await expectItems(thingId, locationId, items); + + const updated = await Thing.update({ thingId, locationId, groupNumber: 2 }).add({ count: 100 }).go({response: 'all_new'}); + expect(updated.data.count).to.equal(100); + const updatedItems = items.map(item => item.groupNumber === 2 ? { ...item, count: 100 } : item); + await expectItems(thingId, locationId, updatedItems); + + await Thing.delete({ thingId, locationId, groupNumber: 5 }).go(); + const deletedItems = updatedItems.filter(item => item.groupNumber !== 5); + await expectItems(thingId, locationId, deletedItems); + }); + + it('should not allow updates to key attributes', async () => { + const thingId = uuid(); + const locationId = uuid(); + const groupNumber = 2; + const item: ThingItem = { + thingId, + locationId, + groupNumber, + color: 'green' + }; + await Thing.put(item).go(); + + const update = await Thing.update({ thingId, locationId, groupNumber }) + // @ts-expect-error + .set({ thingId: uuid() }) + .go() + .then(() => null) + .catch((err) => err); + + expect(update).to.be.instanceOf(Error); + expect(update.message).to.equal('Attribute "thingId" is Read-Only and cannot be updated - For more detail on this error reference: https://electrodb.dev/en/reference/errors/#invalid-attribute'); + }); + + it('should validate the that the composites between reverse indexes match', () => { + function createMismatchedSortKey() { + const entityName = uuid(); + const serviceName = uuid(); + return new Entity({ + model: { + entity: entityName, + service: serviceName, + version: '1', + }, + attributes: { + thingId: { + type: "string", + required: true + }, + locationId: { + type: "string", + required: true + }, + groupNumber: { + type: "number", + padding: { + length: 2, + char: '0', + }, + }, + count: { + type: 'number' + }, + color: { + type: ['green', 'yellow', 'blue'] as const, + } + }, + indexes: { + sectors: { + pk: { + field: "pk", + composite: ["locationId", "groupNumber"] + }, + sk: { + field: "sk", + composite: [] + } + }, + things: { + index: 'reverse-index', + pk: { + field: "sk", + composite: ["thingId"] + }, + sk: { + field: "pk", + composite: ["locationId", "groupNumber"] + } + }, + } + }, { table: 'electro_reverseindex', client }); + } + + function createMisMatchedPartitionKey() { + const entityName = uuid(); + const serviceName = uuid(); + const Thing = new Entity({ + model: { + entity: entityName, + service: serviceName, + version: '1', + }, + attributes: { + thingId: { + type: "string", + required: true + }, + locationId: { + type: "string", + required: true + }, + groupNumber: { + type: "number", + padding: { + length: 2, + char: '0', + }, + }, + count: { + type: 'number' + }, + color: { + type: ['green', 'yellow', 'blue'] as const, + } + }, + indexes: { + sectors: { + pk: { + field: "pk", + composite: ["locationId"], + template: "locationId#${locationId}" + }, + sk: { + field: "sk", + composite: ["thingId"], + template: "thingId#${thingId}" + } + }, + things: { + index: 'reverse-index', + pk: { + field: "sk", + composite: ["thingId"], + template: "thingId#${thingId}" + }, + sk: { + field: "pk", + composite: ["locationId", "groupNumber"], + template: "locationId#${locationId}#groupNumber#${groupNumber}" + } + }, + } + }, { table: 'electro_reverseindex', client }); + } + + expect(createMismatchedSortKey).to.throw("Partition Key (pk) on Access Pattern 'things' is defined with the composite attribute(s) \"thingId\", but the accessPattern '(Primary Index)' defines this field with the composite attributes '. Key fields must have the same composite attribute definitions across all indexes they are involved with - For more detail on this error reference: https://electrodb.dev/en/reference/errors/#inconsistent-index-definition"); + expect(createMisMatchedPartitionKey).to.throw("Sort Key (sk) on Access Pattern 'things' is defined with the composite attribute(s) \"locationId\", \"groupNumber\", but the accessPattern '(Primary Index)' defines this field with the composite attributes \"locationId\"'. Key fields must have the same composite attribute definitions across all indexes they are involved with - For more detail on this error reference: https://electrodb.dev/en/reference/errors/#duplicate-index-fields"); + }); + + describe("when key templates do not match across usages", () => { + it('should throw when a pk does not match', () => { + expectToThrowMessage(() => { + new Entity({ + model: { + entity: 'test', + service: 'test', + version: '1', + }, + attributes: { + attr1: { + type: "string", + required: true + }, + attr2: { + type: "string", + required: true + }, + attr3: { + type: "string", + required: true + }, + }, + indexes: { + followers: { + pk: { + field: "pk", + composite: ["attr1"], + template: "attr1#${attr1}" + }, + sk: { + field: "sk", + composite: ["attr2"], + } + }, + followings: { + index: 'pk-gsi1sk-index', + pk: { + field: "pk", + composite: ["attr1"], + template: "attr2#${attr1}" + }, + sk: { + field: "gsi1sk", + composite: ["attr3"] + } + }, + } + }); + }, "Partition Key (pk) on Access Pattern 'followings' is defined with the template attr2#${attr1}, but the accessPattern '(Primary Index)' defines this field with the key labels attr1#${attr1}'. Key fields must have the same template definitions across all indexes they are involved with - For more detail on this error reference: https://electrodb.dev/en/reference/errors/#incompatible-key-composite-attribute-template"); + }); + + it('should throw when an sk does not match', () => { + expectToThrowMessage(() => { + new Entity({ + model: { + entity: 'test', + service: 'test', + version: '1', + }, + attributes: { + attr1: { + type: "string", + required: true + }, + attr2: { + type: "string", + required: true + }, + attr3: { + type: "string", + required: true + }, + }, + indexes: { + followers: { + pk: { + field: "pk", + composite: ["attr1"], + }, + sk: { + field: "sk", + composite: ["attr2"], + template: "attr2#${attr2}" + } + }, + followings: { + index: 'gsi1pk-sk-index', + pk: { + field: "gsi1pk", + composite: ["attr1"], + }, + sk: { + field: "sk", + composite: ["attr2"], + template: "attr3#${attr2}" + } + }, + } + }); + }, 'Sort Key (sk) on Access Pattern \'followings\' is defined with the template attr3#${attr2}, but the accessPattern \'(Primary Index)\' defines this field with the key labels attr2#${attr2}\'. Key fields must have the same template definitions across all indexes they are involved with - For more detail on this error reference: https://electrodb.dev/en/reference/errors/#incompatible-key-composite-attribute-template'); + }); + }); + + describe('given reverse index template requirements', () => { + it('should throw when a reverse index is defined without using the template syntax', () => { + expectToThrowMessage(() => { + new Entity({ + model: { + entity: 'test', + service: 'test', + version: '1', + }, + attributes: { + attr1: { + type: "string", + required: true + }, + attr2: { + type: "string", + required: true + }, + attr3: { + type: "string", + required: true + }, + }, + indexes: { + followers: { + pk: { + field: "pk", + composite: ["attr1"], + }, + sk: { + field: "sk", + composite: ["attr2"], + } + }, + followings: { + index: 'gsi1pk-pk-index', + pk: { + field: "gsi1pk", + composite: ["attr3"] + }, + sk: { + field: "pk", + composite: ["attr1"], + }, + }, + } + }); + }, "The Sort Key (sk) on Access Pattern 'followings' references the field 'gsi1pk' which is already referenced by the Access Pattern(s) 'followers' as a Partition Key. Fields mapped to Partition Keys cannot be also mapped to Sort Keys unless their format is defined with a 'template'. - For more detail on this error reference: https://electrodb.dev/en/reference/errors/#inconsistent-index-definition"); + }); + + it('should not throw when only a single key is reversed and the other doesnt use the template syntax', () => { + expectNotToThrow(() => { + new Entity({ + model: { + entity: 'test', + service: 'test', + version: '1', + }, + attributes: { + attr1: { + type: "string", + required: true + }, + attr2: { + type: "string", + required: true + }, + attr3: { + type: "string", + required: true + }, + }, + indexes: { + followers: { + pk: { + field: "pk", + composite: ["attr1"], + template: "attr1#${attr1}" + }, + sk: { + field: "sk", + composite: ["attr2"], + } + }, + followings: { + index: 'gsi1pk-pk-index', + pk: { + field: "gsi1pk", + composite: ["attr3"] + }, + sk: { + field: "pk", + composite: ["attr1"], + template: "attr1#${attr1}" + }, + }, + } + }); + }); + }); + + it('should not throw when both keys are reversed but they appropriately use the template syntax', () => { + expectNotToThrow(() => { + new Entity({ + model: { + entity: 'test', + service: 'test', + version: '1', + }, + attributes: { + attr1: { + type: "string", + required: true + }, + attr2: { + type: "string", + required: true + }, + attr3: { + type: "string", + required: true + }, + }, + indexes: { + followers: { + pk: { + field: "pk", + composite: ["attr1"], + template: "attr1#${attr1}" + }, + sk: { + field: "sk", + composite: ["attr2"], + template: "attr2#${attr2}" + } + }, + followings: { + index: 'reverse-index', + pk: { + field: "sk", + composite: ["attr2"], + template: "attr2#${attr2}" + }, + sk: { + field: "pk", + composite: ["attr1"], + template: "attr1#${attr1}" + }, + }, + } + }); + }); + }); + + it('should throw if reverse index with template syntax does not match across index definitions', () => { + expectToThrowMessage(() => { + new Entity({ + model: { + entity: 'test', + service: 'test', + version: '1', + }, + attributes: { + attr1: { + type: "string", + required: true + }, + attr2: { + type: "string", + required: true + }, + attr3: { + type: "string", + required: true + }, + }, + indexes: { + followers: { + pk: { + field: "pk", + composite: ["attr1"], + template: "attr1#${attr1}" + }, + sk: { + field: "sk", + composite: ["attr2"], + } + }, + followings: { + index: 'gsi1pk-pk-index', + pk: { + field: "gsi1pk", + composite: ["attr3"] + }, + sk: { + field: "pk", + composite: ["attr1"], + template: "field1#${attr1}" + }, + }, + } + }); + }, "Sort Key (sk) on Access Pattern 'followings' is defined with the template field1#${attr1}, but the accessPattern '(Primary Index)' defines this field with the key labels attr1#${attr1}'. Key fields must have the same template definitions across all indexes they are involved with - For more detail on this error reference: https://electrodb.dev/en/reference/errors/#incompatible-key-composite-attribute-template"); + + expectToThrowMessage(() => { + new Entity({ + model: { + entity: 'test', + service: 'test', + version: '1', + }, + attributes: { + attr1: { + type: "string", + required: true + }, + attr2: { + type: "string", + required: true + }, + attr3: { + type: "string", + required: true + }, + }, + indexes: { + followers: { + pk: { + field: "pk", + composite: ["attr1"], + template: "field1#${attr1}" + }, + sk: { + field: "sk", + composite: ["attr2"], + template: "field2#${attr2}" + } + }, + followings: { + index: 'reverse-index', + pk: { + field: "sk", + composite: ["attr2"], + template: "attr2#${attr2}" + }, + sk: { + field: "pk", + composite: ["attr1"], + template: "attr1#${attr1}" + }, + }, + } + }); + }, "Partition Key (pk) on Access Pattern 'followings' is defined with the template attr2#${attr2}, but the accessPattern '(Primary Index)' defines this field with the key labels field2#${attr2}'. Key fields must have the same template definitions across all indexes they are involved with - For more detail on this error reference: https://electrodb.dev/en/reference/errors/#incompatible-key-composite-attribute-template"); + }); + }); +}); \ No newline at end of file diff --git a/test/ts_connected.validations.spec.ts b/test/ts_connected.validations.spec.ts index fd21ec0e..9e6e0314 100644 --- a/test/ts_connected.validations.spec.ts +++ b/test/ts_connected.validations.spec.ts @@ -1514,7 +1514,7 @@ describe("Index definition validations", function () { ); expect(createEntity).to.throw( - "The Sort Key (sk) on Access Pattern 'global2' references the field 'gsi2pk' which is already referenced by the Access Pattern(s) 'global1' as a Partition Key. Fields mapped to Partition Keys cannot be also mapped to Sort Keys. - For more detail on this error reference: https://electrodb.dev/en/reference/errors/#inconsistent-index-definition", + "The Sort Key (sk) on Access Pattern 'global2' references the field 'gsi2pk' which is already referenced by the Access Pattern(s) 'global1' as a Partition Key. Fields mapped to Partition Keys cannot be also mapped to Sort Keys unless their format is defined with a 'template'. - For more detail on this error reference: https://electrodb.dev/en/reference/errors/#inconsistent-index-definition", ); }); From 1df448388b85aefefdcf4ae54911573cae443d90 Mon Sep 17 00:00:00 2001 From: ty walch Date: Tue, 11 Feb 2025 10:59:47 -0500 Subject: [PATCH 4/4] Adds 2.15.1 hotfix detail to CHANGELOG --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fd7fdd4..840d2791 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -538,6 +538,10 @@ All notable changes to this project will be documented in this file. Breaking ch - Updated `@aws-sdk/lib-dynamodb` dependency from pinned version `3.395.0` to latest release `^3.654.0`. This impacts users using the v3 aws-sdk. - Adds dependency `@aws-sdk/util-dynamodb` for unmarshalling functionality. +## [2.15.1] - 2025-02-11 +### Hotfix +- Fixed typing for "batchGet" where return type was not defined as a Promise in some cases. This change is the 2.0.0 hotfix, the corresponding 3.0.0 change was introduced in [3.2.0](#320). + ## [3.0.0] ### Changed - ElectroDB is changing how it generates query parameters to give more control to users. Prior to `v3`, query operations that used the `gt`, `lte`, or `between` methods would incur additional post-processing, including additional filter expressions and some sort key hacks. The post-processing was an attempt to bridge an interface gap between attribute-level considerations and key-level considerations. Checkout the GitHub issue championed by @rcoundon and @PaulJNewell77 [here](https://github.com/tywalch/electrodb/issues/228) to learn more. With `v3`, ElectroDB will not apply post-processing to queries of any type and abstains from adding implicit/erroneous filter expressions to queries _by default_. This change should provide additional control to users to achieve more advanced queries, but also introduces some additional complexity. There are many factors related to sorting and using comparison queries that are not intuitive, and the simplest way to mitigate this is by using additional [filter expressions](https://electrodb.dev/en/queries/filters/) to ensure the items returned will match expectations. To ease migration and adoption, I have added a new execution option called `compare`; To recreate `v2` functionality without further changes, use the execution option `{ compare: "v2" }`. This value is marked as deprecated and will be removed at a later date, but should allow users to safely upgrade to `v3` and experiment with the impact of this change on their existing data. The new `compare` option has other values that will continue to see support, however; to learn more about this new option, checkout [Comparison Queries](https://electrodb.dev/en/queries/query#comparison-queries).