From 39363b62af02f59c6ff7d2024a063aef002a1ff7 Mon Sep 17 00:00:00 2001 From: Tinashe Musonza Date: Wed, 18 Oct 2023 18:42:43 -0400 Subject: [PATCH] First --- .github/workflows/ci.yml | 43 ++++ .gitignore | 25 +++ LICENSE | 21 ++ README.md | 180 ++++++++++++++++ composer.json | 69 +++++++ config/dynamo-breeze.php | 49 +++++ phpstan.neon.dist | 11 + phpunit.xml.dist | 22 ++ psalm.xml | 15 ++ rector.php | 23 +++ src/Commands/SetupDynamoDbTables.php | 146 +++++++++++++ src/Configuration.php | 17 ++ src/DynamoBreezeResult.php | 30 +++ src/DynamoBreezeService.php | 195 ++++++++++++++++++ src/DynamoBreezeServiceProvider.php | 40 ++++ src/Facades/DynamoBreeze.php | 14 ++ src/InteractsWithDynamoDB.php | 87 ++++++++ .../Database/Seeders/DynamoDbTableSeeder.php | 48 +++++ tests/Feature/ExampleFeatureTest.php | 174 ++++++++++++++++ tests/FeatureTestCase.php | 38 ++++ tests/TestCase.php | 56 +++++ tests/Traits/Helpers.php | 159 ++++++++++++++ tests/Unit/DynamoBreezeServiceTest.php | 66 ++++++ tests/config/access_patterns.php | 86 ++++++++ tests/config/config.php | 74 +++++++ tests/config/gsis.php | 16 ++ 26 files changed, 1704 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/dynamo-breeze.php create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml.dist create mode 100644 psalm.xml create mode 100644 rector.php create mode 100644 src/Commands/SetupDynamoDbTables.php create mode 100644 src/Configuration.php create mode 100644 src/DynamoBreezeResult.php create mode 100644 src/DynamoBreezeService.php create mode 100644 src/DynamoBreezeServiceProvider.php create mode 100644 src/Facades/DynamoBreeze.php create mode 100644 src/InteractsWithDynamoDB.php create mode 100644 tests/Database/Seeders/DynamoDbTableSeeder.php create mode 100644 tests/Feature/ExampleFeatureTest.php create mode 100644 tests/FeatureTestCase.php create mode 100644 tests/TestCase.php create mode 100644 tests/Traits/Helpers.php create mode 100644 tests/Unit/DynamoBreezeServiceTest.php create mode 100644 tests/config/access_patterns.php create mode 100644 tests/config/config.php create mode 100644 tests/config/gsis.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..72b20d0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: PHP Composer + +on: + push: + branches: [ "master", "develop"] + pull_request: + branches: [ "master" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Setup DynamoDB Local + uses: rrainn/dynamodb-action@v2.0.1 + with: + sharedDb: true + port: 8000 + cors: '*' + - name: Run tests + run: vendor/bin/phpunit --testdox + \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18c621d --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +/vendor/ +node_modules/ +npm-debug.log +yarn-error.log +.idea +composer.lock + +# Laravel 4 specific +bootstrap/compiled.php +app/storage/ + +# Laravel 5 & Lumen specific +public/storage +public/hot + +# Laravel 5 & Lumen specific with changed public path +public_html/storage +public_html/hot + +storage/*.key +.env +Homestead.yaml +Homestead.json +/.vagrant +.phpunit.result.cache diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3a8b2f3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Tinashe Musonza + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d83a005 --- /dev/null +++ b/README.md @@ -0,0 +1,180 @@ +# DynamoBreeze + +A Laravel package for easily interacting with Amazon DynamoDB using a single-table approach and a facade for streamlined developer experience. + +## Table of Contents + +- [Installation](#installation) +- [Configuration](#configuration) +- [Example Usage](#example-usage) +- [Testing](#testing) +- [Contribution](#contribution) +- [License](#license) +- [Contact](#contact) + +## Installation + +### Via Composer + +```bash +composer require musonza/dynamo-breeze +``` + +### Configuration + +After installing the package, publish the configuration file by running: + +```bash +php artisan vendor:publish --provider="Musonza\DynamoBreeze\DynamoBreezeServiceProvider" --tag="config" +``` + +## Example Usage + +With the DynamoBreeze facade, you can interact with DynamoDB in a more expressive and straightforward manner. Here are examples demonstrating its usage: + +Below is a fundamental example of how to interact with DynamoDB using the DynamoBreeze package. DynamoBreeze is designed to accommodate a single-table design principle, which means you can create a generic table that can host multiple entities and utilize various access patterns efficiently. + +In your `dynamo-breeze.php` config file: + +```php +return [ + // 'tables' holds the configuration for all the DynamoDB tables that this package will interact with. + 'tables' => [ + // Each table has its own configuration nested under a unique logical identifier used in your application code to reference the table configuration. + 'social_media' => [ + /* + * 'table_name' is the name of the DynamoDB table as defined in AWS. + */ + 'table_name' => 'SocialMediaTable', + + /* + * 'partition_key' specifies the primary key attribute name of the table. + */ + 'partition_key' => 'PK', + + /* + * 'sort_key' specifies the sort key attribute name of the table. + * If a table doesn't have a sort key, you can omit this field. + */ + 'sort_key' => 'SK', + + /* + * 'attributes' define the attributes and their types that the model will interact with. + * It's used for actions like creating tables or validating input. + * Common types: 'S' => String, 'N' => Number, 'B' => Binary. + */ + 'attributes' => [ + 'PK' => 'S', + 'SK' => 'S', + // ... + ], + + /* + * 'access_patterns' define various access patterns to use with the table. + * Each access pattern has a unique name and associated settings. + */ + 'access_patterns' => [ + 'FetchUserPosts' => [ + 'gsi_name' => null, + 'key_condition_expression' => 'PK = :pk_val AND begins_with(SK, :sk_prefix_val)', + 'expression_attribute_values' => [ + ':pk_val' => ['S' => 'USER#'], + ':sk_prefix_val' => ['S' => 'POST#'], + ], + ], + 'FetchPostComments' => [ + 'gsi_name' => null, + 'key_condition_expression' => 'PK = :pk_val AND begins_with(SK, :sk_prefix_val)', + 'expression_attribute_values' => [ + ':pk_val' => ['S' => 'POST#'], + ':sk_prefix_val' => ['S' => 'COMMENT#'], + ], + ], + // ... + ], + // ... additional settings for the table + ], + + /* + * Additional tables, such as 'products', can have similar configurations. + * Adapt each table configuration to match its structure and access patterns in DynamoDB. + */ + 'products' => [ + // ... configuration for the 'products' table + ], + // ... configurations for other tables + ], + + /* + * 'sdk' holds the configuration for the AWS SDK. + */ + 'sdk' => [ + 'region' => env('DYNAMODB_REGION', 'us-west-2'), + 'version' => env('DYNAMODB_VERSION', 'latest'), + 'credentials' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + ], + ], +]; +``` + +#### Fetch User Posts + +```php +use Musonza\DynamoBreeze\Facades\DynamoBreeze; + +$result = DynamoBreeze::table('social_media') + ->accessPattern('FetchUserPosts', [ + ':pk_val' => 'USER#' . $userId, + ':sk_val' => 'POST#', + ]) + ->get(); +``` + +#### Fetch Post Comments + +```php +$comments = DynamoBreeze::table('social_media') + ->accessPattern('FetchPostComments', [ + ':pk_val' => 'POST#' . $postId, + ':sk_val' => 'COMMENT#', + ]) + ->get(); +``` + +#### Fetch Post Likes + +```php +$likes = DynamoBreeze::table('social_media') + ->accessPattern('FetchPostLikes', [ + ':pk_val' => 'POST#' . $postId, + ':sk_val' => 'LIKE#', + ]) + ->get(); +``` + +#### Fetch Conversation Messages + +```php +$messages = DynamoBreeze::table('social_media') + ->accessPattern('FetchConversationMessages', [ + ':pk_val' => 'CONVERSATION#' . $conversationId, + ':sk_val' => 'MESSAGE#', + ]) + ->get(); +``` + +Ensure that keys, table names, and access patterns align with your actual DynamoDB setup to avoid discrepancies or errors. + +## Testing + +`composer test` + +## Contribution + +Contributions are welcome and will be fully credited. Please see [CONTRIBUTING](.github/CONTRIBUTING.md) and [CODE_OF_CONDUCT](.github/CODE_OF_CONDUCT.md) for details. + +## License + +DynamoBreeze is open-sourced software licensed under the [MIT license](LICENSE.md). diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..3c58555 --- /dev/null +++ b/composer.json @@ -0,0 +1,69 @@ +{ + "name": "musonza/dynamo-breeze", + "description": "A Laravel package for easy interaction with DynamoDB using a single-table approach.", + "type": "library", + "license": "MIT", + "require": { + "php": "^7.4 || ^8.0", + "illuminate/support": "^9.47.0 || ^10.0.0 || ^11.0.0", + "aws/aws-sdk-php": "^3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.4", + "orchestra/testbench": "^8.0", + "phpstan/phpstan": "^1.9", + "nunomaduro/larastan": "^2.6", + "vimeo/psalm": "^5.4", + "rector/rector": "^0.15.2", + "laravel/pint": "^1.3", + "tuupola/ksuid": "^2.1" + }, + "autoload": { + "psr-4": { + "Musonza\\DynamoBreeze\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Musonza\\DynamoBreeze\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Musonza\\DynamoBreeze\\DynamoBreezeServiceProvider" + ] + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "scripts": { + "test:lint-fix": "./vendor/bin/pint", + "test:psalm": "vendor/bin/psalm", + "test:unit": "vendor/bin/phpunit --filter=unit", + "test:feature": "vendor/bin/phpunit --filter=feature", + "test:analyse": "vendor/bin/phpstan analyse --memory-limit=2G --ansi", + "test:lint": "./vendor/bin/pint --test", + "test:refactor": "rector --dry-run", + "test": [ + "@test:lint-fix", + "@test:refactor", + "@test:analyse", + "@test:psalm", + "@test:unit", + "@test:feature" + ] + }, + "config": { + "sort-packages": true + }, + "authors": [ + { + "name": "Tinashe Musonza" + } + ], + "support": { + "issues": "https://github.com/musonza/dynamo-breeze/issues", + "source": "https://github.com/musonza/dynamo-breeze" + } +} diff --git a/config/dynamo-breeze.php b/config/dynamo-breeze.php new file mode 100644 index 0000000..e3abd63 --- /dev/null +++ b/config/dynamo-breeze.php @@ -0,0 +1,49 @@ + [ + 'example_table' => [ + 'table_name' => env('DYNAMODB_TABLE', 'ExampleTable'), + 'partition_key' => 'exampleId', + 'sort_key' => null, + 'attributes' => [ + 'exampleId' => 'S', + ], + 'access_patterns' => [ + 'ExampleAccessPattern' => [ + 'gsi_name' => 'GSI_Example', + 'key_condition_expression' => 'exampleId = :exampleId_val', + ], + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | AWS SDK Configuration + |-------------------------------------------------------------------------- + | + | Here you may configure AWS SDK settings, which will be used when + | interacting with AWS services like DynamoDB. + | + */ + + 'sdk' => [ + 'region' => env('DYNAMODB_REGION', 'us-west-2'), + 'version' => env('DYNAMODB_VERSION', 'latest'), + 'credentials' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + ], + ], +]; diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..f55243c --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,11 @@ +includes: + - ./vendor/nunomaduro/larastan/extension.neon + +parameters: + + paths: + - src + + # Level 9 is the highest level + level: 6 + checkMissingIterableValueType: false \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..059f78e --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,22 @@ + + + + + src/ + + + + + ./tests/Feature + + + ./tests/Unit + + + + + + + + + \ No newline at end of file diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..c5a0740 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..1262ccb --- /dev/null +++ b/rector.php @@ -0,0 +1,23 @@ +paths([ + __DIR__.'/config', + __DIR__.'/src', + __DIR__.'/tests', + ]); + + // register a single rule + $rectorConfig->rule(InlineConstructorDefaultToPropertyRector::class); + + // define sets of rules + // $rectorConfig->sets([ + // LevelSetList::UP_TO_PHP_80 + // ]); +}; diff --git a/src/Commands/SetupDynamoDbTables.php b/src/Commands/SetupDynamoDbTables.php new file mode 100644 index 0000000..2cba5ad --- /dev/null +++ b/src/Commands/SetupDynamoDbTables.php @@ -0,0 +1,146 @@ +dynamoDb = $dynamoDb; + $tablesConfig = config('dynamo-breeze.tables'); + + foreach ($tablesConfig as $tableConfig) { + if ($create) { + $this->createTable($tableConfig); + } else { + $this->deleteTable($tableConfig); + } + } + + return 1; + } + + public function deleteTable(array $tableConfig): void + { + $this->dynamoDb->deleteTable([ + 'TableName' => $tableConfig['table_name'], + ]); + } + + protected function createTable(array $tableConfig): void + { + $args = [ + 'TableName' => $tableConfig['table_name'], + 'AttributeDefinitions' => $this->getAttributeDefinitions($tableConfig['attributes'], $tableConfig['global_secondary_indexes'] ?? []), + 'KeySchema' => $this->getKeySchema($tableConfig['partition_key'], $tableConfig['sort_key'] ?? null), + 'ProvisionedThroughput' => [ + 'ReadCapacityUnits' => $tableConfig['read_capacity_units'] ?? 5, + 'WriteCapacityUnits' => $tableConfig['write_capacity_units'] ?? 5, + ], + ]; + + $gsis = $this->transformGlobalSecondaryIndexes($tableConfig['global_secondary_indexes'] ?? []); + + if (! empty($gsis)) { + $args['GlobalSecondaryIndexes'] = $gsis['GlobalSecondaryIndexes']; + } + + $this->dynamoDb->createTable($args); + + $this->dynamoDb->waitUntil('TableExists', [ + 'TableName' => $tableConfig['table_name'], + ]); + } + + public function transformGlobalSecondaryIndexes(array $gsiConfig): array + { + if (empty($gsiConfig)) { + return []; + } + + $transformedGSIs = []; + $gsiAttributeDefinitions = []; + + foreach ($gsiConfig as $gsi) { + $transformedGSIs[] = [ + 'IndexName' => $gsi['index_name'], + 'KeySchema' => $this->getKeySchema( + $gsi['key_schema']['partition_key'], + $gsi['key_schema']['sort_key'] + ), + // Assuming all attributes are to be included in the GSI + 'Projection' => [ + 'ProjectionType' => 'ALL', + ], + // Assuming a provisioned throughput of 5 read and 5 write capacity units for each GSI + 'ProvisionedThroughput' => [ + 'ReadCapacityUnits' => 5, + 'WriteCapacityUnits' => 5, + ], + ]; + + foreach ($gsi['attributes'] as $attribute => $type) { + $gsiAttributeDefinitions[] = [ + 'AttributeName' => $attribute, + 'AttributeType' => $type, + ]; + } + } + + return [ + 'GlobalSecondaryIndexes' => $transformedGSIs, + 'GSIAttributeDefinitions' => $gsiAttributeDefinitions, + ]; + } + + protected function getAttributeDefinitions(array $attributes, array $globalSecondaryIndexes = []): array + { + $gsiAttributes = Collection::make($globalSecondaryIndexes)->reduce(function ($carry, $gsi) { + foreach ($gsi['key_schema'] as $attributeName) { + // TODO Assuming all GSI attributes are of type 'S' (string) + $carry[$attributeName] = 'S'; + } + + return $carry; + }, []); + + // Merge main attributes and GSI attributes + $allAttributes = array_merge($attributes, $gsiAttributes); + + return Collection::make($allAttributes)->map(function ($type, $attribute) { + return [ + 'AttributeName' => $attribute, + 'AttributeType' => $type, + ]; + })->values()->all(); + } + + protected function getKeySchema(string $partitionKey, ?string $sortKey): array + { + $keySchema = [ + [ + 'AttributeName' => $partitionKey, + 'KeyType' => 'HASH', + ], + ]; + + if ($sortKey) { + $keySchema[] = [ + 'AttributeName' => $sortKey, + 'KeyType' => 'RANGE', + ]; + } + + return $keySchema; + } +} diff --git a/src/Configuration.php b/src/Configuration.php new file mode 100644 index 0000000..cbdfdb1 --- /dev/null +++ b/src/Configuration.php @@ -0,0 +1,17 @@ +awsResult = $awsResult; + } + + public function getItems(): ?array + { + return $this->awsResult->get('Items'); + } + + public function getCount(): ?int + { + return $this->awsResult->get('Count'); + } + + public function getRawResult(): Result + { + return $this->awsResult; + } +} diff --git a/src/DynamoBreezeService.php b/src/DynamoBreezeService.php new file mode 100644 index 0000000..21bf41b --- /dev/null +++ b/src/DynamoBreezeService.php @@ -0,0 +1,195 @@ +dynamoDb = $dynamoDb; + $this->config = $config; + $this->marshaler = $marshaler; + } + + public function getClient(): DynamoDbClient + { + return $this->dynamoDb; + } + + public function table(string $tableIdentifier): self + { + $this->tableIdentifier = $tableIdentifier; + + if (! isset($this->config['tables'][$tableIdentifier])) { + throw new \Exception("Table identifier {$tableIdentifier} is not defined in config"); + } + + $this->tableName = $this->config['tables'][$tableIdentifier]['table_name']; + + return $this; + } + + public function getTableName(): string + { + return $this->tableName; + } + + public function accessPattern(string $patternName, array $dataProvider): self + { + $this->accessPatternConfig['patternName'] = $patternName; + $accessPattern = $this->config['tables'][$this->tableIdentifier]['access_patterns'][$patternName]; + + if (isset($accessPattern['expression_attribute_values'])) { + $expressionAttributes = $this->replacePlaceholders($accessPattern['expression_attribute_values'], $dataProvider); + $marshaledAttributes = $this->marshalExpressionAttributeValues($expressionAttributes); + $this->accessPatternConfig['expressions'] = $marshaledAttributes; + } + + return $this; + } + + public function getAccessPatternConfig(): array + { + return $this->accessPatternConfig; + } + + public function getAccessPatternName(): ?string + { + return $this->accessPatternConfig['patternName'] ?? null; + } + + public function getExpressions(): ?array + { + return $this->accessPatternConfig['expressions'] ?? null; + } + + public function replacePlaceholders(array $expressionAttributes, array $dataProvider): array + { + foreach ($expressionAttributes as &$attribute) { + foreach ($attribute as &$value) { + foreach ($dataProvider as $placeholder => $replacement) { + $value = str_replace("<$placeholder>", $replacement, $value); + } + } + } + + return $expressionAttributes; + } + + public function marshalExpressionAttributeValues(array $expressionAttributes): array + { + foreach ($expressionAttributes as $key => $attribute) { + foreach ($attribute as $type => $value) { + // Ensure the value is in the correct format and not already marshaled + if ($type === 'S' && is_string($value)) { + $expressionAttributes[$key] = [$type => $value]; + } elseif ($type === 'N' && is_numeric($value)) { + $expressionAttributes[$key] = [$type => (string) $value]; + } else { + // TODO test Maps etc + $expressionAttributes[$key] = $this->marshaler->marshalValue($value); + } + } + } + + return $expressionAttributes; + } + + public function get(): DynamoBreezeResult + { + $patternConfig = $this->config['tables'][$this->tableIdentifier]['access_patterns'][$this->getAccessPatternName()]; + + $query = [ + 'TableName' => $this->tableName, + ]; + + if (isset($patternConfig['key_condition_expression'])) { + $query['KeyConditionExpression'] = $patternConfig['key_condition_expression']; + } + + if (isset($patternConfig['expression_attribute_names'])) { + $query['ExpressionAttributeNames'] = $patternConfig['expression_attribute_names']; + } + + if ($this->getExpressions()) { + $query['ExpressionAttributeValues'] = $this->getExpressions(); + } + + if (isset($patternConfig['gsi_name'])) { + $query['IndexName'] = $patternConfig['gsi_name']; + } + + if (isset($patternConfig['projection_expression'])) { + $query['ProjectionExpression'] = $patternConfig['projection_expression']; + } + + if (isset($patternConfig['scan_index_forward'])) { + $query['ScanIndexForward'] = $patternConfig['scan_index_forward']; + } + + if (isset($patternConfig['limit'])) { + $query['Limit'] = $patternConfig['limit']; + } + + $result = $this->getClient()->query($query); + + return new DynamoBreezeResult($result); + } + + /** + * Retrieve records with conditions from DynamoDB. + * + * @return DynamoBreezeResult + */ + public function retrieveRecordsWithConditions(array $parameters) + { + $awsResult = $this->dynamoDb->query($parameters); + + return new DynamoBreezeResult($awsResult); + } + + /** + * Insert a record into DynamoDB. + * + * @return mixed + */ + public function insertRecord(array $parameters) + { + return $this->dynamoDb->putItem($parameters); + } + + /** + * Update a record in DynamoDB. + * + * @return mixed + */ + public function updateRecord(array $parameters) + { + return $this->dynamoDb->updateItem($parameters); + } + + /** + * Delete a record from DynamoDB. + * + * @return mixed + */ + public function deleteRecord(array $parameters) + { + return $this->dynamoDb->deleteItem($parameters); + } +} diff --git a/src/DynamoBreezeServiceProvider.php b/src/DynamoBreezeServiceProvider.php new file mode 100644 index 0000000..bd662fc --- /dev/null +++ b/src/DynamoBreezeServiceProvider.php @@ -0,0 +1,40 @@ +publishes([ + __DIR__.'/../config' => config_path(), + ], 'dynamo-breeze'); + + $this->app->singleton(DynamoDbClient::class, function (): DynamoDbClient { + return new DynamoDbClient([ + 'version' => 'latest', + 'region' => Configuration::getRegion(), + 'endpoint' => Configuration::getDynamodbEndpoint(), + ]); + }); + + $this->app->bind(DynamoBreezeService::class, function ($app) { + return new DynamoBreezeService( + $app->make(DynamoDbClient::class), + config('dynamo-breeze'), + new Marshaler() + ); + }); + } + + public function boot(): void + { + $this->publishes([ + __DIR__.'/../config' => config_path('dynamo-breeze.php'), + ], 'dynamo-breeze'); + } +} diff --git a/src/Facades/DynamoBreeze.php b/src/Facades/DynamoBreeze.php new file mode 100644 index 0000000..c73b53e --- /dev/null +++ b/src/Facades/DynamoBreeze.php @@ -0,0 +1,14 @@ +dynamoBreezeService)) { + $this->dynamoBreezeService = app(DynamoBreezeService::class); + } + + return $this->dynamoBreezeService; + } + + /** + * Ensure the using class has defined `getTable` method. + */ + protected function ensureTableMethodExists() + { + if (! method_exists($this, 'getTable')) { + throw new \RuntimeException( + sprintf('You must define a `getTable` method in %s to use the InteractsWithDynamoDB trait.', get_class($this)) + ); + } + } + + /** + * Fetch data based on provided parameters. + * + * @return mixed + */ + public function fetchData(array $parameters) + { + $this->ensureTableMethodExists(); + + return $this->getDynamoBreezeService()->retrieveRecordsWithConditions($parameters); + } + + /** + * Example method to retrieve data using a defined access pattern. + * + * @return mixed + */ + public function getByAccessPattern(string $patternName, array $keyConditions) + { + $this->ensureTableMethodExists(); + $config = config('dynamo-breeze.tables.'.$this->getTable()); + + $pattern = $config['access_patterns'][$patternName] ?? null; + + if (! $pattern) { + throw new \InvalidArgumentException("Access pattern [$patternName] is not defined."); + } + + $parameters = [ + 'TableName' => $config['table_name'], + 'KeyConditionExpression' => $pattern['key_condition_expression'], + 'ExpressionAttributeValues' => $this->prepareExpressionAttributeValues($keyConditions), + ]; + + if (isset($pattern['gsi_name'])) { + $parameters['IndexName'] = $pattern['gsi_name']; + } + + return $this->fetchData($parameters); + } + + /** + * Prepare the ExpressionAttributeValues parameter for DynamoDB. + */ + private function prepareExpressionAttributeValues(array $keyConditions): array + { + $expressionAttributeValues = []; + + foreach ($keyConditions as $key => $value) { + $expressionAttributeValues[":{$key}_val"] = $value; + } + + return $expressionAttributeValues; + } +} diff --git a/tests/Database/Seeders/DynamoDbTableSeeder.php b/tests/Database/Seeders/DynamoDbTableSeeder.php new file mode 100644 index 0000000..be972cd --- /dev/null +++ b/tests/Database/Seeders/DynamoDbTableSeeder.php @@ -0,0 +1,48 @@ +service = app(DynamoBreezeService::class); + } + + public function seed(array $items, string $tableName): void + { + foreach ($items as $item) { + $this->putItem($item, $tableName); + } + } + + protected function putItem(array $item, string $tableName): void + { + $dynamoDbItem = $this->formatItemForDynamoDb($item); + + $params = [ + 'TableName' => $tableName, + 'Item' => $dynamoDbItem, + ]; + + $this->service->getClient()->putItem($params); + } + + protected function formatItemForDynamoDb(array $item): array + { + $formattedItem = []; + + $marshaler = new Marshaler(); + + foreach ($item as $key => $value) { + $formattedItem[$key] = $marshaler->marshalValue($value); + } + + return $formattedItem; + } +} diff --git a/tests/Feature/ExampleFeatureTest.php b/tests/Feature/ExampleFeatureTest.php new file mode 100644 index 0000000..99f743f --- /dev/null +++ b/tests/Feature/ExampleFeatureTest.php @@ -0,0 +1,174 @@ + 'USER#1', 'SK' => 'POST#123', 'CategoryId' => 'A', 'Content' => 'Hello, World!', 'Timestamp' => time()], + ['PK' => 'USER#1', 'SK' => 'POST#124', 'CategoryId' => 'B', 'Content' => 'My second post!', 'Timestamp' => time()], + ]; + $this->seedDynamoDbTable($posts, self::TABLE_NAME); + } + + public function testCreatesTablesFromConfiguration() + { + $this->assertTableExists(self::TABLE_NAME); + } + + public function testFetchUserPosts(): void + { + $this->assertEquals(2, $this->fetchUserPosts(1)->getCount()); + } + + public function testFetchPostComments(): void + { + $comments = $this->generateCommentsData(1, 3); + $this->seedDynamoDbTable($comments, self::TABLE_NAME); + + $this->assertEquals(3, $this->fetchPostComments(1)->getCount()); + } + + public function testFetchPostLikes(): void + { + $likes = $this->generateLikesData(1, 2); + $this->seedDynamoDbTable($likes, self::TABLE_NAME); + + $this->assertEquals(2, $this->fetchPostLikes(1)->getCount()); + } + + public function testFetchPostsByDate(): void + { + $userId = 'User1'; + $now = Carbon::create(2023, 10, 15); + $date = $now->format('Y-m-d'); + + // Convert date to the start and end timestamps (inclusive) for that date + $startDateTime = $now->startOfDay(); + $endDateTime = (clone $now)->endOfDay(); + + // Seed Posts Data + $post1Timestamp = (clone $startDateTime)->subDays(5)->timestamp; // Different date + $post2Timestamp = (clone $startDateTime)->addMinutes(10)->timestamp; + $post3Timestamp = $now->timestamp; + + $postsData = [ + $this->createPostData($userId, $post1Timestamp, 'Post 1 Content', 1), + $this->createPostData($userId, $post2Timestamp, 'Post 2 Content', 2), + $this->createPostData($userId, $post3Timestamp, 'Post 3 Content', 3), + ]; + + $this->seedDynamoDbTable($postsData, self::TABLE_NAME); + + // Fetch Posts and Assert + $fetchedPosts = $this->fetchPostsByDate( + $userId, + $startDateTime->getTimestamp(), + $endDateTime->getTimestamp() + ); + + $this->assertEquals(2, $fetchedPosts->getCount()); + $this->assertPostsContainCorrectDates($fetchedPosts->getItems(), $date); + } + + public function testFetchConversationMessages(): void + { + $user1 = 'User1'; + $user2 = 'User2'; + $conversationId = self::directConversationId($user1, $user2); + + // Track user conversation + $participation = [ + $this->createParticipationData($user1, $conversationId, 'User2 Name', false), + $this->createParticipationData($user2, $conversationId, 'User1 Name', false), + ]; + + $this->seedDynamoDbTable($participation, self::TABLE_NAME); + + // Send messages and update last message content for use as snippet in conversation + // listing + $message1Timestamp = Carbon::now()->subMinutes(10)->timestamp; + $message2Timestamp = Carbon::now()->subMinutes(9)->timestamp; + + $lastMessageTimestamp = Carbon::now()->subMinutes(5)->timestamp; + $lastMessageContent = 'Message 3'; + $lastMessageSender = $user1; + + $messagesData = [ + $this->createMessageData($conversationId, $message1Timestamp, 'Message 1', $user1), + $this->createMessageData($conversationId, $message2Timestamp, 'Message 2', $user2), + $this->createMessageData($conversationId, $lastMessageTimestamp, $lastMessageContent, $lastMessageSender), + // Update last message information on user conversation + $this->createParticipationData( + $user1, + $conversationId, + 'User2 Name', + false, + $lastMessageTimestamp, + $lastMessageContent + ), + $this->createParticipationData( + $user2, + $conversationId, + 'User1 Name', + false, + $lastMessageTimestamp, + $lastMessageContent + ), + ]; + + $this->seedDynamoDbTable($messagesData, self::TABLE_NAME); + + $this->assertEquals(3, $this->fetchConversationMessages($conversationId)->getCount()); + } + + public function testFetchUserConversations(): void + { + $user1 = 'User1'; + $user2 = 'User2'; + $user3 = 'User3'; + $user4 = 'User4'; + + $conversationId = self::directConversationId($user1, $user2); + $conversation2Id = self::directConversationId($user1, $user3); + $conversation3Id = self::directConversationId($user1, $user4); + + // Track user conversation + $participation = [ + ['PK' => "USER#{$user1}", 'SK' => "CONVERSATION#{$conversationId}", 'ConversationName' => 'User2 Name', 'IsGroup' => false], + ['PK' => "USER#{$user2}", 'SK' => "CONVERSATION#{$conversationId}", 'ConversationName' => 'User1 Name', 'IsGroup' => false], + + ['PK' => "USER#{$user1}", 'SK' => "CONVERSATION#{$conversation2Id}", 'ConversationName' => 'User3 Name', 'IsGroup' => false], + ['PK' => "USER#{$user3}", 'SK' => "CONVERSATION#{$conversation2Id}", 'ConversationName' => 'User1 Name', 'IsGroup' => false], + + ['PK' => "USER#{$user1}", 'SK' => "CONVERSATION#{$conversation3Id}", 'ConversationName' => 'User4 Name', 'IsGroup' => false], + ['PK' => "USER#{$user4}", 'SK' => "CONVERSATION#{$conversation3Id}", 'ConversationName' => 'User1 Name', 'IsGroup' => false], + ]; + + $this->seedDynamoDbTable($participation, self::TABLE_NAME); + + $this->assertEquals(3, $this->fetchUserConversations($user1)->getCount()); + } + + public static function directConversationId(string $user1, string $user2): string + { + $id = strcmp($user1, $user2) < 0 + ? "{$user1}:{$user2}" + : "{$user2}:{$user1}"; + + return hash('sha256', $id); + } +} diff --git a/tests/FeatureTestCase.php b/tests/FeatureTestCase.php new file mode 100644 index 0000000..f89f5a0 --- /dev/null +++ b/tests/FeatureTestCase.php @@ -0,0 +1,38 @@ +client = app(DynamoDbClient::class); + $this->app->make(SetupDynamoDbTables::class)->handle($this->client); + } + + public function tearDown(): void + { + $this->app->make(SetupDynamoDbTables::class)->handle($this->client, false); + parent::tearDown(); + } + + public function seedDynamoDbTable(array $data, string $tableName): void + { + $seeder = app(DynamoDbTableSeeder::class); + $seeder->seed($data, $tableName); + } + + public function generateKSUID(Carbon $date): string + { + return KsuidFactory::fromTimestamp($date->getTimestamp())->string(); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..fcca3a5 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,56 @@ +checkEnvironment(); + parent::tearDown(); + } + + protected function setUp(): void + { + parent::setUp(); + $this->checkEnvironment(); + } + + private function checkEnvironment() + { + if (! app()->environment('testing')) { + throw new Exception('You can only run these tests in a testing environment'); + } + } + + protected function getPackageProviders($app): array + { + return [ + DynamoBreezeServiceProvider::class, + ]; + } + + protected function getPackageAliases($app) + { + return [ + 'DynamoBreeze' => DynamoBreeze::class, + ]; + } + + /** + * Define environment setup. + * + * @param Application $app + * @return void + */ + protected function getEnvironmentSetUp($app) + { + parent::getEnvironmentSetUp($app); + $app['config']->set('dynamo-breeze', require 'config/config.php'); + } +} diff --git a/tests/Traits/Helpers.php b/tests/Traits/Helpers.php new file mode 100644 index 0000000..2dec7b0 --- /dev/null +++ b/tests/Traits/Helpers.php @@ -0,0 +1,159 @@ +client->describeTable(['TableName' => $tableName]); + $this->assertEquals($tableName, $result['Table']['TableName']); + } catch (DynamoDbException $e) { + $this->fail("Table [$tableName] was not created. ".$e->getMessage()); + } + } + + private function fetchUserPosts(int $userId): DynamoBreezeResult + { + return DynamoBreeze::table(self::TABLE_IDENTIFIER) + ->accessPattern('FetchUserPosts', ['user_id' => $userId]) + ->get(); + } + + private function fetchPostComments(int $postId): DynamoBreezeResult + { + return DynamoBreeze::table(self::TABLE_IDENTIFIER) + ->accessPattern('FetchPostComments', ['post_id' => $postId]) + ->get(); + } + + private function fetchPostLikes(int $postId): DynamoBreezeResult + { + return DynamoBreeze::table(self::TABLE_IDENTIFIER) + ->accessPattern('FetchPostLikes', ['post_id' => $postId]) + ->get(); + } + + private function fetchConversationMessages(string $conversationId): DynamoBreezeResult + { + return DynamoBreeze::table(self::TABLE_IDENTIFIER) + ->accessPattern('FetchConversationMessages', ['conversation_id' => $conversationId]) + ->get(); + } + + private function fetchUserConversations(string $userId) + { + return DynamoBreeze::table(self::TABLE_IDENTIFIER) + ->accessPattern('FetchUserConversations', ['user_id' => $userId]) + ->get(); + } + + private function fetchPostsByDate(string $userId, $start, $end) + { + return DynamoBreeze::table(self::TABLE_IDENTIFIER) + ->accessPattern('FetchPostsByDate', [ + 'user_id' => $userId, + 'start' => $start, + 'end' => $end, + ]) + ->get(); + } + + private function generateCommentsData(int $postId, int $numComments): array + { + $comments = []; + for ($i = 1; $i <= $numComments; $i++) { + $comments[] = [ + 'PK' => "POST#$postId", + 'SK' => "COMMENT#$i", + 'Content' => "Comment $i", + 'Timestamp' => time(), + ]; + } + + return $comments; + } + + private function generateLikesData(int $postId, int $numLikes) + { + $likes = []; + for ($i = 1; $i <= $numLikes; $i++) { + $likes[] = [ + 'PK' => "POST#$postId", + 'SK' => "LIKE#$i", + 'Timestamp' => time(), + ]; + } + + return $likes; + } + + private function createPostData($userId, $timestamp, $content, $postId): array + { + return [ + 'PK' => "USER#{$userId}", + 'SK' => "POST#{$postId}", + 'GSI1PK' => "USER#{$userId}", + 'GSI1SK' => "POST#{$timestamp}", + 'Content' => $content, + 'Timestamp' => $timestamp, + ]; + } + + private function assertPostsContainCorrectDates($posts, $expectedDate): void + { + $marshaler = new Marshaler(); + foreach ($posts as $post) { + $post = $marshaler->unmarshalItem($post); + $postDate = Carbon::createFromTimestamp($post['Timestamp']); + $this->assertEquals($expectedDate, $postDate->format('Y-m-d')); + } + } + + private function createMessageData($conversationId, $timestamp, $content, $senderUserId): array + { + return [ + 'PK' => "CONVERSATION#{$conversationId}", + 'SK' => "MESSAGE#{$timestamp}", + 'MessageContent' => $content, + 'SenderUserId' => $senderUserId, + 'Timestamp' => $timestamp, + ]; + } + + private function createParticipationData( + $userId, + $conversationId, + $conversationName, + $isGroup, + $lastMessageTimestamp = null, + $lastMessageContent = null + ) { + $data = [ + 'PK' => "USER#{$userId}", + 'SK' => "CONVERSATION#{$conversationId}", + 'ConversationName' => $conversationName, + 'IsGroup' => $isGroup, + ]; + + if ($lastMessageTimestamp !== null && $lastMessageContent !== null) { + $data['LastMessageTimestamp'] = $lastMessageTimestamp; + $data['LastMessageContent'] = $lastMessageContent; + } + + return $data; + } + + private function assertConversationMessageCount($expectedCount, $conversationId) + { + $actualCount = $this->fetchConversationMessages($conversationId)->getCount(); + $this->assertEquals($expectedCount, $actualCount); + } +} diff --git a/tests/Unit/DynamoBreezeServiceTest.php b/tests/Unit/DynamoBreezeServiceTest.php new file mode 100644 index 0000000..06c8fde --- /dev/null +++ b/tests/Unit/DynamoBreezeServiceTest.php @@ -0,0 +1,66 @@ +dynamoBreezeService = app(DynamoBreezeService::class); + } + + public function testTableMethodSetsTableName(): void + { + $this->dynamoBreezeService->table('example_table'); + $this->assertSame('ExampleTable', $this->dynamoBreezeService->getTableName()); + } + + /** + * @dataProvider provideAccessPatternData + */ + public function testAccessPatternMethodReplacesAndMarshalsPlaceholders( + string $table, + string $patternName, + array $dataProvider, + array $expectedExpressions + ): void { + $this->dynamoBreezeService->table($table) + ->accessPattern($patternName, $dataProvider); + $accessPatternConfig = $this->dynamoBreezeService->getAccessPatternConfig(); + + $this->assertSame($patternName, $accessPatternConfig['patternName']); + $this->assertSame($expectedExpressions, $this->dynamoBreezeService->getExpressions()); + } + + public function provideAccessPatternData(): array + { + return [ + 'FetchUserPosts Example' => [ + 'table' => 'example_table', + 'patternName' => 'FetchUserPosts', + 'dataProvider' => ['user_id' => 123, 'timestamp' => 123456], + 'expectedExpressions' => [':pk_val' => ['S' => 'USER#123'], ':sk_val' => ['N' => '123456']], + ], + 'FetchPostComments Example' => [ + 'table' => 'social_media', + 'patternName' => 'FetchPostComments', + 'dataProvider' => ['post_id' => 'POST1'], + 'expectedExpressions' => [':pk_val' => ['S' => 'POST#POST1'], ':sk_prefix_val' => ['S' => 'COMMENT#']], + ], + 'ExampleWithFilterExpression' => [ + 'table' => 'example_table', + 'patternName' => 'ExampleAccessPatternWithFilter', + 'dataProvider' => ['user_id' => 1, 'age' => 30], + 'expectedExpressions' => [':pk_val' => ['S' => 'USER#1'], ':minAge' => ['N' => '30']], + ], + ]; + } +} diff --git a/tests/config/access_patterns.php b/tests/config/access_patterns.php new file mode 100644 index 0000000..03f48fe --- /dev/null +++ b/tests/config/access_patterns.php @@ -0,0 +1,86 @@ + [ + 'key_condition_expression' => 'PK = :pk_val AND begins_with(SK, :sk_prefix_val)', + 'expression_attribute_values' => [ + ':pk_val' => ['S' => 'USER#'], + ':sk_prefix_val' => ['S' => 'POST#'], + ], + ], + 'FetchPostComments' => [ + 'key_condition_expression' => 'PK = :pk_val AND begins_with(SK, :sk_prefix_val)', + 'expression_attribute_values' => [ + ':pk_val' => ['S' => 'POST#'], + ':sk_prefix_val' => ['S' => 'COMMENT#'], + ], + ], + 'FetchPostLikes' => [ + 'key_condition_expression' => 'PK = :pk_val AND begins_with(SK, :sk_prefix_val)', + 'expression_attribute_values' => [ + ':pk_val' => ['S' => 'POST#'], + ':sk_prefix_val' => ['S' => 'LIKE#'], + ], + ], + /** + * For messaging, we have users one-to-one messages as well as group messages + * + * Primary Key: + * PK: CONVERSATION# + * SK: #MESSAGE# + * + * Attributes: + * MessageContent (String): The content of the message. + * SenderUserId (String): The ID of the user who sent the message. + * + * For keeping track of all conversations (groups and one-to-one) a user is a part of: + * + * Primary Key: + * PK: USER# + * SK: CONVERSATION# + * + * Attributes: + * ConversationName (String): The name of the conversation or group. + * LastMessageTimestamp (Number): Timestamp of the last message in the conversation. + * LastMessageContent (String): A snippet or the full content of the last message. + * UnreadMessages (Number): Count of unread messages in the conversation. + * IsGroup (Boolean): Flag indicating if the conversation is a group conversation. + */ + 'FetchConversationMessages' => [ + 'key_condition_expression' => 'PK = :pk_val AND begins_with(SK, :sk_prefix_val)', + 'expression_attribute_values' => [ + ':pk_val' => ['S' => 'CONVERSATION#'], + ':sk_prefix_val' => ['S' => 'MESSAGE#'], + ], + ], + 'FetchUserConversations' => [ + 'key_condition_expression' => 'PK = :pk_val AND begins_with(SK, :sk_prefix_val)', + 'expression_attribute_values' => [ + ':pk_val' => ['S' => 'USER#'], + ':sk_prefix_val' => ['S' => 'CONVERSATION#'], + ], + ], + 'FetchPostsByDate' => [ + 'gsi_name' => 'GSI1', + 'key_condition_expression' => '#partitionKey = :partitionValue AND #sortKey BETWEEN :startSortKey AND :endSortKey', + 'expression_attribute_names' => [ + '#partitionKey' => 'GSI1PK', + '#sortKey' => 'GSI1SK', + '#timestamp' => 'Timestamp', // using an alias for 'Timestamp' since it's a reserved word + ], + 'expression_attribute_values' => [ + ':partitionValue' => [ + 'S' => 'USER#', + ], + ':startSortKey' => [ + 'S' => 'POST#', + ], + ':endSortKey' => [ + 'S' => 'POST#', + ], + ], + 'projection_expression' => 'PK, SK, Content, #timestamp', + 'scan_index_forward' => false, + 'limit' => 10, + ], +]; diff --git a/tests/config/config.php b/tests/config/config.php new file mode 100644 index 0000000..3042cb5 --- /dev/null +++ b/tests/config/config.php @@ -0,0 +1,74 @@ + [ + 'example_table' => [ + 'table_name' => 'ExampleTable', + 'partition_key' => 'PostId', + 'sort_key' => 'Timestamp', + 'attributes' => [ + 'PostId' => 'S', // String + 'Timestamp' => 'N', // Number + ], + 'access_patterns' => [ + 'ExampleAccessPattern' => [ + 'gsi_name' => 'GSI_UserTimestamp', + 'key_condition_expression' => 'UserId = :userIdVal', + 'filter_expression' => null, + 'expression_attribute_values' => null, + ], + 'ExampleAccessPatternWithFilter' => [ + 'gsi_name' => 'GSI_UserTimestamp', + 'key_condition_expression' => 'UserId = :userIdVal', + 'filter_expression' => 'Age > :minAge', + 'expression_attribute_values' => [ + ':pk_val' => ['S' => 'USER#'], + ':minAge' => ['N' => ''], + ], + ], + 'FetchUserPosts' => [ + 'key_condition_expression' => 'PK = :pk_val AND SK = :sk_val', + 'expression_attribute_values' => [ + ':pk_val' => ['S' => 'USER#'], + ':sk_val' => ['N' => ''], + ], + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | SocialMediaTable + |-------------------------------------------------------------------------- + | + | Unlike normal library tests we have tried to include a real world example + | in our tests to help drive home the single table approach as well + | as enable a greater understanding for the package. + | + | This is an example social media platform that. Some of the entinties we have are: + | Users, Posts, Comments, Likes, Messages + | + | + */ + 'social_media' => [ + 'table_name' => 'SocialMediaTable', + 'partition_key' => 'PK', + 'sort_key' => 'SK', + 'attributes' => [ + 'PK' => 'S', + 'SK' => 'S', + ], + 'access_patterns' => require 'access_patterns.php', + 'global_secondary_indexes' => require 'gsis.php', + ], + ], + + 'sdk' => [ + 'region' => env('DYNAMODB_REGION', 'us-west-2'), + 'version' => env('DYNAMODB_VERSION', 'latest'), + 'credentials' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + ], + ], +]; diff --git a/tests/config/gsis.php b/tests/config/gsis.php new file mode 100644 index 0000000..181d071 --- /dev/null +++ b/tests/config/gsis.php @@ -0,0 +1,16 @@ + [ + 'index_name' => 'GSI1', + 'key_schema' => [ + 'partition_key' => 'GSI1PK', + 'sort_key' => 'GSI1SK', + ], + 'attributes' => [ + 'GSI1PK' => 'S', + 'GSI1SK' => 'S', + ], + ], + // ... other GSIs ... +];