Skip to content
This repository was archived by the owner on Jun 19, 2023. It is now read-only.

Commit 7da6b78

Browse files
aregierjeskew
authored andcommitted
Proof-of-concept for #113 (ensureGlobalSecondaryIndexExists method) (#114)
* Proof-of-concept for #113 (ensureGlobalSecondaryIndexExists method) * Defensive programming; waitFor('tableExists') already polls for ACTIVE table status, but returns before the new GSI finishes backfilling * Move indexName argument before options bag in GlobalSecondaryIndex decls * const correctness from PR feedback * Add unit tests for createGlobalSecondaryIndex / ensureGlobalSecondaryIndexExists * ignore vscode generated debugging launch configs, etc
1 parent 060f78a commit 7da6b78

File tree

3 files changed

+311
-1
lines changed

3 files changed

+311
-1
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pids
1111
*.pid
1212
*.seed
1313
*.pid.lock
14+
.vscode
1415
lib-cov
1516
coverage
1617
build

packages/dynamodb-data-mapper/src/DataMapper.spec.ts

Lines changed: 229 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
UpdateExpression,
1414
} from "@aws/dynamodb-expressions";
1515
import {ItemNotFoundException} from "./ItemNotFoundException";
16-
import {BatchGetOptions, ParallelScanState} from './index';
16+
import {BatchGetOptions, ParallelScanState, GlobalSecondaryIndexOptions} from './index';
1717

1818
type BinaryValue = ArrayBuffer|ArrayBufferView;
1919

@@ -664,6 +664,105 @@ describe('DataMapper', () => {
664664
}
665665
});
666666

667+
describe('#createGlobalSecondaryIndex', () => {
668+
const waitPromiseFunc = jest.fn(() => Promise.resolve());
669+
const updateTablePromiseFunc = jest.fn(() => Promise.resolve({}));
670+
const mockDynamoDbClient = {
671+
config: {},
672+
updateTable: jest.fn(() => ({promise: updateTablePromiseFunc})),
673+
waitFor: jest.fn(() => ({promise: waitPromiseFunc})),
674+
};
675+
676+
beforeEach(() => {
677+
updateTablePromiseFunc.mockClear();
678+
mockDynamoDbClient.updateTable.mockClear();
679+
waitPromiseFunc.mockClear();
680+
mockDynamoDbClient.waitFor.mockClear();
681+
});
682+
683+
const mapper = new DataMapper({
684+
client: mockDynamoDbClient as any,
685+
});
686+
687+
class Item {
688+
get [DynamoDbTable]() { return 'foo' }
689+
690+
get [DynamoDbSchema]() {
691+
return {
692+
id: {
693+
type: 'String',
694+
keyType: 'HASH'
695+
},
696+
description: {
697+
type: 'String',
698+
indexKeyConfigurations: {
699+
DescriptionIndex: 'HASH'
700+
}
701+
}
702+
};
703+
}
704+
}
705+
706+
const DescriptionIndex: GlobalSecondaryIndexOptions = {
707+
projection: 'all',
708+
readCapacityUnits: 1,
709+
type: 'global',
710+
writeCapacityUnits: 1
711+
};
712+
713+
it('should make and send an UpdateTable request', async () => {
714+
await mapper.createGlobalSecondaryIndex(Item, 'DescriptionIndex', {
715+
indexOptions: {
716+
DescriptionIndex
717+
},
718+
readCapacityUnits: 5,
719+
writeCapacityUnits: 5,
720+
});
721+
722+
expect(mockDynamoDbClient.updateTable.mock.calls).toEqual([
723+
[
724+
{
725+
TableName: 'foo',
726+
AttributeDefinitions: [
727+
{
728+
AttributeName: 'id',
729+
AttributeType: 'S'
730+
},
731+
{
732+
AttributeName: 'description',
733+
AttributeType: 'S'
734+
}
735+
],
736+
GlobalSecondaryIndexUpdates: [
737+
{
738+
Create: {
739+
IndexName: 'DescriptionIndex',
740+
KeySchema: [
741+
{
742+
AttributeName: 'description',
743+
KeyType: 'HASH'
744+
}
745+
],
746+
Projection: {
747+
ProjectionType: 'ALL'
748+
},
749+
ProvisionedThroughput: {
750+
ReadCapacityUnits: 1,
751+
WriteCapacityUnits: 1
752+
}
753+
}
754+
}
755+
],
756+
},
757+
]
758+
]);
759+
760+
expect(mockDynamoDbClient.waitFor.mock.calls).toEqual([
761+
[ 'tableExists', { TableName: 'foo' } ],
762+
]);
763+
});
764+
})
765+
667766
describe('#createTable', () => {
668767
const waitPromiseFunc = jest.fn(() => Promise.resolve());
669768
const createTablePromiseFunc = jest.fn(() => Promise.resolve({}));
@@ -1487,6 +1586,135 @@ describe('DataMapper', () => {
14871586
});
14881587
});
14891588

1589+
1590+
describe('#ensureGlobalSecondaryIndexExists', () => {
1591+
const waitPromiseFunc = jest.fn(() => Promise.resolve());
1592+
const describeTablePromiseFunc = jest.fn(() => Promise.resolve({
1593+
Table: {
1594+
TableStatus: 'ACTIVE',
1595+
GlobalSecondaryIndexes: [
1596+
{
1597+
IndexName: 'DescriptionIndex'
1598+
}
1599+
],
1600+
}
1601+
}));
1602+
const mockDynamoDbClient = {
1603+
config: {},
1604+
describeTable: jest.fn(() => ({promise: describeTablePromiseFunc})),
1605+
waitFor: jest.fn(() => ({promise: waitPromiseFunc})),
1606+
};
1607+
1608+
const mapper = new DataMapper({
1609+
client: mockDynamoDbClient as any,
1610+
});
1611+
mapper.createGlobalSecondaryIndex = jest.fn(() => Promise.resolve());
1612+
1613+
beforeEach(() => {
1614+
(mapper.createGlobalSecondaryIndex as any).mockClear();
1615+
mockDynamoDbClient.describeTable.mockClear();
1616+
waitPromiseFunc.mockClear();
1617+
mockDynamoDbClient.waitFor.mockClear();
1618+
});
1619+
1620+
let tableName = 'foo';
1621+
let schema = {
1622+
id: {
1623+
type: 'String',
1624+
keyType: 'HASH'
1625+
},
1626+
description: {
1627+
type: 'String',
1628+
indexKeyConfigurations: {
1629+
DescriptionIndex: 'HASH'
1630+
}
1631+
}
1632+
};
1633+
1634+
class Item {
1635+
get [DynamoDbTable]() { return tableName }
1636+
1637+
get [DynamoDbSchema]() { return schema; }
1638+
}
1639+
1640+
const DescriptionIndex: GlobalSecondaryIndexOptions = {
1641+
projection: 'all',
1642+
readCapacityUnits: 1,
1643+
type: 'global',
1644+
writeCapacityUnits: 1
1645+
};
1646+
1647+
it(
1648+
'should resolve immediately if the table exists, is active, and the GSI already exists',
1649+
async () => {
1650+
await mapper.ensureGlobalSecondaryIndexExists(Item, 'DescriptionIndex', {
1651+
indexOptions: {
1652+
DescriptionIndex
1653+
},
1654+
readCapacityUnits: 5,
1655+
writeCapacityUnits: 5,
1656+
});
1657+
1658+
expect(mockDynamoDbClient.describeTable.mock.calls).toEqual([
1659+
[{ TableName: tableName }]
1660+
]);
1661+
1662+
expect(mockDynamoDbClient.waitFor.mock.calls.length).toBe(0);
1663+
expect((mapper.createGlobalSecondaryIndex as any).mock.calls.length).toBe(0);
1664+
}
1665+
);
1666+
1667+
it(
1668+
'should attempt to create the index if the table exists in the ACTIVE state but the specified index does not exist',
1669+
async () => {
1670+
describeTablePromiseFunc.mockImplementationOnce(() => Promise.resolve({
1671+
Table: { TableStatus: 'ACTIVE' }
1672+
}))
1673+
await mapper.ensureGlobalSecondaryIndexExists(Item, 'DescriptionIndex', {
1674+
indexOptions: {
1675+
DescriptionIndex
1676+
},
1677+
readCapacityUnits: 5,
1678+
writeCapacityUnits: 5,
1679+
});
1680+
1681+
expect(mockDynamoDbClient.describeTable.mock.calls).toEqual([
1682+
[{ TableName: tableName }]
1683+
]);
1684+
1685+
expect((mapper.createGlobalSecondaryIndex as any).mock.calls.length).toBe(1);
1686+
expect(mockDynamoDbClient.waitFor.mock.calls.length).toBe(0);
1687+
}
1688+
);
1689+
1690+
it(
1691+
'should rethrow if "describeTable" throws a "ResourceNotFoundException"',
1692+
async () => {
1693+
const expectedError = new Error('No such table!');
1694+
expectedError.name = 'ResourceNotFoundException';
1695+
describeTablePromiseFunc.mockImplementationOnce(async () => {
1696+
throw expectedError;
1697+
});
1698+
1699+
await expect(mapper.ensureGlobalSecondaryIndexExists(Item, 'DescriptionIndex', {
1700+
indexOptions: {
1701+
DescriptionIndex
1702+
},
1703+
readCapacityUnits: 5,
1704+
writeCapacityUnits: 5,
1705+
}))
1706+
.rejects
1707+
.toMatchObject(expectedError);
1708+
1709+
expect(mockDynamoDbClient.describeTable.mock.calls).toEqual([
1710+
[{ TableName: tableName }]
1711+
]);
1712+
1713+
expect(mockDynamoDbClient.waitFor.mock.calls.length).toBe(0);
1714+
}
1715+
);
1716+
});
1717+
14901718
describe('#ensureTableExists', () => {
14911719
const waitPromiseFunc = jest.fn(() => Promise.resolve());
14921720
const describeTablePromiseFunc = jest.fn(() => Promise.resolve({

packages/dynamodb-data-mapper/src/DataMapper.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import {
7676
import {
7777
AttributeDefinition,
7878
AttributeMap,
79+
CreateGlobalSecondaryIndexAction,
7980
DeleteItemInput,
8081
GetItemInput,
8182
GlobalSecondaryIndexList,
@@ -306,6 +307,86 @@ export class DataMapper {
306307
}
307308
}
308309

310+
/**
311+
* Perform a UpdateTable operation using the schema accessible via the
312+
* {DynamoDbSchema} property, the table name accessible via the
313+
* {DynamoDbTable} property on the prototype of the constructor supplied,
314+
* and the specified global secondary index name.
315+
*
316+
* The promise returned by this method will not resolve until the table is
317+
* active and ready for use.
318+
*
319+
* @param valueConstructor The constructor used for values in the table.
320+
* @param options Options to configure the UpdateTable operation
321+
*/
322+
async createGlobalSecondaryIndex(
323+
valueConstructor: ZeroArgumentsConstructor<any>,
324+
indexName: string,
325+
{
326+
indexOptions = {},
327+
}: CreateTableOptions
328+
) {
329+
const schema = getSchema(valueConstructor.prototype);
330+
const { attributes, indexKeys } = keysFromSchema(schema);
331+
const TableName = this.getTableName(valueConstructor.prototype);
332+
333+
const globalSecondaryIndexes = indexDefinitions(indexKeys, indexOptions, schema).GlobalSecondaryIndexes;
334+
const indexSearch = globalSecondaryIndexes === undefined ? [] : globalSecondaryIndexes.filter(function(index) {
335+
return index.IndexName === indexName;
336+
});
337+
const indexDefinition: CreateGlobalSecondaryIndexAction = indexSearch[0];
338+
339+
const {
340+
TableDescription: {TableStatus} = {TableStatus: 'UPDATING'}
341+
} = await this.client.updateTable({
342+
GlobalSecondaryIndexUpdates: [{
343+
Create: {
344+
...indexDefinition
345+
}
346+
}],
347+
TableName,
348+
AttributeDefinitions: attributeDefinitionList(attributes),
349+
}).promise();
350+
351+
if (TableStatus !== 'ACTIVE') {
352+
await this.client.waitFor('tableExists', {TableName}).promise();
353+
}
354+
}
355+
356+
/**
357+
* If the index does not already exist, perform a UpdateTable operation
358+
* using the schema accessible via the {DynamoDbSchema} property, the
359+
* table name accessible via the {DynamoDbTable} property on the prototype
360+
* of the constructor supplied, and the index name.
361+
*
362+
* The promise returned by this method will not resolve until the table is
363+
* active and ready for use. Note that the index will not be usable for queries
364+
* until it has finished backfilling
365+
*
366+
* @param valueConstructor The constructor used for values in the table.
367+
* @param options Options to configure the UpdateTable operation
368+
*/
369+
async ensureGlobalSecondaryIndexExists(
370+
valueConstructor: ZeroArgumentsConstructor<any>,
371+
indexName: string,
372+
options: CreateTableOptions
373+
) {
374+
const TableName = this.getTableName(valueConstructor.prototype);
375+
try {
376+
const {
377+
Table: {GlobalSecondaryIndexes } = {GlobalSecondaryIndexes: []}
378+
} = await this.client.describeTable({TableName}).promise();
379+
const indexSearch = GlobalSecondaryIndexes === undefined ? [] : GlobalSecondaryIndexes.filter(function(index) {
380+
return index.IndexName === indexName;
381+
});
382+
if (indexSearch.length === 0) {
383+
await this.createGlobalSecondaryIndex(valueConstructor, indexName, options);
384+
}
385+
} catch (err) {
386+
throw err;
387+
}
388+
}
389+
309390
/**
310391
* Perform a DeleteItem operation using the schema accessible via the
311392
* {DynamoDbSchema} property and the table name accessible via the

0 commit comments

Comments
 (0)