Skip to content

Commit

Permalink
Add support for reverse indexes
Browse files Browse the repository at this point in the history
- Fixes issue #416
  • Loading branch information
tywalch committed Feb 11, 2025
1 parent daa1644 commit 3ed5aad
Show file tree
Hide file tree
Showing 10 changed files with 750 additions and 693 deletions.
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
6 changes: 4 additions & 2 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3648,6 +3648,8 @@ export type AccessPatternCollection<C extends string> = C | ReadonlyArray<C>;

export type KeyCastOption = "string" | "number";

export type KeyCasingOption = "upper" | "lower" | "none" | "default";

export interface Schema<A extends string, F extends string, C extends string> {
readonly model: {
readonly entity: string;
Expand All @@ -3666,14 +3668,14 @@ export interface Schema<A extends string, F extends string, C extends string> {
readonly collection?: AccessPatternCollection<C>;
readonly condition?: (composite: Record<string, unknown>) => boolean;
readonly pk: {
readonly casing?: "upper" | "lower" | "none" | "default";
readonly casing?: KeyCasingOption;
readonly field: string;
readonly composite: ReadonlyArray<F>;
readonly template?: string;
readonly cast?: KeyCastOption;
};
readonly sk?: {
readonly casing?: "upper" | "lower" | "none" | "default";
readonly casing?: KeyCasingOption;
readonly field: string;
readonly composite: ReadonlyArray<F>;
readonly template?: string;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
90 changes: 67 additions & 23 deletions src/entity.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const { Schema } = require("./schema");
const {
AllPages,
KeyCasing,
DefaultKeyCasing,
TableIndex,
FormatToReturnValues,
ReturnValues,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -3519,6 +3516,7 @@ class Entity {
modelVersion,
isClustered,
schema,
prefixes = {},
}) {
/*
Collections will prefix the sort key so they can be queried with
Expand All @@ -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: "",
Expand All @@ -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 = "";
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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]) {
Expand All @@ -3805,6 +3859,7 @@ class Entity {
}
}
}

return {
pk: pk.key,
sk,
Expand Down Expand Up @@ -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;
}

Expand All @@ -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}`;
}

Expand Down Expand Up @@ -4248,6 +4307,7 @@ class Entity {
pk: false,
sk: false,
};

const pkCasing =
KeyCasing[index.pk.casing] === undefined
? KeyCasing.default
Expand Down Expand Up @@ -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,
);
Expand Down Expand Up @@ -4640,6 +4683,7 @@ class Entity {
modelVersion,
isClustered: clusteredIndexes.has(accessPattern),
schema,
prefixes,
});
}
return prefixes;
Expand Down
6 changes: 6 additions & 0 deletions src/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,8 @@ const KeyCasing = {
default: "default",
};

const DefaultKeyCasing = KeyCasing.lower;

const EventSubscriptionTypes = ["query", "results"];

const TerminalOperation = {
Expand Down Expand Up @@ -378,4 +380,5 @@ module.exports = {
TransactionMethods,
UpsertOperations,
BatchWriteTypes,
DefaultKeyCasing,
};
20 changes: 18 additions & 2 deletions src/util.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
const t = require("./types");
const e = require("./errors");
const v = require("./validations");

function parseJSONPath(path = "") {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -268,6 +283,7 @@ module.exports = {
getModelVersion,
formatKeyCasing,
cursorFormatter,
toKeyCasingOption,
genericizeJSONPath,
commaSeparatedString,
formatAttributeCasing,
Expand Down
Loading

0 comments on commit 3ed5aad

Please sign in to comment.