From 3ed5aada38e1b179408fe44f5b4b7137753d0572 Mon Sep 17 00:00:00 2001 From: ty walch Date: Tue, 11 Feb 2025 14:08:08 -0500 Subject: [PATCH] Add support for reverse indexes - Fixes issue #416 --- CHANGELOG.md | 6 +- index.d.ts | 6 +- package.json | 2 +- src/entity.js | 90 +- src/errors.js | 6 + src/types.js | 3 + src/util.js | 20 +- test/ts_connected.entity.spec.ts | 1202 +++++++++++++------------ test/ts_connected.validations.spec.ts | 98 -- www/src/pages/en/reference/errors.mdx | 10 + 10 files changed, 750 insertions(+), 693 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59132b17..bdfc2209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -570,5 +570,9 @@ All notable changes to this project will be documented in this file. Breaking ch ## [3.3.0] - Fixed typing for "batchGet" where return type was not defined as a Promise in some cases. -### Changed +### Added - [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. + +## [3.4.0] +### Added +- [Issue #416](https://github.com/tywalch/electrodb/issues/416); You can now use reverse indexes without the use of `template`. \ No newline at end of file diff --git a/index.d.ts b/index.d.ts index 1b780e1a..f3ac8e97 100644 --- a/index.d.ts +++ b/index.d.ts @@ -3648,6 +3648,8 @@ export type AccessPatternCollection = C | ReadonlyArray; export type KeyCastOption = "string" | "number"; +export type KeyCasingOption = "upper" | "lower" | "none" | "default"; + export interface Schema { readonly model: { readonly entity: string; @@ -3666,14 +3668,14 @@ export interface Schema { readonly collection?: AccessPatternCollection; readonly condition?: (composite: Record) => boolean; readonly pk: { - readonly casing?: "upper" | "lower" | "none" | "default"; + readonly casing?: KeyCasingOption; readonly field: string; readonly composite: ReadonlyArray; readonly template?: string; readonly cast?: KeyCastOption; }; readonly sk?: { - readonly casing?: "upper" | "lower" | "none" | "default"; + readonly casing?: KeyCasingOption; readonly field: string; readonly composite: ReadonlyArray; readonly template?: string; diff --git a/package.json b/package.json index 7da55b83..4ba54200 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "electrodb", - "version": "3.3.0", + "version": "3.4.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/src/entity.js b/src/entity.js index cea040f1..659cddf6 100644 --- a/src/entity.js +++ b/src/entity.js @@ -3,6 +3,7 @@ const { Schema } = require("./schema"); const { AllPages, KeyCasing, + DefaultKeyCasing, TableIndex, FormatToReturnValues, ReturnValues, @@ -1369,10 +1370,6 @@ class Entity { return this.config.table; } - getTableName() { - return this.config.table; - } - _chain(state, clauses, clause) { let current = {}; for (let child of clause.children) { @@ -3519,6 +3516,7 @@ class Entity { modelVersion, isClustered, schema, + prefixes = {}, }) { /* Collections will prefix the sort key so they can be queried with @@ -3527,7 +3525,6 @@ class Entity { of a customKey AND a collection, the collection is ignored to favor the custom key. */ - let keys = { pk: { prefix: "", @@ -3545,6 +3542,28 @@ class Entity { }, }; + let previouslyDefinedPk = null; + let previouslyDefinedSk = null; + for (const [indexName, definition] of Object.entries(prefixes)) { + if (definition.pk.field === tableIndex.pk.field) { + previouslyDefinedPk = { indexName, definition: definition.pk }; + } else if (definition.sk && definition.sk.field === tableIndex.pk.field) { + previouslyDefinedPk = { indexName, definition: definition.sk }; + } + + if (tableIndex.sk) { + if (definition.pk.field === tableIndex.sk.field) { + previouslyDefinedSk = { indexName, definition: definition.pk }; + } else if (definition.sk && definition.sk.field === tableIndex.sk.field) { + previouslyDefinedSk = { indexName, definition: definition.sk }; + } + } + + if (previouslyDefinedPk && (previouslyDefinedSk || !tableIndex.sk)) { + break; + } + } + let pk = `$${service}`; let sk = ""; let entityKeys = ""; @@ -3636,6 +3655,37 @@ class Entity { } } + if (previouslyDefinedPk) { + const casingMatch = u.toKeyCasingOption(keys.pk.casing) === u.toKeyCasingOption(previouslyDefinedPk.definition.casing); + if (!casingMatch) { + throw new e.ElectroError( + e.ErrorCodes.IncompatibleKeyCasing, + `Partition Key (pk) on Access Pattern '${u.formatIndexNameForDisplay( + tableIndex.index, + )}' is defined with the casing ${keys.pk.casing}, but the accessPattern '${u.formatIndexNameForDisplay( + previouslyDefinedPk.indexName, + )}' defines the same index field with the ${previouslyDefinedPk.definition.casing === DefaultKeyCasing ? '(default)' : ''} casing ${previouslyDefinedPk.definition.casing}. Key fields must have the same casing definitions across all indexes they are involved with.`, + ); + } + + keys.pk = previouslyDefinedPk.definition; + } + + if (previouslyDefinedSk) { + const casingMatch = u.toKeyCasingOption(keys.sk.casing) === u.toKeyCasingOption(previouslyDefinedSk.definition.casing); + if (!casingMatch) { + throw new e.ElectroError( + e.ErrorCodes.IncompatibleKeyCasing, + `Sort Key (sk) on Access Pattern '${u.formatIndexNameForDisplay( + tableIndex.index, + )}' is defined with the casing ${keys.sk.casing}, but the accessPattern '${u.formatIndexNameForDisplay( + previouslyDefinedSk.indexName, + )}' defines the same index field with the ${previouslyDefinedSk.definition.casing === DefaultKeyCasing ? '(default)' : ''} casing ${previouslyDefinedSk.definition.casing}. Key fields must have the same casing definitions across all indexes they are involved with.`, + ); + } + keys.sk = previouslyDefinedSk.definition; + } + return keys; } @@ -3772,17 +3822,21 @@ class Entity { if (!skAttributes.length) { skAttributes.push({}); } + let facets = this.model.facets.byIndex[index]; + let prefixes = this.model.prefixes[index]; if (!prefixes) { throw new Error(`Invalid index: ${index}`); } + let pk = this._makeKey( prefixes.pk, facets.pk, pkAttributes, this.model.facets.labels[index].pk, ); + let sk = []; let fulfilled = false; if (this.model.lookup.indexHasSortKeys[index]) { @@ -3805,6 +3859,7 @@ class Entity { } } } + return { pk: pk.key, sk, @@ -3866,8 +3921,9 @@ class Entity { for (let i = 0; i < labels.length; i++) { const { name, label } = labels[i]; const attribute = this.model.schema.getAttribute(name); + let value = supplied[name]; - if (supplied[name] === undefined && excludeLabelTail) { + if (value === undefined && excludeLabelTail) { break; } @@ -3880,11 +3936,14 @@ class Entity { } else { key = `${key}#${label}_`; } + // Undefined facet value means we cant build any more of the key if (supplied[name] === undefined) { break; } + foundCount++; + key = `${key}${value}`; } @@ -4248,6 +4307,7 @@ class Entity { pk: false, sk: false, }; + const pkCasing = KeyCasing[index.pk.casing] === undefined ? KeyCasing.default @@ -4462,23 +4522,6 @@ class Entity { }' as the field name for both the PK and SK. Fields used for indexes need to be unique to avoid conflicts.`, ); } else if (seenIndexFields[sk.field] !== undefined) { - const isAlsoDefinedAsPK = seenIndexFields[sk.field].find( - (field) => field.type === "pk", - ); - - if (isAlsoDefinedAsPK && !sk.isCustom) { - throw new e.ElectroError( - e.ErrorCodes.InconsistentIndexDefinition, - `The Sort Key (sk) on Access Pattern '${u.formatIndexNameForDisplay( - accessPattern, - )}' references the field '${ - 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 unless their format is defined with a 'template'.`, - ); - } - const definition = Object.values(facets.byField[sk.field]).find( (definition) => definition.index !== indexName, ); @@ -4640,6 +4683,7 @@ class Entity { modelVersion, isClustered: clusteredIndexes.has(accessPattern), schema, + prefixes, }); } return prefixes; diff --git a/src/errors.js b/src/errors.js index 2a180b94..39581026 100644 --- a/src/errors.js +++ b/src/errors.js @@ -127,6 +127,12 @@ const ErrorCodes = { name: "InvalidIndexCompositeWithAttributeName", sym: ErrorCode, }, + IncompatibleKeyCasing: { + code: 1020, + section: "incompatible-key-casing", + name: "IncompatibleKeyCasing", + sym: ErrorCode, + }, InvalidListenerProvided: { code: 1020, section: "invalid-listener-provided", diff --git a/src/types.js b/src/types.js index 52b7a965..cfd5fa79 100644 --- a/src/types.js +++ b/src/types.js @@ -295,6 +295,8 @@ const KeyCasing = { default: "default", }; +const DefaultKeyCasing = KeyCasing.lower; + const EventSubscriptionTypes = ["query", "results"]; const TerminalOperation = { @@ -378,4 +380,5 @@ module.exports = { TransactionMethods, UpsertOperations, BatchWriteTypes, + DefaultKeyCasing, }; diff --git a/src/util.js b/src/util.js index 1345d87f..1a09642e 100644 --- a/src/util.js +++ b/src/util.js @@ -1,5 +1,4 @@ const t = require("./types"); -const e = require("./errors"); const v = require("./validations"); function parseJSONPath(path = "") { @@ -105,8 +104,24 @@ function formatStringCasing(str, casing, defaultCase) { } } +function toKeyCasingOption(casing) { + switch(casing) { + case t.KeyCasing.upper: + return t.KeyCasing.upper; + case t.KeyCasing.none: + return t.KeyCasing.none; + case t.KeyCasing.lower: + return t.KeyCasing.lower; + case t.KeyCasing.default: + case undefined: + return t.DefaultKeyCasing; + default: + throw new Error(`Unknown casing option: ${casing}`); + } +} + function formatKeyCasing(str, casing) { - return formatStringCasing(str, casing, t.KeyCasing.lower); + return formatStringCasing(str, casing, t.DefaultKeyCasing); } function formatAttributeCasing(str, casing) { @@ -268,6 +283,7 @@ module.exports = { getModelVersion, formatKeyCasing, cursorFormatter, + toKeyCasingOption, genericizeJSONPath, commaSeparatedString, formatAttributeCasing, diff --git a/test/ts_connected.entity.spec.ts b/test/ts_connected.entity.spec.ts index cb0105fb..cdd65ded 100644 --- a/test/ts_connected.entity.spec.ts +++ b/test/ts_connected.entity.spec.ts @@ -1,8 +1,17 @@ import { DocumentClient, PutItemInput } from "aws-sdk/clients/dynamodb"; -import { Entity, EntityRecord, EntityItem, createWriteTransaction, ElectroEvent, createConversions, Service } from "../"; +import { + Entity, + EntityRecord, + EntityItem, + createWriteTransaction, + ElectroEvent, + createConversions, + KeyCasingOption, +} from "../"; import { expect } from "chai"; import { v4 as uuid } from "uuid"; const u = require("../src/util"); +const { KeyCasing } = require('../src/types.js') type ConversionTest = { item: any; @@ -5457,652 +5466,713 @@ 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, + describe(`when using reverse indexes`, () => { + function expectNotToThrow(fn: Function) { + let err: any = null; + try { + fn(); + } catch (e) { + err = e; } - }, - 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}`, - } - }, + expect(err).to.be.null; } - }, { 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(); + function expectToThrowMessage(fn: Function, message?: string) { + let err: any = null; + try { + fn(); + } catch (e) { + err = e; + } - const update = await Thing.update({ thingId, locationId, groupNumber }) - // @ts-expect-error - .set({ thingId: uuid() }) - .go() - .then(() => null) - .catch((err) => err); + if (message) { + expect(err?.message).to.be.equal(message); + } + } - 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'); - }); + function expectToThrowCode(fn: Function, code: number) { + let err: any = null; + try { + fn(); + } catch (e) { + err = e; + } - 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 }); + expect(err?.code).to.be.equal(code); } - 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, + for (const useTemplate of [true, false]) { + describe(`when templates ${useTemplate ? 'are' : 'arent'} used`, () => { + function expectNotToThrow(fn: Function) { + let err: any = null; + try { + fn(); + } catch (e) { + err = e; } - }, - 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}" - } - }, + expect(err).to.be.null; } - }, { 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({ + const entityName = uuid(); + const serviceName = uuid(); + const createThing = (e = entityName, s = serviceName) => new Entity({ model: { - entity: 'test', - service: 'test', + entity: e, + service: s, version: '1', }, attributes: { - attr1: { + thingId: { type: "string", required: true }, - attr2: { + locationId: { type: "string", required: true }, - attr3: { - type: "string", - required: true + groupNumber: { + type: "number", + padding: { + length: 2, + char: '0', + }, }, + count: { + type: 'number' + }, + color: { + type: ['green', 'yellow', 'blue'] as const, + } }, indexes: { - followers: { + sectors: { pk: { field: "pk", - composite: ["attr1"], - template: "attr1#${attr1}" + composite: ["locationId", "groupNumber"], + template: useTemplate ? `$${serviceName}#location_id\${locationId}#groupnumber_\${groupNumber}` : undefined, }, sk: { field: "sk", - composite: ["attr2"], + composite: ["thingId"], + template: useTemplate ? `${entityName}_1#thing_id_\${thingId}` : undefined, } }, - followings: { - index: 'pk-gsi1sk-index', + things: { + index: 'reverse-index', pk: { - field: "pk", - composite: ["attr1"], - template: "attr2#${attr1}" + field: "sk", + composite: ["thingId"], + template: useTemplate ? `${entityName}_1#thing_id_\${thingId}` : undefined, }, sk: { - field: "gsi1sk", - composite: ["attr3"] + field: "pk", + composite: ["locationId", "groupNumber"], + template: useTemplate ? `$${serviceName}#location_id\${locationId}#groupnumber_\${groupNumber}` : undefined, } }, } + }, { table: 'electro_reverseindex', client }); + + type Thing = ReturnType; + type ThingItem = EntityItem; + + async function expectItems(thing: Thing, 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] + })); + const thing = createThing(); + await thing.put(items).go(); + await expectItems(thing, 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(thing, thingId, locationId, updatedItems); + + await thing.delete({ thingId, locationId, groupNumber: 5 }).go(); + const deletedItems = updatedItems.filter(item => item.groupNumber !== 5); + await expectItems(thing, thingId, locationId, deletedItems); }); - }, "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"], + 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' + }; + + const thing = createThing(); + + 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', }, - sk: { - field: "sk", - composite: ["attr2"], - template: "attr2#${attr2}" + 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"] + } + }, } - }, - followings: { - index: 'gsi1pk-sk-index', - pk: { - field: "gsi1pk", - composite: ["attr1"], + }, { table: 'electro_reverseindex', client }); + } + + function createMisMatchedPartitionKey() { + const entityName = uuid(); + const serviceName = uuid(); + const Thing = new Entity({ + model: { + entity: entityName, + service: serviceName, + version: '1', }, - sk: { - field: "sk", - composite: ["attr2"], - template: "attr3#${attr2}" + 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: useTemplate ? "locationId#${locationId}" : undefined, + }, + sk: { + field: "sk", + composite: ["thingId"], + template: useTemplate ? "thingId#${thingId}" : undefined, + } + }, + things: { + index: 'reverse-index', + pk: { + field: "sk", + composite: ["thingId"], + template: useTemplate ? "thingId#${thingId}" : undefined, + }, + sk: { + field: "pk", + composite: ["locationId", "groupNumber"], + template: useTemplate ? "locationId#${locationId}#groupNumber#${groupNumber}" : undefined, + } + }, } - }, + }, { 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"); }); - }, '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 + }); + } + + describe("when key templates do not match across usages", () => { + 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', }, - }, - indexes: { - followers: { - pk: { - field: "pk", - composite: ["attr1"], + attributes: { + attr1: { + type: "string", + required: true }, - sk: { - field: "sk", - composite: ["attr2"], - } - }, - followings: { - index: 'gsi1pk-pk-index', - pk: { - field: "gsi1pk", - composite: ["attr3"] + attr2: { + type: "string", + required: true }, - sk: { - field: "pk", - composite: ["attr1"], + 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}" + }, + }, + } + }); }); - }, "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 + 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', }, - }, - indexes: { - followers: { - pk: { - field: "pk", - composite: ["attr1"], - template: "attr1#${attr1}" + attributes: { + attr1: { + type: "string", + required: true }, - sk: { - field: "sk", - composite: ["attr2"], - } - }, - followings: { - index: 'gsi1pk-pk-index', - pk: { - field: "gsi1pk", - composite: ["attr3"] + attr2: { + type: "string", + required: true }, - sk: { - field: "pk", - composite: ["attr1"], - template: "attr1#${attr1}" + 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 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 + 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', }, - attr3: { - type: "string", - required: true + 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}" + indexes: { + followers: { + pk: { + field: "pk", + composite: ["attr1"], + template: "attr1#${attr1}" + }, + sk: { + field: "sk", + composite: ["attr2"], + } }, - sk: { - field: "sk", - composite: ["attr2"], - template: "attr2#${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', }, - followings: { - index: 'reverse-index', - pk: { - field: "sk", - composite: ["attr2"], - template: "attr2#${attr2}" + attributes: { + attr1: { + type: "string", + required: true }, - sk: { - field: "pk", - composite: ["attr1"], - template: "attr1#${attr1}" + 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"); }); - }); - 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 + it('should throw when a pk does not match', () => { + expectToThrowMessage(() => { + new Entity({ + model: { + entity: 'test', + service: 'test', + version: '1', }, - }, - indexes: { - followers: { - pk: { - field: "pk", - composite: ["attr1"], - template: "attr1#${attr1}" + attributes: { + attr1: { + type: "string", + required: true }, - sk: { - field: "sk", - composite: ["attr2"], - } - }, - followings: { - index: 'gsi1pk-pk-index', - pk: { - field: "gsi1pk", - composite: ["attr3"] + attr2: { + type: "string", + required: true }, - sk: { - field: "pk", - composite: ["attr1"], - template: "field1#${attr1}" + attr3: { + type: "string", + required: true }, }, - } - }); - }, "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"); + 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"); + }); - 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 + 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', }, - }, - indexes: { - followers: { - pk: { - field: "pk", - composite: ["attr1"], - template: "field1#${attr1}" + attributes: { + attr1: { + type: "string", + required: true }, - sk: { - field: "sk", - composite: ["attr2"], - template: "field2#${attr2}" - } - }, - followings: { - index: 'reverse-index', - pk: { - field: "sk", - composite: ["attr2"], - template: "attr2#${attr2}" + attr2: { + type: "string", + required: true }, - sk: { - field: "pk", - composite: ["attr1"], - template: "attr1#${attr1}" + attr3: { + type: "string", + required: true }, }, - } - }); - }, "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"); + 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('when reused index definitions are not compatible', () => { + const casingOptions = [...Object.keys(KeyCasing), undefined] as Array; + + function keyCasingMatches(left: string | undefined, right: string | undefined): boolean { + const leftOption = u.toKeyCasingOption(left); + const rightOption = u.toKeyCasingOption(right); + return leftOption === rightOption; + } + + describe('when casing options are not compatible', () => { + for (const leftCasing of casingOptions) { + for (const rightCasing of casingOptions) { + it(`should enforce matching casing when the primary index defines an sk with a case of ${leftCasing} a GSI defines a pk with the same field name but with casing defined with the casing ${rightCasing}`, () => { + const create = () => { + 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"], + casing: leftCasing, + } + }, + followings: { + index: 'reverse-index', + pk: { + field: "sk", + composite: ["attr2"], + casing: rightCasing + }, + sk: { + field: 'pk', + composite: ["attr1"], + } + }, + } + }); + } + + if (keyCasingMatches(leftCasing, rightCasing)) { + expectNotToThrow(create); + } else { + expectToThrowCode(create, 1020); + } + }); + } + } + }); + }) }); -}); \ No newline at end of file diff --git a/test/ts_connected.validations.spec.ts b/test/ts_connected.validations.spec.ts index 9e6e0314..8540628c 100644 --- a/test/ts_connected.validations.spec.ts +++ b/test/ts_connected.validations.spec.ts @@ -1420,104 +1420,6 @@ describe("Index definition validations", function () { expect(queries).to.deep.equal([[item], [item], [item], [item]]); }); - it("should not allow a field to map to both an sort key and a partition key", async () => { - const table = "electro_localsecondaryindex"; - const createEntity = () => - new Entity( - { - model: { - entity: "entity", - service: "service", - version: "1", - }, - attributes: { - prop1: { - type: "string", - }, - prop2: { - type: "string", - }, - prop3: { - type: "string", - }, - prop4: { - type: "string", - }, - prop5: { - type: "string", - }, - prop6: { - type: "string", - }, - prop7: { - type: "string", - }, - }, - indexes: { - record: { - pk: { - field: "pk", - composite: ["prop1"], - }, - sk: { - field: "sk", - composite: ["prop2"], - }, - }, - local1: { - index: "lsi1pk-lsi1sk-index", - pk: { - field: "pk", - composite: ["prop1"], - }, - sk: { - field: "lsi1sk", - composite: ["prop5", "prop2"], - }, - }, - local2: { - index: "lsi2pk-lsi2sk-index", - pk: { - field: "pk", - composite: ["prop1"], - }, - sk: { - field: "lsi2sk", - composite: ["prop6"], - }, - }, - global1: { - index: "gsi1pk-lsi1sk-index", - pk: { - field: "gsi1pk", - composite: ["prop3", "prop1"], - }, - sk: { - field: "lsi1sk", - composite: ["prop5", "prop2"], - }, - }, - global2: { - index: "gsi2pk-gsi1pk-index", - pk: { - field: "gsi2pk", - composite: ["prop7"], - }, - sk: { - field: "gsi1pk", - composite: ["prop3", "prop1"], - }, - }, - }, - }, - { table, client }, - ); - - 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 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("enforce pk field definitions in cases where a field is used in multiple indexes", async () => { const table = "electro_localsecondaryindex"; const createEntity = () => diff --git a/www/src/pages/en/reference/errors.mdx b/www/src/pages/en/reference/errors.mdx index 20fe4643..827c8f14 100644 --- a/www/src/pages/en/reference/errors.mdx +++ b/www/src/pages/en/reference/errors.mdx @@ -319,6 +319,16 @@ Collections allow for unique access patterns to be modeled between entities. It **What to do about it:** Checkout the section [Collections](/en/modeling/collections) to learn more about collections, as well as the section [Using ElectroDB with existing data](/en/core-concepts/use-electrodb-with-existing-table) to learn more about considerations to make when using attributes as index fields. +### Invalid Key Casing + +**Code: 1020** + +**Why this occurred:** +The casing between instances of the same key field in your model is inconsistent. DynamoDB is case-sensitive, and ElectroDB will enforce this. + +**What to do about it:** +Ensure that the casing of your key fields is consistent across your model. + ## Invalid Query Errors ### Missing Composite Attributes