Skip to content

Commit

Permalink
Adds new property scope to index definitions
Browse files Browse the repository at this point in the history
This property allows users to further isolate partition keys beyond just `service` participation. This implements an RFC that was thoughtfully put forward by [@sam3d](https://github.com/sam3d) in [Issue #290](#290).
  • Loading branch information
tywalch committed Nov 12, 2023
1 parent 57467f0 commit 01cc0b7
Show file tree
Hide file tree
Showing 9 changed files with 612 additions and 5 deletions.
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -485,4 +485,8 @@ All notable changes to this project will be documented in this file. Breaking ch

## [2.10.7] - 2023-11-09
### Fixed
- Fixes latency issue introduced in `2.10.4` affecting all queries discovered and brought forward by Ross Gerbasi. Thank you, Ross Gerbasi!
- Fixes latency issue introduced in `2.10.4` affecting all queries discovered and brought forward by Ross Gerbasi. Thank you, Ross Gerbasi!

## [2.11.0] - 2023-11-12
### Added
- Adds new property `scope` to index definitions, allowing users to further isolate partition keys beyond just `service` participation. This implements an RFC that was thoughtfully put forward by [@Sam3d](https://github.com/sam3d) in [Issue #290](https://github.com/tywalch/electrodb/issues/290).
1 change: 1 addition & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3655,6 +3655,7 @@ export interface Schema<A extends string, F extends string, C extends string> {
[accessPattern: string]: {
readonly project?: "keys_only";
readonly index?: string;
readonly scope?: string;
readonly type?: "clustered" | "isolated";
readonly collection?: AccessPatternCollection<C>;
readonly pk: {
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": "2.10.7",
"version": "2.11.0",
"description": "A library to more easily create and interact with multiple entities and heretical relationships in dynamodb",
"main": "index.js",
"scripts": {
Expand Down
11 changes: 8 additions & 3 deletions src/entity.js
Original file line number Diff line number Diff line change
Expand Up @@ -3222,6 +3222,9 @@ class Entity {

// If keys are not custom, set the prefixes
if (!keys.pk.isCustom) {
if (tableIndex.scope) {
pk = `${pk}_${tableIndex.scope}`;
}
keys.pk.prefix = u.formatKeyCasing(pk, tableIndex.pk.casing);
}

Expand Down Expand Up @@ -3842,6 +3845,7 @@ class Entity {
let indexName = index.index || TableIndex;
let indexType =
typeof index.type === "string" ? index.type : IndexTypes.isolated;
let indexScope = index.scope || "";
if (indexType === "clustered") {
clusteredIndexes.add(accessPattern);
}
Expand Down Expand Up @@ -3940,14 +3944,15 @@ class Entity {
}
}

let definition = {
let definition= {
pk,
sk,
collection,
hasSk,
collection,
customFacets,
index: indexName,
type: indexType,
index: indexName,
scope: indexScope,
};

indexHasSubCollections[indexName] =
Expand Down
11 changes: 11 additions & 0 deletions src/service.js
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,7 @@ class Service {
let pkFieldMatch = definition.pk.field === providedIndex.pk.field;
let pkFacetLengthMatch =
definition.pk.facets.length === providedIndex.pk.facets.length;
let scopeMatch = definition.scope === providedIndex.scope;
let mismatchedFacetLabels = [];
let collectionDifferences = [];
let definitionIndexName = u.formatIndexNameForDisplay(definition.index);
Expand Down Expand Up @@ -631,6 +632,16 @@ class Service {
}
}

if (!scopeMatch) {
collectionDifferences.push(
`The index scope value provided "${
providedIndex.scope || "undefined"
}" does not match established index scope value "${
definition.scope || "undefined"
}" on index "${providedIndexName}". Index scope options must match across all entities participating in a collection`,
);
}

if (!isCustomMatchPK) {
collectionDifferences.push(
`The usage of key templates the partition key on index ${definitionIndexName} must be consistent across all Entities, some entities provided use template while others do not`,
Expand Down
4 changes: 4 additions & 0 deletions src/validations.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ const Index = {
enum: ["string", "number"],
required: false,
},
scope: {
type: "string",
required: false,
}
},
},
sk: {
Expand Down
259 changes: 259 additions & 0 deletions test/ts_connected.entity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2573,4 +2573,263 @@ describe('field translation', () => {
});
});

describe('index scope', () => {
const serviceName = uuid();
const withScope = new Entity(
{
model: {
entity: serviceName,
service: 'test',
version: "1",
},
attributes: {
prop1: {
type: "string",
},
prop2: {
type: "string",
},
prop3: {
type: "string",
},
prop4: {
type: 'list',
items: {
type: 'string'
}
}
},
indexes: {
test: {
scope: 'scope1',
pk: {
field: "pk",
composite: ["prop1"],
},
sk: {
field: "sk",
composite: ["prop2"],
},
},
reverse: {
index: 'gsi1pk-gsi1sk-index',
scope: 'scope2',
pk: {
field: "gsi1pk",
composite: ["prop2"],
},
sk: {
field: "gsi1sk",
composite: ["prop1"],
},
},
},
},
{ table: "electro", client }
);

const withoutScope = new Entity(
{
model: {
entity: serviceName,
service: 'test',
version: "1",
},
attributes: {
prop1: {
type: "string",
},
prop2: {
type: "string",
},
prop3: {
type: "string",
},
prop4: {
type: 'list',
items: {
type: 'string'
}
}
},
indexes: {
test: {
pk: {
field: "pk",
composite: ["prop1"],
},
sk: {
field: "sk",
composite: ["prop2"],
},
},
reverse: {
index: 'gsi1pk-gsi1sk-index',
pk: {
field: "gsi1pk",
composite: ["prop2"],
},
sk: {
field: "gsi1sk",
composite: ["prop1"],
},
},
},
},
{ table: "electro", client }
);

it('should add scope value to all keys', () => {
const getParams = withScope.get({prop1: 'abc', prop2: 'def'}).params();
expect(getParams.Key.pk).to.equal('$test_scope1#prop1_abc');

const queryParams = withScope.query.test({prop1: 'abc'}).params();
expect(queryParams.ExpressionAttributeValues[':pk']).to.equal('$test_scope1#prop1_abc');

const queryParams2 = withScope.query.reverse({prop2: 'def'}).params();
expect(queryParams2.ExpressionAttributeValues[':pk']).to.equal('$test_scope2#prop2_def');

const scanParams = withScope.scan.params();
expect(scanParams.ExpressionAttributeValues[':pk']).to.equal('$test_scope1#prop1_');

const deleteParams = withScope.delete({prop1: 'abc', prop2: 'def'}).params();
expect(deleteParams.Key.pk).to.equal('$test_scope1#prop1_abc');

const removeParams = withScope.remove({prop1: 'abc', prop2: 'def'}).params();
expect(removeParams.Key.pk).to.equal('$test_scope1#prop1_abc');

const updateParams = withScope.update({prop1: 'abc', prop2: 'def'}).set({prop3: 'ghi'}).params();
expect(updateParams.Key.pk).to.equal('$test_scope1#prop1_abc');

const patchParams = withScope.patch({prop1: 'abc', prop2: 'def'}).set({prop3: 'ghi'}).params();
expect(patchParams.Key.pk).to.equal('$test_scope1#prop1_abc');

const putParams = withScope.put({prop1: 'abc', prop2: 'def'}).params();
expect(putParams.Item.pk).to.equal('$test_scope1#prop1_abc');

const createParams = withScope.create({prop1: 'abc', prop2: 'def'}).params();
expect(createParams.Item.pk).to.equal('$test_scope1#prop1_abc');

const upsertParams = withScope.upsert({prop1: 'abc', prop2: 'def'}).set({prop3: 'ghi'}).params();
expect(upsertParams.Key.pk).to.equal('$test_scope1#prop1_abc');

const batchGetParams = withScope.get([{prop1: 'abc', prop2: 'def'}]).params();
expect(batchGetParams[0].RequestItems.electro.Keys[0].pk).to.equal('$test_scope1#prop1_abc');

const batchDeleteParams = withScope.delete([{prop1: 'abc', prop2: 'def'}]).params();
expect(batchDeleteParams[0].RequestItems.electro[0].DeleteRequest.Key.pk).to.equal('$test_scope1#prop1_abc');

const batchPutParams = withScope.put([{prop1: 'abc', prop2: 'def'}]).params();
expect(batchPutParams[0].RequestItems.electro[0].PutRequest.Item.pk).to.equal('$test_scope1#prop1_abc');

const keys = withScope.conversions.fromComposite.toKeys({prop1: 'abc', prop2: 'def'});
expect(keys.pk).to.equal('$test_scope1#prop1_abc');
expect(keys.gsi1pk).to.equal('$test_scope2#prop2_def');

const keysComposite = withScope.conversions.fromKeys.toComposite(keys);
expect(keysComposite).to.deep.equal({prop1: 'abc', prop2: 'def'});

const indexKeys = withScope.conversions.byAccessPattern.test.fromComposite.toKeys({prop1: 'abc', prop2: 'def'});
expect(indexKeys.pk).to.equal('$test_scope1#prop1_abc');

const indexKeysComposite = withScope.conversions.byAccessPattern.test.fromKeys.toComposite(indexKeys);
expect(indexKeysComposite).to.deep.equal({prop1: 'abc', prop2: 'def'});

const reverseKeys = withScope.conversions.byAccessPattern.reverse.fromComposite.toKeys({prop1: 'abc', prop2: 'def'});
expect(reverseKeys.gsi1pk).to.equal('$test_scope2#prop2_def');
expect(keys.pk).to.equal('$test_scope1#prop1_abc');

const reverseKeysComposite = withScope.conversions.byAccessPattern.reverse.fromKeys.toComposite(reverseKeys);
expect(reverseKeysComposite).to.deep.equal({prop1: 'abc', prop2: 'def'});
});

it('should query scoped indexes without issue', async () => {
const prop1 = uuid();
const prop2 = uuid();

const record1 = {
prop1,
prop2,
prop3: uuid(),
};

const record2 = {
prop1,
prop2,
prop3: uuid(),
};

const [
scopeRecord,
withoutScopeRecord
] = await Promise.all([
withScope.create(record1).go(),
withoutScope.create(record2).go(),
]);

expect(scopeRecord.data).to.deep.equal(record1);
expect(withoutScopeRecord.data).to.deep.equal(record2);

const scopeGet = await withScope.get({prop1, prop2}).go();
expect(scopeGet.data).to.deep.equal(record1);

const withoutScopeGet = await withoutScope.get({prop1, prop2}).go();
expect(withoutScopeGet.data).to.deep.equal(record2);

const scopeQuery = await withScope.query.test({prop1}).go();
expect(scopeQuery.data).to.deep.equal([record1]);

const withoutScopeQuery = await withoutScope.query.test({prop1}).go();
expect(withoutScopeQuery.data).to.deep.equal([record2]);

const reverseScopeQuery = await withScope.query.reverse({prop2}).go();
expect(reverseScopeQuery.data).to.deep.equal([record1]);

const reverseWithoutScopeQuery = await withoutScope.query.reverse({prop2}).go();
expect(reverseWithoutScopeQuery.data).to.deep.equal([record2]);

const batchGetScopeRecords = await withScope.get([{prop1, prop2}]).go();
expect(batchGetScopeRecords.data).to.deep.equal([record1]);

const batchGetWithoutScopeRecords = await withoutScope.get([{prop1, prop2}]).go();
expect(batchGetWithoutScopeRecords.data).to.deep.equal([record2]);

const updatedScopeRecord = await withScope.update({prop1, prop2}).set({prop4: ['updated1']}).go({response: 'all_new'});
expect(updatedScopeRecord.data).to.deep.equal({
...record1,
prop4: ['updated1'],
});

const updatedWithoutScopeRecord = await withoutScope.update({prop1, prop2}).set({prop4: ['updated2']}).go({response: 'all_new'});
expect(updatedWithoutScopeRecord.data).to.deep.equal({
...record2,
prop4: ['updated2'],
});

const patchedScopeRecord = await withScope.patch({prop1, prop2}).append({prop4: ['patched1']}).go({response: 'all_new'});
expect(patchedScopeRecord.data).to.deep.equal({
...record1,
prop4: ['updated1', 'patched1'],
});

const patchedWithoutScopeRecord = await withoutScope.patch({prop1, prop2}).append({prop4: ['patched2']}).go({response: 'all_new'});
expect(patchedWithoutScopeRecord.data).to.deep.equal({
...record2,
prop4: ['updated2', 'patched2'],
});

const upsertedScopeRecord = await withScope.upsert({prop1, prop2}).append({prop4: ['upserted1']}).go({response: 'all_new'});
expect(upsertedScopeRecord.data).to.deep.equal({
...record1,
prop4: ['updated1', 'patched1', 'upserted1'],
});

const upsertedWithoutScopeRecord = await withoutScope.upsert({prop1, prop2}).append({prop4: ['upserted2']}).go({response: 'all_new'});
expect(upsertedWithoutScopeRecord.data).to.deep.equal({
...record2,
prop4: ['updated2', 'patched2', 'upserted2'],
});
});
});


Loading

0 comments on commit 01cc0b7

Please sign in to comment.