Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for reverse indexes #474

Merged
merged 1 commit into from
Feb 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading