├── src ├── Parsers │ ├── FilterExpression.php │ ├── Placeholder.php │ ├── ProjectionExpression.php │ ├── KeyConditionExpression.php │ ├── UpdateExpression.php │ ├── ExpressionAttributeValues.php │ ├── ExpressionAttributeNames.php │ └── ConditionExpression.php ├── InvalidQuery.php ├── NotSupportedException.php ├── DynamoDbClientInterface.php ├── ModelTrait.php ├── Facades │ └── DynamoDb.php ├── ConditionAnalyzer │ ├── Index.php │ └── Analyzer.php ├── EmptyAttributeFilter.php ├── DynamoDbCollection.php ├── H.php ├── DynamoDbServiceProvider.php ├── DynamoDb │ ├── DynamoDbManager.php │ ├── QueryBuilder.php │ └── ExecutableQuery.php ├── ModelObserver.php ├── DynamoDbClientService.php ├── Concerns │ └── HasParsers.php ├── ComparisonOperator.php ├── RawDynamoDbQuery.php ├── DynamoDbModel.php └── DynamoDbQueryBuilder.php ├── .gitignore ├── .gitattributes ├── scripts └── dev-setup.sh ├── .github └── ISSUE_TEMPLATE │ └── bug_report.md ├── .travis.yml ├── phpcs.xml ├── phpunit.xml ├── CONTRIBUTING.md ├── LICENSE ├── composer.json ├── config └── dynamodb.php ├── CODE_OF_CONDUCT.md └── README.md /src/Parsers/FilterExpression.php: -------------------------------------------------------------------------------- 1 | reset(); 15 | } 16 | 17 | public function next() 18 | { 19 | $this->counter += 1; 20 | return "a{$this->counter}"; 21 | } 22 | 23 | public function reset() 24 | { 25 | $this->counter = 0; 26 | 27 | return $this; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | 9 | A clear and concise description of what the bug is. 10 | 11 | **Schema** 12 | 13 | Describe your table schema: 14 | * Primary key / composite key 15 | * Any index? 16 | 17 | **Debug info** 18 | 19 | Show the query that you're having trouble with by copy-pasting the result of: 20 | 21 | ```php 22 | print_r($query->toDynamoDbQuery()); 23 | ``` 24 | 25 | **Version info** 26 | 27 | * Laravel: 5.5 28 | * laravel-dynamodb: latest 29 | -------------------------------------------------------------------------------- /src/Parsers/ProjectionExpression.php: -------------------------------------------------------------------------------- 1 | names = $names; 12 | } 13 | 14 | /** 15 | * @param array $columns 16 | * @return string 17 | */ 18 | public function parse(array $columns) 19 | { 20 | foreach ($columns as $column) { 21 | $this->names->set($column); 22 | } 23 | 24 | return join(', ', $this->names->placeholders()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: php 4 | 5 | stages: 6 | - linter 7 | - test 8 | 9 | php: 10 | - '7.2' 11 | - '7.3' 12 | - '7.4' 13 | 14 | before_script: 15 | - java -Djava.library.path=./DynamoDBLocal_lib -jar dynamodb_local/DynamoDBLocal.jar --port 3000 & 16 | - sleep 2 17 | - composer self-update 18 | - COMPOSER_MEMORY_LIMIT=-1 travis_retry composer install --prefer-dist --no-interaction 19 | 20 | script: ./vendor/bin/phpunit 21 | 22 | jobs: 23 | include: 24 | - stage: linter 25 | php: 7.2 26 | before_script: source ./scripts/dev-setup.sh 27 | script: phpcs -s {src/*,tests/*} 28 | 29 | -------------------------------------------------------------------------------- /src/ModelTrait.php: -------------------------------------------------------------------------------- 1 | getTable(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Parsers/KeyConditionExpression.php: -------------------------------------------------------------------------------- 1 | names = $names; 15 | } 16 | 17 | public function reset() 18 | { 19 | $this->names->reset(); 20 | } 21 | 22 | public function remove(array $attributes) 23 | { 24 | foreach ($attributes as $attribute) { 25 | $this->names->set($attribute); 26 | } 27 | 28 | return 'REMOVE ' . implode(', ', $this->names->placeholders()); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | vendor/* 17 | 18 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | ./tests/ 14 | 15 | 16 | 17 | 18 | ./src/ 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/Facades/DynamoDb.php: -------------------------------------------------------------------------------- 1 | * DynamoDb local version: 2016-01-07_1.0 10 | > * DynamoDb local schema for tests created by the [DynamoDb local shell](http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Tools.DynamoDBLocal.Shell.html) is located [here](dynamodb_local_schema.js) 11 | 12 | Run the following commands: 13 | 14 | ```bash 15 | $ java -Djava.library.path=./DynamoDBLocal_lib -jar dynamodb_local/DynamoDBLocal.jar --port 3000 16 | # In a separate tab 17 | $ ./vendor/bin/phpunit 18 | ``` 19 | 20 | or 21 | 22 | ```bash 23 | composer --timeout=0 run dynamodb_local 24 | # In a separate tab 25 | composer run-script test 26 | ``` -------------------------------------------------------------------------------- /src/ConditionAnalyzer/Index.php: -------------------------------------------------------------------------------- 1 | name = $name; 25 | $this->hash = $hash; 26 | $this->range = $range; 27 | } 28 | 29 | public function isComposite() 30 | { 31 | return isset($this->hash) && isset($this->range); 32 | } 33 | 34 | public function columns() 35 | { 36 | $columns = []; 37 | 38 | if ($this->hash) { 39 | $columns[] = $this->hash; 40 | } 41 | 42 | if ($this->range) { 43 | $columns[] = $this->range; 44 | } 45 | 46 | return $columns; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/EmptyAttributeFilter.php: -------------------------------------------------------------------------------- 1 | &$value) { 18 | $value = is_string($value) ? trim($value) : $value; 19 | $empty = $value === null || (is_array($value) && empty($value)); 20 | 21 | $empty = $empty || (is_scalar($value) && $value !== false && (string) $value === ''); 22 | 23 | if ($empty) { 24 | $store[$key] = null; 25 | } else { 26 | if (is_object($value)) { 27 | $value = (array) $value; 28 | } 29 | if (is_array($value)) { 30 | $this->filter($value); 31 | } 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Parsers/ExpressionAttributeValues.php: -------------------------------------------------------------------------------- 1 | reset(); 20 | $this->prefix = $prefix; 21 | } 22 | 23 | public function set($placeholder, $value) 24 | { 25 | $this->mapping["{$this->prefix}{$placeholder}"] = $value; 26 | } 27 | 28 | public function get($placeholder) 29 | { 30 | return $this->mapping[$placeholder]; 31 | } 32 | 33 | public function all() 34 | { 35 | return $this->mapping; 36 | } 37 | 38 | public function placeholders() 39 | { 40 | return array_keys($this->mapping); 41 | } 42 | 43 | public function reset() 44 | { 45 | $this->mapping = []; 46 | 47 | return $this; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/DynamoDbCollection.php: -------------------------------------------------------------------------------- 1 | conditionIndex = $conditionIndex; 20 | } 21 | 22 | public function lastKey() 23 | { 24 | $after = $this->last(); 25 | 26 | if (empty($after)) { 27 | return null; 28 | } 29 | 30 | $afterKey = $after->getKeys(); 31 | 32 | $attributes = $this->conditionIndex ? $this->conditionIndex->columns() : []; 33 | 34 | foreach ($attributes as $attribute) { 35 | $afterKey[$attribute] = $after->getAttribute($attribute); 36 | } 37 | 38 | return $afterKey; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Bao Pham 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/H.php: -------------------------------------------------------------------------------- 1 | $value) { 27 | if (call_user_func($callback, $value, $key)) { 28 | return $value; 29 | } 30 | } 31 | return static::value($default); 32 | } 33 | 34 | public static function value($value) 35 | { 36 | return $value instanceof \Closure ? $value() : $value; 37 | } 38 | } 39 | // phpcs:enable Squiz.Classes.ValidClassName.NotCamelCaps 40 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "baopham/dynamodb", 3 | "description": "Eloquent syntax for DynamoDB", 4 | "keywords": [ 5 | "laravel", 6 | "dynamodb", 7 | "aws" 8 | ], 9 | "require": { 10 | "aws/aws-sdk-php": "^3.0.0", 11 | "illuminate/support": "5.1.* || 5.2.* || 5.3.* || 5.4.* || 5.5.* || 5.6.* || 5.7.* || 5.8.* || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", 12 | "illuminate/database": "5.1.* || 5.2.* || 5.3.* || 5.4.* || 5.5.* || 5.6.* || 5.7.* || 5.8.* || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0" 13 | }, 14 | "license": "MIT", 15 | "authors": [ 16 | { 17 | "name": "Bao Pham", 18 | "email": "gbaopham@gmail.com" 19 | } 20 | ], 21 | "autoload": { 22 | "psr-4": { 23 | "BaoPham\\DynamoDb\\": "src/" 24 | } 25 | }, 26 | "require-dev": { 27 | "orchestra/testbench": "~3.0 || ~5.0|^8.0" 28 | }, 29 | "scripts": { 30 | "test": "phpunit", 31 | "dynamodb_local": "java -Djava.library.path=./DynamoDBLocal_lib -jar dynamodb_local/DynamoDBLocal.jar --port 3000" 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "BaoPham\\DynamoDb\\Tests\\": "tests/" 36 | } 37 | }, 38 | "extra": { 39 | "laravel": { 40 | "providers": [ 41 | "BaoPham\\DynamoDb\\DynamoDbServiceProvider" 42 | ] 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/DynamoDbServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->make(DynamoDbClientInterface::class)); 19 | 20 | $this->publishes([ 21 | __DIR__.'/../config/dynamodb.php' => app()->basePath('config/dynamodb.php'), 22 | ]); 23 | } 24 | 25 | /** 26 | * Register the service provider. 27 | */ 28 | public function register() 29 | { 30 | $marshalerOptions = [ 31 | 'nullify_invalid' => true, 32 | ]; 33 | 34 | $this->app->singleton(DynamoDbClientInterface::class, function () use ($marshalerOptions) { 35 | $client = new DynamoDbClientService(new Marshaler($marshalerOptions), new EmptyAttributeFilter()); 36 | 37 | return $client; 38 | }); 39 | 40 | $this->app->singleton('dynamodb', function () { 41 | return new DynamoDbManager(app(DynamoDbClientInterface::class)); 42 | }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Parsers/ExpressionAttributeNames.php: -------------------------------------------------------------------------------- 1 | reset(); 25 | $this->prefix = $prefix; 26 | } 27 | 28 | public function set($name) 29 | { 30 | if ($this->isNested($name)) { 31 | $this->nested[] = $name; 32 | return; 33 | } 34 | $this->mapping["{$this->prefix}{$name}"] = $name; 35 | } 36 | 37 | public function get($placeholder) 38 | { 39 | return $this->mapping[$placeholder]; 40 | } 41 | 42 | public function placeholder($name) 43 | { 44 | $placeholder = "{$this->prefix}{$name}"; 45 | if (isset($this->mapping[$placeholder])) { 46 | return $placeholder; 47 | } 48 | return $name; 49 | } 50 | 51 | public function all() 52 | { 53 | return $this->mapping; 54 | } 55 | 56 | public function placeholders() 57 | { 58 | return array_merge(array_keys($this->mapping), $this->nested); 59 | } 60 | 61 | public function reset() 62 | { 63 | $this->mapping = []; 64 | $this->nested = []; 65 | 66 | return $this; 67 | } 68 | 69 | private function isNested($name) 70 | { 71 | return strpos($name, '.') !== false || (strpos($name, '[') !== false && strpos($name, ']') !== false); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/DynamoDb/DynamoDbManager.php: -------------------------------------------------------------------------------- 1 | service = $service; 27 | $this->marshaler = $service->getMarshaler(); 28 | } 29 | 30 | public function marshalItem($item) 31 | { 32 | return $this->marshaler->marshalItem($item); 33 | } 34 | 35 | public function marshalValue($value) 36 | { 37 | return $this->marshaler->marshalValue($value); 38 | } 39 | 40 | public function unmarshalItem($item) 41 | { 42 | return $this->marshaler->unmarshalItem($item); 43 | } 44 | 45 | public function unmarshalValue($value) 46 | { 47 | return $this->marshaler->unmarshalValue($value); 48 | } 49 | 50 | /** 51 | * @param string|null $connection 52 | * @return \Aws\DynamoDb\DynamoDbClient 53 | */ 54 | public function client($connection = null) 55 | { 56 | return $this->service->getClient($connection); 57 | } 58 | 59 | /** 60 | * @return QueryBuilder 61 | */ 62 | public function newQuery() 63 | { 64 | return new QueryBuilder($this->service); 65 | } 66 | 67 | /** 68 | * @param string $table 69 | * @return QueryBuilder 70 | */ 71 | public function table($table) 72 | { 73 | return $this->newQuery()->setTableName($table); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/ModelObserver.php: -------------------------------------------------------------------------------- 1 | dynamoDbClient = $dynamoDb->getClient(); 31 | $this->marshaler = $dynamoDb->getMarshaler(); 32 | $this->attributeFilter = $dynamoDb->getAttributeFilter(); 33 | } 34 | 35 | private function saveToDynamoDb($model) 36 | { 37 | $attrs = $model->attributesToArray(); 38 | 39 | try { 40 | $this->dynamoDbClient->putItem([ 41 | 'TableName' => $model->getDynamoDbTableName(), 42 | 'Item' => $this->marshaler->marshalItem($attrs), 43 | ]); 44 | } catch (Exception $e) { 45 | Log::error($e); 46 | } 47 | } 48 | 49 | private function deleteFromDynamoDb($model) 50 | { 51 | $key = [$model->getKeyName() => $model->getKey()]; 52 | 53 | try { 54 | $this->dynamoDbClient->deleteItem([ 55 | 'TableName' => $model->getDynamoDbTableName(), 56 | 'Key' => $this->marshaler->marshalItem($key), 57 | ]); 58 | } catch (Exception $e) { 59 | Log::error($e); 60 | } 61 | } 62 | 63 | public function created($model) 64 | { 65 | $this->saveToDynamoDb($model); 66 | } 67 | 68 | public function updated($model) 69 | { 70 | $this->saveToDynamoDb($model); 71 | } 72 | 73 | public function deleted($model) 74 | { 75 | $this->deleteFromDynamoDb($model); 76 | } 77 | 78 | public function restored($model) 79 | { 80 | $this->saveToDynamoDb($model); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/DynamoDbClientService.php: -------------------------------------------------------------------------------- 1 | marshaler = $marshaler; 30 | $this->attributeFilter = $filter; 31 | $this->clients = []; 32 | } 33 | 34 | /** 35 | * @return \Aws\DynamoDb\DynamoDbClient 36 | */ 37 | public function getClient($connection = null) 38 | { 39 | $connection = $connection ?: config('dynamodb.default'); 40 | 41 | if (isset($this->clients[$connection])) { 42 | return $this->clients[$connection]; 43 | } 44 | 45 | $config = config("dynamodb.connections.$connection", []); 46 | $config['version'] = '2012-08-10'; 47 | $config['debug'] = $this->getDebugOptions(Arr::get($config, 'debug')); 48 | 49 | $client = new DynamoDbClient($config); 50 | 51 | $this->clients[$connection] = $client; 52 | 53 | return $client; 54 | } 55 | 56 | /** 57 | * @return \Aws\DynamoDb\Marshaler 58 | */ 59 | public function getMarshaler() 60 | { 61 | return $this->marshaler; 62 | } 63 | 64 | /** 65 | * @return \BaoPham\DynamoDb\EmptyAttributeFilter 66 | */ 67 | public function getAttributeFilter() 68 | { 69 | return $this->attributeFilter; 70 | } 71 | 72 | protected function getDebugOptions($debug = false) 73 | { 74 | if ($debug === true) { 75 | $logfn = function ($msg) { 76 | Log::info($msg); 77 | }; 78 | 79 | return ['logfn' => $logfn]; 80 | } 81 | 82 | return $debug; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Concerns/HasParsers.php: -------------------------------------------------------------------------------- 1 | placeholder = new Placeholder(); 53 | 54 | $this->expressionAttributeNames = new ExpressionAttributeNames(); 55 | 56 | $this->expressionAttributeValues = new ExpressionAttributeValues(); 57 | 58 | $this->keyConditionExpression = new KeyConditionExpression( 59 | $this->placeholder, 60 | $this->expressionAttributeValues, 61 | $this->expressionAttributeNames 62 | ); 63 | 64 | $this->filterExpression = new FilterExpression( 65 | $this->placeholder, 66 | $this->expressionAttributeValues, 67 | $this->expressionAttributeNames 68 | ); 69 | 70 | $this->projectionExpression = new ProjectionExpression($this->expressionAttributeNames); 71 | 72 | $this->updateExpression = new UpdateExpression($this->expressionAttributeNames); 73 | } 74 | 75 | public function resetExpressions() 76 | { 77 | $this->filterExpression->reset(); 78 | $this->keyConditionExpression->reset(); 79 | $this->updateExpression->reset(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /config/dynamodb.php: -------------------------------------------------------------------------------- 1 | env('DYNAMODB_CONNECTION', 'aws'), 15 | 16 | /* 17 | |-------------------------------------------------------------------------- 18 | | DynamoDb Connections 19 | |-------------------------------------------------------------------------- 20 | | 21 | | Here are each of the DynamoDb connections setup for your application. 22 | | 23 | | Most of the connection's config will be fed directly to AwsClient 24 | | constructor http://docs.aws.amazon.com/aws-sdk-php/v3/api/class-Aws.AwsClient.html#___construct 25 | */ 26 | 27 | 'connections' => [ 28 | 'aws' => [ 29 | 'credentials' => [ 30 | 'key' => env('DYNAMODB_KEY'), 31 | 'secret' => env('DYNAMODB_SECRET'), 32 | // If using as an assumed IAM role, you can also use the `token` parameter 33 | 'token' => env('AWS_SESSION_TOKEN'), 34 | ], 35 | 'region' => env('DYNAMODB_REGION'), 36 | // if true, it will use Laravel Log. 37 | // For advanced options, see http://docs.aws.amazon.com/aws-sdk-php/v3/guide/guide/configuration.html 38 | 'debug' => env('DYNAMODB_DEBUG'), 39 | ], 40 | 'aws_iam_role' => [ 41 | 'region' => env('DYNAMODB_REGION'), 42 | 'debug' => env('DYNAMODB_DEBUG'), 43 | ], 44 | 'local' => [ 45 | 'credentials' => [ 46 | 'key' => 'dynamodblocal', 47 | 'secret' => 'secret', 48 | ], 49 | 'region' => 'stub', 50 | // see http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Tools.DynamoDBLocal.html 51 | 'endpoint' => env('DYNAMODB_LOCAL_ENDPOINT'), 52 | 'debug' => true, 53 | ], 54 | 'test' => [ 55 | 'credentials' => [ 56 | 'key' => 'dynamodblocal', 57 | 'secret' => 'secret', 58 | ], 59 | 'region' => 'test', 60 | 'endpoint' => env('DYNAMODB_LOCAL_ENDPOINT'), 61 | 'debug' => true, 62 | ], 63 | ], 64 | ]; 65 | -------------------------------------------------------------------------------- /src/ComparisonOperator.php: -------------------------------------------------------------------------------- 1 | static::EQ, 28 | '>' => static::GT, 29 | '>=' => static::GE, 30 | '<' => static::LT, 31 | '<=' => static::LE, 32 | 'in' => static::IN, 33 | '!=' => static::NE, 34 | 'begins_with' => static::BEGINS_WITH, 35 | 'between' => static::BETWEEN, 36 | 'not_contains' => static::NOT_CONTAINS, 37 | 'contains' => static::CONTAINS, 38 | 'null' => static::NULL, 39 | 'not_null' => static::NOT_NULL, 40 | ]; 41 | } 42 | 43 | public static function getSupportedOperators() 44 | { 45 | return array_keys(static::getOperatorMapping()); 46 | } 47 | 48 | public static function isValidOperator($operator) 49 | { 50 | $operator = strtolower($operator); 51 | 52 | $mapping = static::getOperatorMapping(); 53 | 54 | return isset($mapping[$operator]); 55 | } 56 | 57 | public static function getDynamoDbOperator($operator) 58 | { 59 | $mapping = static::getOperatorMapping(); 60 | 61 | $operator = strtolower($operator); 62 | 63 | return $mapping[$operator]; 64 | } 65 | 66 | public static function getQuerySupportedOperators($isRangeKey = false) 67 | { 68 | if ($isRangeKey) { 69 | return [ 70 | static::EQ, 71 | static::LE, 72 | static::LT, 73 | static::GE, 74 | static::GT, 75 | static::BEGINS_WITH, 76 | static::BETWEEN, 77 | ]; 78 | } 79 | 80 | return [static::EQ]; 81 | } 82 | 83 | public static function isValidQueryOperator($operator, $isRangeKey = false) 84 | { 85 | $dynamoDbOperator = static::getDynamoDbOperator($operator); 86 | 87 | return static::isValidQueryDynamoDbOperator($dynamoDbOperator, $isRangeKey); 88 | } 89 | 90 | public static function isValidQueryDynamoDbOperator($dynamoDbOperator, $isRangeKey = false) 91 | { 92 | return in_array($dynamoDbOperator, static::getQuerySupportedOperators($isRangeKey)); 93 | } 94 | 95 | public static function is($op, $dynamoDbOperator) 96 | { 97 | $mapping = static::getOperatorMapping(); 98 | return $mapping[strtolower($op)] === $dynamoDbOperator; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at gbaopham@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /src/DynamoDb/QueryBuilder.php: -------------------------------------------------------------------------------- 1 | `, where `` 17 | * is the key name of the query body to be sent. 18 | * 19 | * For example, to build a query: 20 | * [ 21 | * 'AttributeDefinitions' => ..., 22 | * 'GlobalSecondaryIndexUpdates' => ... 23 | * 'TableName' => ... 24 | * ] 25 | * 26 | * Do: 27 | * 28 | * $query = $query->setAttributeDefinitions(...)->setGlobalSecondaryIndexUpdates(...)->setTableName(...); 29 | * 30 | * When ready: 31 | * 32 | * $query->prepare()->updateTable(); 33 | * 34 | * Common methods: 35 | * 36 | * @method QueryBuilder setExpressionAttributeNames(array $mapping) 37 | * @method QueryBuilder setExpressionAttributeValues(array $mapping) 38 | * @method QueryBuilder setFilterExpression(string $expression) 39 | * @method QueryBuilder setKeyConditionExpression(string $expression) 40 | * @method QueryBuilder setProjectionExpression(string $expression) 41 | * @method QueryBuilder setUpdateExpression(string $expression) 42 | * @method QueryBuilder setAttributeUpdates(array $updates) 43 | * @method QueryBuilder setConsistentRead(bool $consistent) 44 | * @method QueryBuilder setScanIndexForward(bool $forward) 45 | * @method QueryBuilder setExclusiveStartKey(mixed $key) 46 | * @method QueryBuilder setReturnValues(string $type) 47 | * @method QueryBuilder setRequestItems(array $items) 48 | * @method QueryBuilder setTableName(string $table) 49 | * @method QueryBuilder setIndexName(string $index) 50 | * @method QueryBuilder setSelect(string $select) 51 | * @method QueryBuilder setItem(array $item) 52 | * @method QueryBuilder setKeys(array $keys) 53 | * @method QueryBuilder setLimit(int $limit) 54 | * @method QueryBuilder setKey(array $key) 55 | */ 56 | class QueryBuilder 57 | { 58 | /** 59 | * @var DynamoDbClientInterface 60 | */ 61 | private $service; 62 | 63 | /** 64 | * Query body to be sent to AWS 65 | * 66 | * @var array 67 | */ 68 | public $query = []; 69 | 70 | public function __construct(DynamoDbClientInterface $service) 71 | { 72 | $this->service = $service; 73 | } 74 | 75 | public function hydrate(array $query) 76 | { 77 | $this->query = $query; 78 | 79 | return $this; 80 | } 81 | 82 | public function setExpressionAttributeName($placeholder, $name) 83 | { 84 | $this->query['ExpressionAttributeNames'][$placeholder] = $name; 85 | 86 | return $this; 87 | } 88 | 89 | public function setExpressionAttributeValue($placeholder, $value) 90 | { 91 | $this->query['ExpressionAttributeValues'][$placeholder] = $value; 92 | 93 | return $this; 94 | } 95 | 96 | /** 97 | * @param DynamoDbClient|null $client 98 | * @return ExecutableQuery 99 | */ 100 | public function prepare(DynamoDbClient $client = null) 101 | { 102 | $raw = new RawDynamoDbQuery(null, $this->query); 103 | return new ExecutableQuery($client ?: $this->service->getClient(), $raw->finalize()->query); 104 | } 105 | 106 | /** 107 | * @param string $method 108 | * @param array $parameters 109 | * @return mixed 110 | */ 111 | public function __call($method, $parameters) 112 | { 113 | if (Str::startsWith($method, 'set')) { 114 | $key = array_reverse(explode('set', $method, 2))[0]; 115 | $this->query[$key] = current($parameters); 116 | 117 | return $this; 118 | } 119 | 120 | throw new BadMethodCallException(sprintf( 121 | 'Method %s::%s does not exist.', 122 | static::class, 123 | $method 124 | )); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/RawDynamoDbQuery.php: -------------------------------------------------------------------------------- 1 | op = $op; 29 | $this->query = $query; 30 | } 31 | 32 | /** 33 | * Perform any final clean up. 34 | * Remove any empty values to avoid errors. 35 | * 36 | * @return $this 37 | */ 38 | public function finalize() 39 | { 40 | $this->query = array_filter($this->query, function ($value) { 41 | return !empty($value) || is_bool($value) || is_numeric($value); 42 | }); 43 | 44 | return $this; 45 | } 46 | 47 | /** 48 | * Whether a offset exists 49 | * @link http://php.net/manual/en/arrayaccess.offsetexists.php 50 | * @param mixed $offset

51 | * An offset to check for. 52 | *

53 | * @return boolean true on success or false on failure. 54 | *

55 | *

56 | * The return value will be casted to boolean if non-boolean was returned. 57 | * @since 5.0.0 58 | */ 59 | #[\ReturnTypeWillChange] 60 | public function offsetExists($offset) 61 | { 62 | return isset($this->internal()[$offset]); 63 | } 64 | 65 | /** 66 | * Offset to retrieve 67 | * @link http://php.net/manual/en/arrayaccess.offsetget.php 68 | * @param mixed $offset

69 | * The offset to retrieve. 70 | *

71 | * @return mixed Can return all value types. 72 | * @since 5.0.0 73 | */ 74 | #[\ReturnTypeWillChange] 75 | public function offsetGet($offset) 76 | { 77 | return $this->internal()[$offset]; 78 | } 79 | 80 | /** 81 | * Offset to set 82 | * @link http://php.net/manual/en/arrayaccess.offsetset.php 83 | * @param mixed $offset

84 | * The offset to assign the value to. 85 | *

86 | * @param mixed $value

87 | * The value to set. 88 | *

89 | * @return void 90 | * @since 5.0.0 91 | */ 92 | #[\ReturnTypeWillChange] 93 | public function offsetSet($offset, $value) 94 | { 95 | $this->internal()[$offset] = $value; 96 | } 97 | 98 | /** 99 | * Offset to unset 100 | * @link http://php.net/manual/en/arrayaccess.offsetunset.php 101 | * @param mixed $offset

102 | * The offset to unset. 103 | *

104 | * @return void 105 | * @since 5.0.0 106 | */ 107 | #[\ReturnTypeWillChange] 108 | public function offsetUnset($offset) 109 | { 110 | unset($this->internal()[$offset]); 111 | } 112 | 113 | /** 114 | * Retrieve an external iterator 115 | * @link http://php.net/manual/en/iteratoraggregate.getiterator.php 116 | * @return \Traversable An instance of an object implementing Iterator or 117 | * Traversable 118 | * @since 5.0.0 119 | */ 120 | #[\ReturnTypeWillChange] 121 | public function getIterator() 122 | { 123 | return new \ArrayObject($this->internal()); 124 | } 125 | 126 | /** 127 | * Count elements of an object 128 | * @link http://php.net/manual/en/countable.count.php 129 | * @return int The custom count as an integer. 130 | *

131 | *

132 | * The return value is cast to an integer. 133 | * @since 5.1.0 134 | */ 135 | #[\ReturnTypeWillChange] 136 | public function count() 137 | { 138 | return count($this->internal()); 139 | } 140 | 141 | /** 142 | * For backward compatibility, previously we use array to represent the raw query 143 | * 144 | * @var array 145 | * 146 | * @return array 147 | */ 148 | private function internal() 149 | { 150 | return [$this->op, $this->query]; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/DynamoDb/ExecutableQuery.php: -------------------------------------------------------------------------------- 1 | client = $client; 86 | $this->query = $query; 87 | } 88 | 89 | /** 90 | * @param string $method 91 | * @param array $parameters 92 | * @return mixed 93 | */ 94 | public function __call($method, $parameters) 95 | { 96 | return $this->client->{$method}($this->query); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Parsers/ConditionExpression.php: -------------------------------------------------------------------------------- 1 | '%s = :%s', 14 | ComparisonOperator::LE => '%s <= :%s', 15 | ComparisonOperator::LT => '%s < :%s', 16 | ComparisonOperator::GE => '%s >= :%s', 17 | ComparisonOperator::GT => '%s > :%s', 18 | ComparisonOperator::BEGINS_WITH => 'begins_with(%s, :%s)', 19 | ComparisonOperator::BETWEEN => '(%s BETWEEN :%s AND :%s)', 20 | ComparisonOperator::CONTAINS => 'contains(%s, :%s)', 21 | ComparisonOperator::NOT_CONTAINS => 'NOT contains(%s, :%s)', 22 | ComparisonOperator::NULL => 'attribute_not_exists(%s)', 23 | ComparisonOperator::NOT_NULL => 'attribute_exists(%s)', 24 | ComparisonOperator::NE => '%s <> :%s', 25 | ComparisonOperator::IN => '%s IN (%s)', 26 | ]; 27 | 28 | /** 29 | * @var ExpressionAttributeValues 30 | */ 31 | protected $values; 32 | 33 | /** 34 | * @var ExpressionAttributeNames 35 | */ 36 | protected $names; 37 | 38 | /** 39 | * @var Placeholder 40 | */ 41 | protected $placeholder; 42 | 43 | public function __construct( 44 | Placeholder $placeholder, 45 | ExpressionAttributeValues $values, 46 | ExpressionAttributeNames $names 47 | ) { 48 | $this->placeholder = $placeholder; 49 | $this->values = $values; 50 | $this->names = $names; 51 | } 52 | 53 | /** 54 | * @param array $where 55 | * [ 56 | * 'column' => 'name', 57 | * 'type' => 'EQ', 58 | * 'value' => 'foo', 59 | * 'boolean' => 'and', 60 | * ] 61 | * 62 | * @return string 63 | * @throws NotSupportedException 64 | */ 65 | public function parse($where) 66 | { 67 | if (empty($where)) { 68 | return ''; 69 | } 70 | 71 | $parsed = []; 72 | 73 | foreach ($where as $condition) { 74 | $boolean = Arr::get($condition, 'boolean'); 75 | $value = Arr::get($condition, 'value'); 76 | $type = Arr::get($condition, 'type'); 77 | 78 | $prefix = ''; 79 | 80 | if (count($parsed) > 0) { 81 | $prefix = strtoupper($boolean) . ' '; 82 | } 83 | 84 | if ($type === 'Nested') { 85 | $parsed[] = $prefix . $this->parseNestedCondition($value); 86 | continue; 87 | } 88 | 89 | $parsed[] = $prefix . $this->parseCondition( 90 | Arr::get($condition, 'column'), 91 | $type, 92 | $value 93 | ); 94 | } 95 | 96 | return implode(' ', $parsed); 97 | } 98 | 99 | public function reset() 100 | { 101 | $this->placeholder->reset(); 102 | $this->names->reset(); 103 | $this->values->reset(); 104 | } 105 | 106 | protected function getSupportedOperators() 107 | { 108 | return static::OPERATORS; 109 | } 110 | 111 | protected function parseNestedCondition(array $conditions) 112 | { 113 | return '(' . $this->parse($conditions) . ')'; 114 | } 115 | 116 | protected function parseCondition($name, $operator, $value) 117 | { 118 | $operators = $this->getSupportedOperators(); 119 | 120 | if (empty($operators[$operator])) { 121 | throw new NotSupportedException("$operator is not supported"); 122 | } 123 | 124 | $template = $operators[$operator]; 125 | 126 | $this->names->set($name); 127 | 128 | if ($operator === ComparisonOperator::BETWEEN) { 129 | return $this->parseBetweenCondition($name, $value, $template); 130 | } 131 | 132 | if ($operator === ComparisonOperator::IN) { 133 | return $this->parseInCondition($name, $value, $template); 134 | } 135 | 136 | if ($operator === ComparisonOperator::NULL || $operator === ComparisonOperator::NOT_NULL) { 137 | return $this->parseNullCondition($name, $template); 138 | } 139 | 140 | $placeholder = $this->placeholder->next(); 141 | 142 | $this->values->set($placeholder, DynamoDb::marshalValue($value)); 143 | 144 | return sprintf($template, $this->names->placeholder($name), $placeholder); 145 | } 146 | 147 | protected function parseBetweenCondition($name, $value, $template) 148 | { 149 | $first = $this->placeholder->next(); 150 | 151 | $second = $this->placeholder->next(); 152 | 153 | $this->values->set($first, DynamoDb::marshalValue($value[0])); 154 | 155 | $this->values->set($second, DynamoDb::marshalValue($value[1])); 156 | 157 | return sprintf($template, $this->names->placeholder($name), $first, $second); 158 | } 159 | 160 | protected function parseInCondition($name, $value, $template) 161 | { 162 | $valuePlaceholders = []; 163 | 164 | foreach ($value as $item) { 165 | $placeholder = $this->placeholder->next(); 166 | 167 | $valuePlaceholders[] = ":" . $placeholder; 168 | 169 | $this->values->set($placeholder, DynamoDb::marshalValue($item)); 170 | } 171 | 172 | return sprintf($template, $this->names->placeholder($name), implode(', ', $valuePlaceholders)); 173 | } 174 | 175 | protected function parseNullCondition($name, $template) 176 | { 177 | return sprintf($template, $this->names->placeholder($name)); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/ConditionAnalyzer/Analyzer.php: -------------------------------------------------------------------------------- 1 | on($model) 18 | * ->withIndex($index) 19 | * ->analyze($conditions); 20 | * 21 | * $analyzer->isExactSearch(); 22 | * $analyzer->keyConditions(); 23 | * $analyzer->filterConditions(); 24 | * $analyzer->index(); 25 | */ 26 | class Analyzer 27 | { 28 | /** 29 | * @var DynamoDbModel 30 | */ 31 | private $model; 32 | 33 | /** 34 | * @var array 35 | */ 36 | private $conditions = []; 37 | 38 | /** 39 | * @var string 40 | */ 41 | private $indexName; 42 | 43 | public function on(DynamoDbModel $model) 44 | { 45 | $this->model = $model; 46 | 47 | return $this; 48 | } 49 | 50 | public function withIndex($index) 51 | { 52 | $this->indexName = $index; 53 | 54 | return $this; 55 | } 56 | 57 | public function analyze($conditions) 58 | { 59 | $this->conditions = $conditions; 60 | 61 | return $this; 62 | } 63 | 64 | public function isExactSearch() 65 | { 66 | if (empty($this->conditions)) { 67 | return false; 68 | } 69 | 70 | if (empty($this->identifierConditions())) { 71 | return false; 72 | } 73 | 74 | if (count($this->conditions) !== count($this->model->getKeyNames())) { 75 | return false; 76 | } 77 | 78 | foreach ($this->conditions as $condition) { 79 | if (Arr::get($condition, 'type') !== ComparisonOperator::EQ) { 80 | return false; 81 | } 82 | 83 | if (array_search(Arr::get($condition, 'column'), $this->model->getKeyNames()) === false) { 84 | return false; 85 | } 86 | } 87 | 88 | return true; 89 | } 90 | 91 | /** 92 | * @return Index|null 93 | */ 94 | public function index() 95 | { 96 | return $this->getIndex(); 97 | } 98 | 99 | public function keyConditions() 100 | { 101 | $index = $this->getIndex(); 102 | 103 | if ($index) { 104 | return $this->getConditions($index->columns()); 105 | } 106 | 107 | return $this->identifierConditions(); 108 | } 109 | 110 | public function filterConditions() 111 | { 112 | $keyConditions = $this->keyConditions() ?: []; 113 | 114 | return array_filter($this->conditions, function ($condition) use ($keyConditions) { 115 | return array_search($condition, $keyConditions) === false; 116 | }); 117 | } 118 | 119 | public function identifierConditions() 120 | { 121 | $keyNames = $this->model->getKeyNames(); 122 | 123 | $conditions = $this->getConditions($keyNames); 124 | 125 | if (!$this->hasValidQueryOperator(...$keyNames)) { 126 | return null; 127 | } 128 | 129 | return $conditions; 130 | } 131 | 132 | public function identifierConditionValues() 133 | { 134 | $idConditions = $this->identifierConditions(); 135 | 136 | if (!$idConditions) { 137 | return []; 138 | } 139 | 140 | $values = []; 141 | 142 | foreach ($idConditions as $condition) { 143 | $values[$condition['column']] = $condition['value']; 144 | } 145 | 146 | return $values; 147 | } 148 | 149 | /** 150 | * @param $column 151 | * 152 | * @return array 153 | */ 154 | private function getCondition($column) 155 | { 156 | return H::array_first($this->conditions, function ($condition) use ($column) { 157 | return $condition['column'] === $column; 158 | }); 159 | } 160 | 161 | /** 162 | * @param $columns 163 | * 164 | * @return array 165 | */ 166 | private function getConditions($columns) 167 | { 168 | return array_filter($this->conditions, function ($condition) use ($columns) { 169 | return in_array($condition['column'], $columns); 170 | }); 171 | } 172 | 173 | /** 174 | * @return Index|null 175 | */ 176 | private function getIndex() 177 | { 178 | if (empty($this->conditions)) { 179 | return null; 180 | } 181 | 182 | $index = null; 183 | 184 | foreach ($this->model->getDynamoDbIndexKeys() as $name => $keysInfo) { 185 | $conditionKeys = Arr::pluck($this->conditions, 'column'); 186 | $keys = array_values($keysInfo); 187 | 188 | if (count(array_intersect($conditionKeys, $keys)) === count($keys)) { 189 | if (!isset($this->indexName) || $this->indexName === $name) { 190 | $index = new Index( 191 | $name, 192 | Arr::get($keysInfo, 'hash'), 193 | Arr::get($keysInfo, 'range') 194 | ); 195 | 196 | break; 197 | } 198 | } 199 | } 200 | 201 | if ($index && !$this->hasValidQueryOperator($index->hash, $index->range)) { 202 | $index = null; 203 | } 204 | 205 | return $index; 206 | } 207 | 208 | private function hasValidQueryOperator($hash, $range = null) 209 | { 210 | $hashConditionType = $this->getCondition($hash)['type'] ?? null; 211 | $validQueryOp = ComparisonOperator::isValidQueryDynamoDbOperator($hashConditionType); 212 | 213 | if ($validQueryOp && $range && $this->getCondition($range) !== null) { 214 | $rangeConditionType = $this->getCondition($range)['type']; 215 | $validQueryOp = ComparisonOperator::isValidQueryDynamoDbOperator( 216 | $rangeConditionType, 217 | true 218 | ); 219 | } 220 | 221 | return $validQueryOp; 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/DynamoDbModel.php: -------------------------------------------------------------------------------- 1 | ' => [ 43 | * 'hash' => '' 44 | * ], 45 | * '' => [ 46 | * 'hash' => '', 47 | * 'range' => '' 48 | * ], 49 | * ] 50 | * 51 | * @var array 52 | */ 53 | protected $dynamoDbIndexKeys = []; 54 | 55 | /** 56 | * Array of your composite key. 57 | * ['', ''] 58 | * 59 | * @var array 60 | */ 61 | protected $compositeKey = []; 62 | 63 | /** 64 | * Default Date format 65 | * ISO 8601 Compliant 66 | */ 67 | protected $dateFormat = DateTime::ATOM; 68 | 69 | 70 | public function __construct(array $attributes = []) 71 | { 72 | $this->bootIfNotBooted(); 73 | 74 | $this->syncOriginal(); 75 | 76 | $this->fill($attributes); 77 | 78 | $this->setupDynamoDb(); 79 | } 80 | 81 | /** 82 | * Get the DynamoDbClient service that is being used by the models. 83 | * 84 | * @return DynamoDbClientInterface 85 | */ 86 | public static function getDynamoDbClientService() 87 | { 88 | return static::$dynamoDb; 89 | } 90 | 91 | /** 92 | * Set the DynamoDbClient used by models. 93 | * 94 | * @param DynamoDbClientInterface $dynamoDb 95 | * 96 | * @return void 97 | */ 98 | public static function setDynamoDbClientService(DynamoDbClientInterface $dynamoDb) 99 | { 100 | static::$dynamoDb = $dynamoDb; 101 | } 102 | 103 | /** 104 | * Unset the DynamoDbClient service for models. 105 | * 106 | * @return void 107 | */ 108 | public static function unsetDynamoDbClientService() 109 | { 110 | static::$dynamoDb = null; 111 | } 112 | 113 | protected function setupDynamoDb() 114 | { 115 | $this->marshaler = static::$dynamoDb->getMarshaler(); 116 | $this->attributeFilter = static::$dynamoDb->getAttributeFilter(); 117 | } 118 | 119 | public function newCollection(array $models = [], $index = null) 120 | { 121 | return new DynamoDbCollection($models, $index); 122 | } 123 | 124 | public function save(array $options = []) 125 | { 126 | $create = !$this->exists; 127 | 128 | if ($this->fireModelEvent('saving') === false) { 129 | return false; 130 | } 131 | 132 | if ($create && $this->fireModelEvent('creating') === false) { 133 | return false; 134 | } 135 | 136 | if (!$create && $this->fireModelEvent('updating') === false) { 137 | return false; 138 | } 139 | 140 | if ($this->usesTimestamps()) { 141 | $this->updateTimestamps(); 142 | } 143 | 144 | $saved = $this->newQuery()->save(); 145 | 146 | if (!$saved) { 147 | return $saved; 148 | } 149 | 150 | $this->exists = true; 151 | $this->wasRecentlyCreated = $create; 152 | $this->fireModelEvent($create ? 'created' : 'updated', false); 153 | 154 | $this->finishSave($options); 155 | 156 | return $saved; 157 | } 158 | 159 | /** 160 | * Saves the model to DynamoDb asynchronously and returns a promise 161 | * @param array $options 162 | * @return bool|\GuzzleHttp\Promise\Promise 163 | */ 164 | public function saveAsync(array $options = []) 165 | { 166 | $create = !$this->exists; 167 | 168 | if ($this->fireModelEvent('saving') === false) { 169 | return false; 170 | } 171 | 172 | if ($create && $this->fireModelEvent('creating') === false) { 173 | return false; 174 | } 175 | 176 | if (!$create && $this->fireModelEvent('updating') === false) { 177 | return false; 178 | } 179 | 180 | if ($this->usesTimestamps()) { 181 | $this->updateTimestamps(); 182 | } 183 | 184 | $savePromise = $this->newQuery()->saveAsync(); 185 | 186 | $savePromise->then(function ($result) use ($create, $options) { 187 | if (Arr::get($result, '@metadata.statusCode') === 200) { 188 | $this->exists = true; 189 | $this->wasRecentlyCreated = $create; 190 | $this->fireModelEvent($create ? 'created' : 'updated', false); 191 | 192 | $this->finishSave($options); 193 | } 194 | }); 195 | 196 | return $savePromise; 197 | } 198 | 199 | public function update(array $attributes = [], array $options = []) 200 | { 201 | return $this->fill($attributes)->save(); 202 | } 203 | 204 | public function updateAsync(array $attributes = [], array $options = []) 205 | { 206 | return $this->fill($attributes)->saveAsync($options); 207 | } 208 | 209 | public static function create(array $attributes = []) 210 | { 211 | $model = new static; 212 | 213 | $model->fill($attributes)->save(); 214 | 215 | return $model; 216 | } 217 | 218 | public function delete() 219 | { 220 | if (is_null($this->getKeyName())) { 221 | throw new Exception('No primary key defined on model.'); 222 | } 223 | 224 | if ($this->exists) { 225 | if ($this->fireModelEvent('deleting') === false) { 226 | return false; 227 | } 228 | 229 | $this->exists = false; 230 | 231 | $success = $this->newQuery()->delete(); 232 | 233 | if ($success) { 234 | $this->fireModelEvent('deleted', false); 235 | } 236 | 237 | return $success; 238 | } 239 | } 240 | 241 | public function deleteAsync() 242 | { 243 | if (is_null($this->getKeyName())) { 244 | throw new Exception('No primary key defined on model.'); 245 | } 246 | 247 | if ($this->exists) { 248 | if ($this->fireModelEvent('deleting') === false) { 249 | return false; 250 | } 251 | 252 | $this->exists = false; 253 | 254 | $deletePromise = $this->newQuery()->deleteAsync(); 255 | 256 | $deletePromise->then(function () { 257 | $this->fireModelEvent('deleted', false); 258 | }); 259 | 260 | return $deletePromise; 261 | } 262 | } 263 | 264 | public static function all($columns = []) 265 | { 266 | $instance = new static; 267 | 268 | return $instance->newQuery()->get($columns); 269 | } 270 | 271 | public function refresh() 272 | { 273 | if (! $this->exists) { 274 | return $this; 275 | } 276 | 277 | $query = $this->newQuery(); 278 | 279 | $refreshed = $query->find($this->getKeys()); 280 | 281 | $this->setRawAttributes($refreshed->toArray()); 282 | 283 | return $this; 284 | } 285 | 286 | /** 287 | * @return DynamoDbQueryBuilder 288 | */ 289 | public function newQuery() 290 | { 291 | $builder = new DynamoDbQueryBuilder($this); 292 | 293 | foreach ($this->getGlobalScopes() as $identifier => $scope) { 294 | $builder->withGlobalScope($identifier, $scope); 295 | } 296 | 297 | return $builder; 298 | } 299 | 300 | public function hasCompositeKey() 301 | { 302 | return !empty($this->compositeKey); 303 | } 304 | 305 | /** 306 | * @deprecated 307 | * @param $item 308 | * @return array 309 | */ 310 | public function marshalItem($item) 311 | { 312 | return $this->marshaler->marshalItem($item); 313 | } 314 | 315 | /** 316 | * @deprecated 317 | * @param $value 318 | * @return array 319 | */ 320 | public function marshalValue($value) 321 | { 322 | return $this->marshaler->marshalValue($value); 323 | } 324 | 325 | /** 326 | * @deprecated 327 | * @param $item 328 | * @return array|\stdClass 329 | */ 330 | public function unmarshalItem($item) 331 | { 332 | return $this->marshaler->unmarshalItem($item); 333 | } 334 | 335 | public function setId($id) 336 | { 337 | if (!is_array($id)) { 338 | $this->setAttribute($this->getKeyName(), $id); 339 | 340 | return $this; 341 | } 342 | 343 | foreach ($id as $keyName => $value) { 344 | $this->setAttribute($keyName, $value); 345 | } 346 | 347 | return $this; 348 | } 349 | 350 | /** 351 | * @return \Aws\DynamoDb\DynamoDbClient 352 | */ 353 | public function getClient() 354 | { 355 | return static::$dynamoDb->getClient($this->getConnectionName()); 356 | } 357 | 358 | /** 359 | * Get the value of the model's primary key. 360 | * 361 | * @return mixed 362 | */ 363 | public function getKey() 364 | { 365 | return $this->getAttribute($this->getKeyName()); 366 | } 367 | 368 | /** 369 | * Get the value of the model's primary / composite key. 370 | * Use this if you always want the key values in associative array form. 371 | * 372 | * @return array 373 | * 374 | * ['id' => 'foo'] 375 | * 376 | * or 377 | * 378 | * ['id' => 'foo', 'id2' => 'bar'] 379 | */ 380 | public function getKeys() 381 | { 382 | if ($this->hasCompositeKey()) { 383 | $key = []; 384 | 385 | foreach ($this->compositeKey as $name) { 386 | $key[$name] = $this->getAttribute($name); 387 | } 388 | 389 | return $key; 390 | } 391 | 392 | $name = $this->getKeyName(); 393 | 394 | return [$name => $this->getAttribute($name)]; 395 | } 396 | 397 | /** 398 | * Get the primary key for the model. 399 | * 400 | * @return string 401 | */ 402 | public function getKeyName() 403 | { 404 | return $this->primaryKey; 405 | } 406 | 407 | /** 408 | * Get the primary/composite key for the model. 409 | * 410 | * @return array 411 | */ 412 | public function getKeyNames() 413 | { 414 | return $this->hasCompositeKey() ? $this->compositeKey : [$this->primaryKey]; 415 | } 416 | 417 | /** 418 | * @return array 419 | */ 420 | public function getDynamoDbIndexKeys() 421 | { 422 | return $this->dynamoDbIndexKeys; 423 | } 424 | 425 | /** 426 | * @param array $dynamoDbIndexKeys 427 | */ 428 | public function setDynamoDbIndexKeys($dynamoDbIndexKeys) 429 | { 430 | $this->dynamoDbIndexKeys = $dynamoDbIndexKeys; 431 | } 432 | 433 | /** 434 | * @deprecated 435 | * @return \Aws\DynamoDb\Marshaler 436 | */ 437 | public function getMarshaler() 438 | { 439 | return $this->marshaler; 440 | } 441 | 442 | /** 443 | * Remove non-serializable properties when serializing. 444 | * 445 | * @return array 446 | */ 447 | public function __sleep() 448 | { 449 | return array_keys( 450 | Arr::except(get_object_vars($this), ['marshaler', 'attributeFilter']) 451 | ); 452 | } 453 | 454 | /** 455 | * When a model is being unserialized, check if it needs to be booted and setup DynamoDB. 456 | * 457 | * @return void 458 | */ 459 | public function __wakeup() 460 | { 461 | parent::__wakeup(); 462 | $this->setupDynamoDb(); 463 | } 464 | } 465 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | laravel-dynamodb 2 | ================ 3 | 4 | [![Latest Stable Version](https://poser.pugx.org/baopham/dynamodb/v/stable)](https://packagist.org/packages/baopham/dynamodb) 5 | [![Total Downloads](https://poser.pugx.org/baopham/dynamodb/downloads)](https://packagist.org/packages/baopham/dynamodb) 6 | [![Latest Unstable Version](https://poser.pugx.org/baopham/dynamodb/v/unstable)](https://packagist.org/packages/baopham/dynamodb) 7 | [![Build Status](https://travis-ci.org/baopham/laravel-dynamodb.svg?branch=master)](https://travis-ci.org/baopham/laravel-dynamodb) 8 | [![Code Coverage](https://scrutinizer-ci.com/g/baopham/laravel-dynamodb/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/baopham/laravel-dynamodb/?branch=master) 9 | [![License](https://poser.pugx.org/baopham/dynamodb/license)](https://packagist.org/packages/baopham/dynamodb) 10 | 11 | Supports all key types - primary hash key and composite keys. 12 | 13 | > For advanced users only. If you're not familiar with Laravel, Laravel Eloquent and DynamoDB, then I suggest that you get familiar with those first. 14 | 15 | **Breaking changes in v2: config no longer lives in config/services.php** 16 | 17 | * [Install](#install) 18 | * [Usage](#usage) 19 | * [find() and delete()](#find-and-delete) 20 | * [Conditions](#conditions) 21 | * [all() and first()](#all-and-first) 22 | * [Pagination](#pagination) 23 | * [update](#update) / [updateAsync()](#updateasync) 24 | * [save](#save) / [saveAsync()](#saveasync) 25 | * [delete](#delete) / [deleteAsync()](#deleteasync) 26 | * [chunk](#chunk) 27 | * [limit() and take()](#limit-and-take) 28 | * [firstOrFail()](#firstorfail) 29 | * [findOrFail()](#findorfail) 30 | * [refresh()](#refresh) 31 | * [Query scope](#query-scope) 32 | * [REMOVE — Deleting Attributes From An Item](#remove--deleting-attributes-from-an-item) 33 | * [toSql() Style](#tosql-style) 34 | * [Decorate Query](#decorate-query) 35 | * [Indexes](#indexes) 36 | * [Composite Keys](#composite-keys) 37 | * [Query Builder](#query-builder) 38 | * [Requirements](#requirements) 39 | * [Migrate from v1 to v2](#migrate-from-v1-to-v2) 40 | * [FAQ](#faq) 41 | * [License](LICENSE) 42 | * [Author and Contributors](#author-and-contributors) 43 | 44 | Install 45 | ------ 46 | 47 | * Composer install 48 | ```bash 49 | composer require baopham/dynamodb 50 | ``` 51 | 52 | * Install service provider (< Laravel 5.5): 53 | 54 | ```php 55 | // config/app.php 56 | 57 | 'providers' => [ 58 | ... 59 | BaoPham\DynamoDb\DynamoDbServiceProvider::class, 60 | ... 61 | ]; 62 | ``` 63 | 64 | * Run 65 | 66 | ```php 67 | php artisan vendor:publish --provider 'BaoPham\DynamoDb\DynamoDbServiceProvider' 68 | ``` 69 | 70 | * Update DynamoDb config in [config/dynamodb.php](config/dynamodb.php) 71 | 72 | **For Lumen** 73 | 74 | * Try [this](https://github.com/laravelista/lumen-vendor-publish) to install the `vendor:publish` command 75 | 76 | * Load configuration file and enable Eloquent support in `bootstrap/app.php`: 77 | 78 | ```php 79 | $app = new Laravel\Lumen\Application( 80 | realpath(__DIR__.'/../') 81 | ); 82 | 83 | // Load dynamodb config file 84 | $app->configure('dynamodb'); 85 | 86 | // Enable Facade support 87 | $app->withFacades(); 88 | 89 | // Enable Eloquent support 90 | $app->withEloquent(); 91 | ``` 92 | 93 | 94 | 95 | Usage 96 | ----- 97 | * Extends your model with `BaoPham\DynamoDb\DynamoDbModel`, then you can use Eloquent methods that are supported. The idea here is that you can switch back to Eloquent without changing your queries. 98 | * Or if you want to sync your DB table with a DynamoDb table, use trait `BaoPham\DynamoDb\ModelTrait`, it will call a `PutItem` after the model is saved. 99 | * Alternatively, you can use the [query builder](#query-builder) facade to build more complex queries. 100 | * AWS SDK v3 for PHP uses guzzlehttp promises to allow for asynchronous workflows. Using this package you can run eloquent queries like [delete](#deleteasync), [update](#updateasync), [save](#saveasync) asynchronously on DynamoDb. 101 | 102 | ### Supported features: 103 | 104 | #### find() and delete() 105 | 106 | ```php 107 | $model->find($id, array $columns = []); 108 | $model->findMany($ids, array $columns = []); 109 | $model->delete(); 110 | $model->deleteAsync()->wait(); 111 | ``` 112 | 113 | #### Conditions 114 | 115 | ```php 116 | // Using getIterator() 117 | // If 'key' is the primary key or a global/local index and it is a supported Query condition, 118 | // will use 'Query', otherwise 'Scan'. 119 | $model->where('key', 'key value')->get(); 120 | 121 | $model->where(['key' => 'key value']); 122 | 123 | // Chainable for 'AND'. 124 | $model->where('foo', 'bar') 125 | ->where('foo2', '!=', 'bar2') 126 | ->get(); 127 | 128 | // Chainable for 'OR'. 129 | $model->where('foo', 'bar') 130 | ->orWhere('foo2', '!=', 'bar2') 131 | ->get(); 132 | 133 | // Other types of conditions 134 | $model->where('count', '>', 0)->get(); 135 | $model->where('count', '>=', 0)->get(); 136 | $model->where('count', '<', 0)->get(); 137 | $model->where('count', '<=', 0)->get(); 138 | $model->whereIn('count', [0, 100])->get(); 139 | $model->whereNotIn('count', [0, 100])->get(); 140 | $model->where('count', 'between', [0, 100])->get(); 141 | $model->where('description', 'begins_with', 'foo')->get(); 142 | $model->where('description', 'contains', 'foo')->get(); 143 | $model->where('description', 'not_contains', 'foo')->get(); 144 | 145 | // Nested conditions 146 | $model->where('name', 'foo') 147 | ->where(function ($query) { 148 | $query->where('count', 10)->orWhere('count', 20); 149 | }) 150 | ->get(); 151 | 152 | // Nested attributes 153 | $model->where('nestedMap.foo', 'bar')->where('list[0]', 'baz')->get(); 154 | ``` 155 | 156 | ##### whereNull() and whereNotNull() 157 | 158 | > NULL and NOT_NULL only check for the attribute presence not its value being null 159 | > See: http://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Condition.html 160 | 161 | ```php 162 | $model->whereNull('name'); 163 | $model->whereNotNull('name'); 164 | ``` 165 | 166 | #### all() and first() 167 | 168 | ```php 169 | // Using scan operator, not too reliable since DynamoDb will only give 1MB total of data. 170 | $model->all(); 171 | 172 | // Basically a scan but with limit of 1 item. 173 | $model->first(); 174 | ``` 175 | 176 | #### Pagination 177 | 178 | Unfortunately, offset of how many records to skip does not make sense for DynamoDb. 179 | Instead, provide the last result of the previous query as the starting point for the next query. 180 | 181 | **Examples:** 182 | 183 | For query such as: 184 | 185 | ```php 186 | $query = $model->where('count', 10)->limit(2); 187 | $items = $query->all(); 188 | $last = $items->last(); 189 | ``` 190 | 191 | Take the last item of this query result as the next "offset": 192 | 193 | ```php 194 | $nextPage = $query->after($last)->limit(2)->all(); 195 | // or 196 | $nextPage = $query->afterKey($items->lastKey())->limit(2)->all(); 197 | // or (for query without index condition only) 198 | $nextPage = $query->afterKey($last->getKeys())->limit(2)->all(); 199 | ``` 200 | 201 | #### update() 202 | 203 | ```php 204 | // update 205 | $model->update($attributes); 206 | ``` 207 | 208 | #### updateAsync() 209 | 210 | ```php 211 | // update asynchronously and wait on the promise for completion. 212 | $model->updateAsync($attributes)->wait(); 213 | ``` 214 | 215 | #### save() 216 | 217 | ```php 218 | $model = new Model(); 219 | // Define fillable attributes in your Model class. 220 | $model->fillableAttr1 = 'foo'; 221 | $model->fillableAttr2 = 'foo'; 222 | // DynamoDb doesn't support incremented Id, so you need to use UUID for the primary key. 223 | $model->id = 'de305d54-75b4-431b-adb2-eb6b9e546014'; 224 | $model->save(); 225 | ``` 226 | 227 | #### saveAsync() 228 | 229 | Saving single model asynchronously and waiting on the promise for completion. 230 | 231 | ```php 232 | $model = new Model(); 233 | // Define fillable attributes in your Model class. 234 | $model->fillableAttr1 = 'foo'; 235 | $model->fillableAttr2 = 'bar'; 236 | // DynamoDb doesn't support incremented Id, so you need to use UUID for the primary key. 237 | $model->id = 'de305d54-75b4-431b-adb2-eb6b9e546014'; 238 | $model->saveAsync()->wait(); 239 | ``` 240 | 241 | Saving multiple models asynchronously and waiting on all of them simultaneously. 242 | 243 | ```php 244 | for($i = 0; $i < 10; $i++){ 245 | $model = new Model(); 246 | // Define fillable attributes in your Model class. 247 | $model->fillableAttr1 = 'foo'; 248 | $model->fillableAttr2 = 'bar'; 249 | // DynamoDb doesn't support incremented Id, so you need to use UUID for the primary key. 250 | $model->id = uniqid(); 251 | // Returns a promise which you can wait on later. 252 | $promises[] = $model->saveAsync(); 253 | } 254 | 255 | \GuzzleHttp\Promise\all($promises)->wait(); 256 | ``` 257 | 258 | #### delete() 259 | 260 | ```php 261 | $model->delete(); 262 | ``` 263 | 264 | #### deleteAsync() 265 | 266 | ```php 267 | $model->deleteAsync()->wait(); 268 | ``` 269 | 270 | #### chunk() 271 | 272 | ```php 273 | $model->chunk(10, function ($records) { 274 | foreach ($records as $record) { 275 | 276 | } 277 | }); 278 | ``` 279 | 280 | #### limit() and take() 281 | 282 | ```php 283 | // Use this with caution unless your limit is small. 284 | // DynamoDB has a limit of 1MB so if your limit is very big, the results will not be expected. 285 | $model->where('name', 'foo')->take(3)->get(); 286 | ``` 287 | 288 | #### firstOrFail() 289 | 290 | ```php 291 | $model->where('name', 'foo')->firstOrFail(); 292 | // for composite key 293 | $model->where('id', 'foo')->where('id2', 'bar')->firstOrFail(); 294 | ``` 295 | 296 | #### findOrFail() 297 | 298 | ```php 299 | $model->findOrFail('foo'); 300 | // for composite key 301 | $model->findOrFail(['id' => 'foo', 'id2' => 'bar']); 302 | ``` 303 | 304 | #### refresh() 305 | 306 | ```php 307 | $model = Model::first(); 308 | $model->refresh(); 309 | ``` 310 | 311 | #### Query Scope 312 | 313 | ```php 314 | class Foo extends DynamoDbModel 315 | { 316 | protected static function boot() 317 | { 318 | parent::boot(); 319 | 320 | static::addGlobalScope('count', function (DynamoDbQueryBuilder $builder) { 321 | $builder->where('count', '>', 6); 322 | }); 323 | } 324 | 325 | public function scopeCountUnderFour($builder) 326 | { 327 | return $builder->where('count', '<', 4); 328 | } 329 | 330 | public function scopeCountUnder($builder, $count) 331 | { 332 | return $builder->where('count', '<', $count); 333 | } 334 | } 335 | 336 | $foo = new Foo(); 337 | // Global scope will be applied 338 | $foo->all(); 339 | // Local scope 340 | $foo->withoutGlobalScopes()->countUnderFour()->get(); 341 | // Dynamic local scope 342 | $foo->withoutGlobalScopes()->countUnder(6)->get(); 343 | ``` 344 | 345 | #### REMOVE — Deleting Attributes From An Item 346 | 347 | > See: http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.UpdateExpressions.html#Expressions.UpdateExpressions.REMOVE 348 | 349 | ```php 350 | $model = new Model(); 351 | $model->where('id', 'foo')->removeAttribute('name', 'description', 'nested.foo', 'nestedArray[0]'); 352 | 353 | // Or 354 | Model::find('foo')->removeAttribute('name', 'description', 'nested.foo', 'nestedArray[0]'); 355 | ``` 356 | 357 | 358 | #### toSql() Style 359 | 360 | For debugging purposes, you can choose to convert to the actual DynamoDb query 361 | 362 | ```php 363 | $raw = $model->where('count', '>', 10)->toDynamoDbQuery(); 364 | // $op is either "Scan" or "Query" 365 | $op = $raw->op; 366 | // The query body being sent to AWS 367 | $query = $raw->query; 368 | ``` 369 | 370 | where `$raw` is an instance of [RawDynamoDbQuery](./src/RawDynamoDbQuery.php) 371 | 372 | 373 | #### Decorate Query 374 | 375 | Use `decorate` when you want to enhance the query. For example: 376 | 377 | To set the order of the sort key: 378 | 379 | ```php 380 | $items = $model 381 | ->where('hash', 'hash-value') 382 | ->where('range', '>', 10) 383 | ->decorate(function (RawDynamoDbQuery $raw) { 384 | // desc order 385 | $raw->query['ScanIndexForward'] = false; 386 | }) 387 | ->get(); 388 | ``` 389 | 390 | To force to use "Query" instead of "Scan" if the library fails to detect the correct operation: 391 | 392 | ```php 393 | $items = $model 394 | ->where('hash', 'hash-value') 395 | ->decorate(function (RawDynamoDbQuery $raw) { 396 | $raw->op = 'Query'; 397 | }) 398 | ->get(); 399 | ``` 400 | 401 | Indexes 402 | ----------- 403 | If your table has indexes, make sure to declare them in your model class like so 404 | 405 | ```php 406 | /** 407 | * Indexes. 408 | * [ 409 | * '' => [ 410 | * 'hash' => '' 411 | * ], 412 | * '' => [ 413 | * 'hash' => '', 414 | * 'range' => '' 415 | * ], 416 | * ] 417 | * 418 | * @var array 419 | */ 420 | protected $dynamoDbIndexKeys = [ 421 | 'count_index' => [ 422 | 'hash' => 'count' 423 | ], 424 | ]; 425 | ``` 426 | 427 | Note that order of index matters when a key exists in multiple indexes. 428 | For example, we have this 429 | 430 | ```php 431 | $model->where('user_id', 123)->where('count', '>', 10)->get(); 432 | ``` 433 | 434 | with 435 | 436 | ```php 437 | protected $dynamoDbIndexKeys = [ 438 | 'count_index' => [ 439 | 'hash' => 'user_id', 440 | 'range' => 'count' 441 | ], 442 | 'user_index' => [ 443 | 'hash' => 'user_id', 444 | ], 445 | ]; 446 | ``` 447 | 448 | will use `count_index`. 449 | 450 | ```php 451 | protected $dynamoDbIndexKeys = [ 452 | 'user_index' => [ 453 | 'hash' => 'user_id', 454 | ], 455 | 'count_index' => [ 456 | 'hash' => 'user_id', 457 | 'range' => 'count' 458 | ] 459 | ]; 460 | ``` 461 | 462 | will use `user_index`. 463 | 464 | 465 | Most of the time, you should not have to do anything but if you need to use a specific index, you can specify it like so 466 | 467 | ```php 468 | $model->where('user_id', 123)->where('count', '>', 10)->withIndex('count_index')->get(); 469 | ``` 470 | 471 | 472 | Composite Keys 473 | -------------- 474 | To use composite keys with your model: 475 | 476 | * Set `$compositeKey` to an array of the attributes names comprising the key, e.g. 477 | 478 | ```php 479 | protected $primaryKey = 'customer_id'; 480 | protected $compositeKey = ['customer_id', 'agent_id']; 481 | ``` 482 | 483 | * To find a record with a composite key 484 | 485 | ```php 486 | $model->find(['customer_id' => 'value1', 'agent_id' => 'value2']); 487 | ``` 488 | 489 | Query Builder 490 | ------------- 491 | 492 | Use `DynamoDb` facade to build raw queries 493 | 494 | ```php 495 | use BaoPham\DynamoDb\Facades\DynamoDb; 496 | 497 | DynamoDb::table('articles') 498 | // call set to build the query body to be sent to AWS 499 | ->setFilterExpression('#name = :name') 500 | ->setExpressionAttributeNames(['#name' => 'author_name']) 501 | ->setExpressionAttributeValues([':name' => DynamoDb::marshalValue('Bao')]) 502 | ->prepare() 503 | // the query body will be sent upon calling this. 504 | ->scan(); // supports any DynamoDbClient methods (e.g. batchWriteItem, batchGetItem, etc.) 505 | 506 | DynamoDb::table('articles') 507 | ->setIndexName('author_name') 508 | ->setKeyConditionExpression('#name = :name') 509 | ->setProjectionExpression('id, author_name') 510 | // Can set the attribute mapping one by one instead 511 | ->setExpressionAttributeName('#name', 'author_name') 512 | ->setExpressionAttributeValue(':name', DynamoDb::marshalValue('Bao')) 513 | ->prepare() 514 | ->query(); 515 | 516 | DynamoDb::table('articles') 517 | ->setKey(DynamoDb::marshalItem(['id' => 'ae025ed8'])) 518 | ->setUpdateExpression('REMOVE #c, #t') 519 | ->setExpressionAttributeName('#c', 'comments') 520 | ->setExpressionAttributeName('#t', 'tags') 521 | ->prepare() 522 | ->updateItem(); 523 | 524 | DynamoDb::table('articles') 525 | ->setKey(DynamoDb::marshalItem(['id' => 'ae025ed8'])) 526 | ->prepare() 527 | ->deleteItem(); 528 | 529 | DynamoDb::table('articles') 530 | ->setItem(DynamoDb::marshalItem(['id' => 'ae025ed8', 'author_name' => 'New Name'])) 531 | ->prepare() 532 | ->putItem(); 533 | 534 | // Or, instead of ::table() 535 | DynamoDb::newQuery() 536 | ->setTableName('articles') 537 | 538 | // Or access the DynamoDbClient instance directly 539 | DynamoDb::client(); 540 | // pass in the connection name to get a different client instance other than the default. 541 | DynamoDb::client('test'); 542 | ``` 543 | 544 | The query builder methods are in the form of `set`, where `` is the key name of the query body to be sent. 545 | 546 | For example, to build an [`UpdateTable`](https://docs.aws.amazon.com/aws-sdk-php/v3/api/api-dynamodb-2012-08-10.html#updatetable) query: 547 | 548 | ```php 549 | [ 550 | 'AttributeDefinitions' => ..., 551 | 'GlobalSecondaryIndexUpdates' => ..., 552 | 'TableName' => ... 553 | ] 554 | ``` 555 | 556 | Do: 557 | 558 | ```php 559 | $query = DynamoDb::table('articles') 560 | ->setAttributeDefinitions(...) 561 | ->setGlobalSecondaryIndexUpdates(...); 562 | ``` 563 | 564 | And when ready: 565 | 566 | ```php 567 | $query->prepare()->updateTable(); 568 | ``` 569 | 570 | Requirements 571 | ------------- 572 | Laravel ^5.1 573 | 574 | 575 | Migrate from v1 to v2 576 | --------------------- 577 | 578 | Follow these steps: 579 | 580 | 1. Update your `composer.json` to use v2 581 | 1. Run `composer update` 582 | 1. Run `php artisan vendor:publish` 583 | 1. Move your DynamoDb config in `config/services.php` to the new config file `config/dynamodb.php` as one of the connections 584 | 1. Move `key`, `secret`, `token` inside `credentials` 585 | 1. Rename `local_endpoint` to `endpoint` 586 | 1. Remove `local` field 587 | 588 | 589 | FAQ 590 | --- 591 | Q: Cannot assign `id` property if its not in the fillable array 592 | A: Try [this](https://github.com/baopham/laravel-dynamodb/issues/10)? 593 | 594 | 595 | Q: How to create migration? 596 | A: Please see [this issue](https://github.com/baopham/laravel-dynamodb/issues/90) 597 | 598 | 599 | Q: How to use with factory? 600 | A: Please see [this issue](https://github.com/baopham/laravel-dynamodb/issues/111) 601 | 602 | 603 | Q: How do I use with Job? Getting a SerializesModels error 604 | A: You can either [write your own restoreModel](https://github.com/baopham/laravel-dynamodb/issues/132) or remove the `SerializesModels` trait from your Job. 605 | 606 | 607 | Author and Contributors 608 | ------- 609 | * [Bao Pham](https://github.com/baopham/laravel-dynamodb) 610 | * [warrick-loyaltycorp](https://github.com/warrick-loyaltycorp) 611 | * [Alexander Ward](https://github.com/cthos) 612 | * [Quang Ngo](https://github.com/vanquang9387) 613 | * [David Higgins](https://github.com/zoul0813) 614 | * [Damon Williams](https://github.com/footballencarta) 615 | * [David Palmer](https://github.com/dp88) 616 | -------------------------------------------------------------------------------- /src/DynamoDbQueryBuilder.php: -------------------------------------------------------------------------------- 1 | model = $model; 82 | $this->client = $model->getClient(); 83 | $this->setupExpressions(); 84 | } 85 | 86 | /** 87 | * Alias to set the "limit" value of the query. 88 | * 89 | * @param int $value 90 | * @return DynamoDbQueryBuilder 91 | */ 92 | public function take($value) 93 | { 94 | return $this->limit($value); 95 | } 96 | 97 | /** 98 | * Set the "limit" value of the query. 99 | * 100 | * @param int $value 101 | * @return $this 102 | */ 103 | public function limit($value) 104 | { 105 | $this->limit = $value; 106 | 107 | return $this; 108 | } 109 | 110 | /** 111 | * Alias to set the "offset" value of the query. 112 | * 113 | * @param int $value 114 | * @throws NotSupportedException 115 | */ 116 | public function skip($value) 117 | { 118 | return $this->offset($value); 119 | } 120 | 121 | /** 122 | * Set the "offset" value of the query. 123 | * 124 | * @param int $value 125 | * @throws NotSupportedException 126 | */ 127 | public function offset($value) 128 | { 129 | throw new NotSupportedException('Skip/Offset is not supported. Consider using after() instead'); 130 | } 131 | 132 | /** 133 | * Determine the starting point (exclusively) of the query. 134 | * Unfortunately, offset of how many records to skip does not make sense for DynamoDb. 135 | * Instead, provide the last result of the previous query as the starting point for the next query. 136 | * 137 | * @param DynamoDbModel|null $after 138 | * Examples: 139 | * 140 | * For query such as 141 | * $query = $model->where('count', 10)->limit(2); 142 | * $last = $query->all()->last(); 143 | * Take the last item of this query result as the next "offset": 144 | * $nextPage = $query->after($last)->limit(2)->all(); 145 | * 146 | * Alternatively, pass in nothing to reset the starting point. 147 | * 148 | * @return $this 149 | */ 150 | public function after(DynamoDbModel $after = null) 151 | { 152 | if (empty($after)) { 153 | $this->lastEvaluatedKey = null; 154 | 155 | return $this; 156 | } 157 | 158 | $afterKey = $after->getKeys(); 159 | 160 | $analyzer = $this->getConditionAnalyzer(); 161 | 162 | if ($index = $analyzer->index()) { 163 | foreach ($index->columns() as $column) { 164 | $afterKey[$column] = $after->getAttribute($column); 165 | } 166 | } 167 | 168 | $this->lastEvaluatedKey = DynamoDb::marshalItem($afterKey); 169 | 170 | return $this; 171 | } 172 | 173 | /** 174 | * Similar to after(), but instead of using the model instance, the model's keys are used. 175 | * Use $collection->lastKey() or $model->getKeys() to retrieve the value. 176 | * 177 | * @param Array $key 178 | * Examples: 179 | * 180 | * For query such as 181 | * $query = $model->where('count', 10)->limit(2); 182 | * $items = $query->all(); 183 | * Take the last item of this query result as the next "offset": 184 | * $nextPage = $query->afterKey($items->lastKey())->limit(2)->all(); 185 | * 186 | * Alternatively, pass in nothing to reset the starting point. 187 | * 188 | * @return $this 189 | */ 190 | public function afterKey($key = null) 191 | { 192 | $this->lastEvaluatedKey = empty($key) ? null : DynamoDb::marshalItem($key); 193 | return $this; 194 | } 195 | 196 | /** 197 | * Set the index name manually 198 | * 199 | * @param string $index The index name 200 | * @return $this 201 | */ 202 | public function withIndex($index) 203 | { 204 | $this->index = $index; 205 | return $this; 206 | } 207 | 208 | public function where($column, $operator = null, $value = null, $boolean = 'and') 209 | { 210 | // If the column is an array, we will assume it is an array of key-value pairs 211 | // and can add them each as a where clause. We will maintain the boolean we 212 | // received when the method was called and pass it into the nested where. 213 | if (is_array($column)) { 214 | foreach ($column as $key => $value) { 215 | $this->where($key, '=', $value, $boolean); 216 | } 217 | 218 | return $this; 219 | } 220 | 221 | // Here we will make some assumptions about the operator. If only 2 values are 222 | // passed to the method, we will assume that the operator is an equals sign 223 | // and keep going. Otherwise, we'll require the operator to be passed in. 224 | if (func_num_args() == 2) { 225 | list($value, $operator) = [$operator, '=']; 226 | } 227 | 228 | // If the columns is actually a Closure instance, we will assume the developer 229 | // wants to begin a nested where statement which is wrapped in parenthesis. 230 | // We'll add that Closure to the query then return back out immediately. 231 | if ($column instanceof Closure) { 232 | return $this->whereNested($column, $boolean); 233 | } 234 | 235 | // If the given operator is not found in the list of valid operators we will 236 | // assume that the developer is just short-cutting the '=' operators and 237 | // we will set the operators to '=' and set the values appropriately. 238 | if (!ComparisonOperator::isValidOperator($operator)) { 239 | list($value, $operator) = [$operator, '=']; 240 | } 241 | 242 | // If the value is a Closure, it means the developer is performing an entire 243 | // sub-select within the query and we will need to compile the sub-select 244 | // within the where clause to get the appropriate query record results. 245 | if ($value instanceof Closure) { 246 | throw new NotSupportedException('Closure in where clause is not supported'); 247 | } 248 | 249 | $this->wheres[] = [ 250 | 'column' => $column, 251 | 'type' => ComparisonOperator::getDynamoDbOperator($operator), 252 | 'value' => $value, 253 | 'boolean' => $boolean, 254 | ]; 255 | 256 | return $this; 257 | } 258 | 259 | /** 260 | * Add a nested where statement to the query. 261 | * 262 | * @param \Closure $callback 263 | * @param string $boolean 264 | * @return $this 265 | */ 266 | public function whereNested(Closure $callback, $boolean = 'and') 267 | { 268 | call_user_func($callback, $query = $this->forNestedWhere()); 269 | 270 | return $this->addNestedWhereQuery($query, $boolean); 271 | } 272 | 273 | /** 274 | * Create a new query instance for nested where condition. 275 | * 276 | * @return $this 277 | */ 278 | public function forNestedWhere() 279 | { 280 | return $this->newQuery(); 281 | } 282 | 283 | /** 284 | * Add another query builder as a nested where to the query builder. 285 | * 286 | * @param DynamoDbQueryBuilder $query 287 | * @param string $boolean 288 | * @return $this 289 | */ 290 | public function addNestedWhereQuery($query, $boolean = 'and') 291 | { 292 | if (count($query->wheres)) { 293 | $type = 'Nested'; 294 | $column = null; 295 | $value = $query->wheres; 296 | $this->wheres[] = compact('column', 'type', 'value', 'boolean'); 297 | } 298 | 299 | return $this; 300 | } 301 | 302 | /** 303 | * Add an "or where" clause to the query. 304 | * 305 | * @param string $column 306 | * @param string $operator 307 | * @param mixed $value 308 | * @return $this 309 | */ 310 | public function orWhere($column, $operator = null, $value = null) 311 | { 312 | return $this->where($column, $operator, $value, 'or'); 313 | } 314 | 315 | /** 316 | * Add a "where in" clause to the query. 317 | * 318 | * @param string $column 319 | * @param mixed $values 320 | * @param string $boolean 321 | * @param bool $not 322 | * @return $this 323 | * @throws NotSupportedException 324 | */ 325 | public function whereIn($column, $values, $boolean = 'and', $not = false) 326 | { 327 | if ($not) { 328 | throw new NotSupportedException('"not in" is not a valid DynamoDB comparison operator'); 329 | } 330 | 331 | // If the value is a query builder instance, not supported 332 | if ($values instanceof static) { 333 | throw new NotSupportedException('Value is a query builder instance'); 334 | } 335 | 336 | // If the value of the where in clause is actually a Closure, not supported 337 | if ($values instanceof Closure) { 338 | throw new NotSupportedException('Value is a Closure'); 339 | } 340 | 341 | // Next, if the value is Arrayable we need to cast it to its raw array form 342 | if ($values instanceof Arrayable) { 343 | $values = $values->toArray(); 344 | } 345 | 346 | return $this->where($column, ComparisonOperator::IN, $values, $boolean); 347 | } 348 | 349 | /** 350 | * Add an "or where in" clause to the query. 351 | * 352 | * @param string $column 353 | * @param mixed $values 354 | * @return $this 355 | */ 356 | public function orWhereIn($column, $values) 357 | { 358 | return $this->whereIn($column, $values, 'or'); 359 | } 360 | 361 | /** 362 | * Add a "where null" clause to the query. 363 | * 364 | * @param string $column 365 | * @param string $boolean 366 | * @param bool $not 367 | * @return $this 368 | */ 369 | public function whereNull($column, $boolean = 'and', $not = false) 370 | { 371 | $type = $not ? ComparisonOperator::NOT_NULL : ComparisonOperator::NULL; 372 | 373 | $this->wheres[] = compact('column', 'type', 'boolean'); 374 | 375 | return $this; 376 | } 377 | 378 | /** 379 | * Add an "or where null" clause to the query. 380 | * 381 | * @param string $column 382 | * @return $this 383 | */ 384 | public function orWhereNull($column) 385 | { 386 | return $this->whereNull($column, 'or'); 387 | } 388 | 389 | /** 390 | * Add an "or where not null" clause to the query. 391 | * 392 | * @param string $column 393 | * @return $this 394 | */ 395 | public function orWhereNotNull($column) 396 | { 397 | return $this->whereNotNull($column, 'or'); 398 | } 399 | 400 | /** 401 | * Add a "where not null" clause to the query. 402 | * 403 | * @param string $column 404 | * @param string $boolean 405 | * @return $this 406 | */ 407 | public function whereNotNull($column, $boolean = 'and') 408 | { 409 | return $this->whereNull($column, $boolean, true); 410 | } 411 | 412 | /** 413 | * Get a new instance of the query builder. 414 | * 415 | * @return DynamoDbQueryBuilder 416 | */ 417 | public function newQuery() 418 | { 419 | return new static($this->getModel()); 420 | } 421 | 422 | /** 423 | * Implements the Query Chunk method 424 | * 425 | * @param int $chunkSize 426 | * @param callable $callback 427 | */ 428 | public function chunk($chunkSize, callable $callback) 429 | { 430 | while (true) { 431 | $results = $this->getAll([], $chunkSize, false); 432 | 433 | if (!$results->isEmpty()) { 434 | if (call_user_func($callback, $results) === false) { 435 | return false; 436 | } 437 | } 438 | 439 | if (empty($this->lastEvaluatedKey)) { 440 | break; 441 | } 442 | } 443 | 444 | return true; 445 | } 446 | 447 | /** 448 | * @param $id 449 | * @param array $columns 450 | * @return DynamoDbModel|\Illuminate\Database\Eloquent\Collection|null 451 | */ 452 | public function find($id, array $columns = []) 453 | { 454 | if ($this->isMultipleIds($id)) { 455 | return $this->findMany($id, $columns); 456 | } 457 | 458 | $this->resetExpressions(); 459 | 460 | $this->model->setId($id); 461 | 462 | $query = DynamoDb::table($this->model->getTable()) 463 | ->setKey(DynamoDb::marshalItem($this->model->getKeys())) 464 | ->setConsistentRead(true); 465 | 466 | if (!empty($columns)) { 467 | $query 468 | ->setProjectionExpression($this->projectionExpression->parse($columns)) 469 | ->setExpressionAttributeNames($this->expressionAttributeNames->all()); 470 | } 471 | 472 | $item = $query->prepare($this->client)->getItem(); 473 | 474 | $item = Arr::get($item->toArray(), 'Item'); 475 | 476 | if (empty($item)) { 477 | return null; 478 | } 479 | 480 | $item = DynamoDb::unmarshalItem($item); 481 | 482 | $model = $this->model->newInstance([], true); 483 | 484 | $model->setRawAttributes($item, true); 485 | 486 | return $model; 487 | } 488 | 489 | /** 490 | * @param $ids 491 | * @param array $columns 492 | * @return \Illuminate\Database\Eloquent\Collection 493 | */ 494 | public function findMany($ids, array $columns = []) 495 | { 496 | $collection = $this->model->newCollection(); 497 | 498 | if (empty($ids)) { 499 | return $collection; 500 | } 501 | 502 | $this->resetExpressions(); 503 | 504 | $table = $this->model->getTable(); 505 | 506 | $keys = collect($ids)->map(function ($id) { 507 | if (! is_array($id)) { 508 | $id = [$this->model->getKeyName() => $id]; 509 | } 510 | 511 | return DynamoDb::marshalItem($id); 512 | }); 513 | 514 | $subQuery = DynamoDb::newQuery() 515 | ->setKeys($keys->toArray()) 516 | ->setProjectionExpression($this->projectionExpression->parse($columns)) 517 | ->setExpressionAttributeNames($this->expressionAttributeNames->all()) 518 | ->prepare($this->client) 519 | ->query; 520 | 521 | $results = DynamoDb::newQuery() 522 | ->setRequestItems([$table => $subQuery]) 523 | ->prepare($this->client) 524 | ->batchGetItem(); 525 | 526 | foreach ($results['Responses'][$table] as $item) { 527 | $item = DynamoDb::unmarshalItem($item); 528 | $model = $this->model->newInstance([], true); 529 | $model->setRawAttributes($item, true); 530 | $collection->add($model); 531 | } 532 | 533 | return $collection; 534 | } 535 | 536 | public function findOrFail($id, $columns = []) 537 | { 538 | $result = $this->find($id, $columns); 539 | 540 | if ($this->isMultipleIds($id)) { 541 | if (count($result) == count(array_unique($id))) { 542 | return $result; 543 | } 544 | } elseif (! is_null($result)) { 545 | return $result; 546 | } 547 | 548 | throw (new ModelNotFoundException)->setModel( 549 | get_class($this->model), 550 | $id 551 | ); 552 | } 553 | 554 | public function first($columns = []) 555 | { 556 | $items = $this->getAll($columns, 1); 557 | 558 | return $items->first(); 559 | } 560 | 561 | public function firstOrFail($columns = []) 562 | { 563 | if (! is_null($model = $this->first($columns))) { 564 | return $model; 565 | } 566 | 567 | throw (new ModelNotFoundException)->setModel(get_class($this->model)); 568 | } 569 | 570 | /** 571 | * Remove attributes from an existing item 572 | * 573 | * @param array ...$attributes 574 | * @return bool 575 | * @throws InvalidQuery 576 | */ 577 | public function removeAttribute(...$attributes) 578 | { 579 | $keySet = !empty(array_filter($this->model->getKeys())); 580 | 581 | if (!$keySet) { 582 | $analyzer = $this->getConditionAnalyzer(); 583 | 584 | if (!$analyzer->isExactSearch()) { 585 | throw new InvalidQuery('Need to provide the key in your query'); 586 | } 587 | 588 | $id = $analyzer->identifierConditionValues(); 589 | $this->model->setId($id); 590 | } 591 | 592 | $key = DynamoDb::marshalItem($this->model->getKeys()); 593 | 594 | $this->resetExpressions(); 595 | 596 | /** @var \Aws\Result $result */ 597 | $result = DynamoDb::table($this->model->getTable()) 598 | ->setKey($key) 599 | ->setUpdateExpression($this->updateExpression->remove($attributes)) 600 | ->setExpressionAttributeNames($this->expressionAttributeNames->all()) 601 | ->setReturnValues('ALL_NEW') 602 | ->prepare($this->client) 603 | ->updateItem(); 604 | 605 | $success = Arr::get($result, '@metadata.statusCode') === 200; 606 | 607 | if ($success) { 608 | $this->model->setRawAttributes(DynamoDb::unmarshalItem($result->get('Attributes'))); 609 | $this->model->syncOriginal(); 610 | } 611 | 612 | return $success; 613 | } 614 | 615 | public function delete() 616 | { 617 | $result = DynamoDb::table($this->model->getTable()) 618 | ->setKey(DynamoDb::marshalItem($this->model->getKeys())) 619 | ->prepare($this->client) 620 | ->deleteItem(); 621 | 622 | return Arr::get($result->toArray(), '@metadata.statusCode') === 200; 623 | } 624 | 625 | public function deleteAsync() 626 | { 627 | $promise = DynamoDb::table($this->model->getTable()) 628 | ->setKey(DynamoDb::marshalItem($this->model->getKeys())) 629 | ->prepare($this->client) 630 | ->deleteItemAsync(); 631 | 632 | return $promise; 633 | } 634 | 635 | public function save() 636 | { 637 | $result = DynamoDb::table($this->model->getTable()) 638 | ->setItem(DynamoDb::marshalItem($this->model->getAttributes())) 639 | ->prepare($this->client) 640 | ->putItem(); 641 | 642 | return Arr::get($result, '@metadata.statusCode') === 200; 643 | } 644 | 645 | public function saveAsync() 646 | { 647 | $promise = DynamoDb::table($this->model->getTable()) 648 | ->setItem(DynamoDb::marshalItem($this->model->getAttributes())) 649 | ->prepare($this->client) 650 | ->putItemAsync(); 651 | 652 | return $promise; 653 | } 654 | 655 | public function get($columns = []) 656 | { 657 | return $this->all($columns); 658 | } 659 | 660 | public function all($columns = []) 661 | { 662 | $limit = isset($this->limit) ? $this->limit : static::MAX_LIMIT; 663 | return $this->getAll($columns, $limit, !isset($this->limit)); 664 | } 665 | 666 | public function count() 667 | { 668 | $limit = isset($this->limit) ? $this->limit : static::MAX_LIMIT; 669 | $raw = $this->toDynamoDbQuery(['count(*)'], $limit); 670 | 671 | if ($raw->op === 'Scan') { 672 | $res = $this->client->scan($raw->query); 673 | } else { 674 | $res = $this->client->query($raw->query); 675 | } 676 | 677 | return $res['Count']; 678 | } 679 | 680 | public function decorate(Closure $closure) 681 | { 682 | $this->decorator = $closure; 683 | return $this; 684 | } 685 | 686 | protected function getAll( 687 | $columns = [], 688 | $limit = DynamoDbQueryBuilder::MAX_LIMIT, 689 | $useIterator = DynamoDbQueryBuilder::DEFAULT_TO_ITERATOR 690 | ) { 691 | $analyzer = $this->getConditionAnalyzer(); 692 | 693 | if ($analyzer->isExactSearch()) { 694 | $item = $this->find($analyzer->identifierConditionValues(), $columns); 695 | 696 | return $this->getModel()->newCollection([$item]); 697 | } 698 | 699 | $raw = $this->toDynamoDbQuery($columns, $limit); 700 | 701 | if ($useIterator) { 702 | $iterator = $this->client->getIterator($raw->op, $raw->query); 703 | 704 | if (isset($raw->query['Limit'])) { 705 | $iterator = new \LimitIterator($iterator, 0, $raw->query['Limit']); 706 | } 707 | } else { 708 | if ($raw->op === 'Scan') { 709 | $res = $this->client->scan($raw->query); 710 | } else { 711 | $res = $this->client->query($raw->query); 712 | } 713 | 714 | $this->lastEvaluatedKey = Arr::get($res, 'LastEvaluatedKey'); 715 | $iterator = $res['Items']; 716 | } 717 | 718 | $results = []; 719 | 720 | foreach ($iterator as $item) { 721 | $item = DynamoDb::unmarshalItem($item); 722 | $model = $this->model->newInstance([], true); 723 | $model->setRawAttributes($item, true); 724 | $results[] = $model; 725 | } 726 | 727 | return $this->getModel()->newCollection($results, $analyzer->index()); 728 | } 729 | 730 | /** 731 | * Return the raw DynamoDb query 732 | * 733 | * @param array $columns 734 | * @param int $limit 735 | * @return RawDynamoDbQuery 736 | */ 737 | public function toDynamoDbQuery( 738 | $columns = [], 739 | $limit = DynamoDbQueryBuilder::MAX_LIMIT 740 | ) { 741 | $this->applyScopes(); 742 | 743 | $this->resetExpressions(); 744 | 745 | $op = 'Scan'; 746 | $queryBuilder = DynamoDb::table($this->model->getTable()); 747 | 748 | if (! empty($this->wheres)) { 749 | $analyzer = $this->getConditionAnalyzer(); 750 | 751 | if ($keyConditions = $analyzer->keyConditions()) { 752 | $op = 'Query'; 753 | $queryBuilder->setKeyConditionExpression($this->keyConditionExpression->parse($keyConditions)); 754 | } 755 | 756 | if ($filterConditions = $analyzer->filterConditions()) { 757 | $queryBuilder->setFilterExpression($this->filterExpression->parse($filterConditions)); 758 | } 759 | 760 | if ($index = $analyzer->index()) { 761 | $queryBuilder->setIndexName($index->name); 762 | } 763 | } 764 | 765 | if ($this->index) { 766 | // If user specifies the index manually, respect that 767 | $queryBuilder->setIndexName($this->index); 768 | } 769 | 770 | if ($limit !== static::MAX_LIMIT) { 771 | $queryBuilder->setLimit($limit); 772 | } 773 | 774 | if (!empty($columns)) { 775 | // Either we try to get the count or specific columns 776 | if ($columns == ['count(*)']) { 777 | $queryBuilder->setSelect('COUNT'); 778 | } else { 779 | $queryBuilder->setProjectionExpression($this->projectionExpression->parse($columns)); 780 | } 781 | } 782 | 783 | if (!empty($this->lastEvaluatedKey)) { 784 | $queryBuilder->setExclusiveStartKey($this->lastEvaluatedKey); 785 | } 786 | 787 | $queryBuilder 788 | ->setExpressionAttributeNames($this->expressionAttributeNames->all()) 789 | ->setExpressionAttributeValues($this->expressionAttributeValues->all()); 790 | 791 | $raw = new RawDynamoDbQuery($op, $queryBuilder->prepare($this->client)->query); 792 | 793 | if ($this->decorator) { 794 | call_user_func($this->decorator, $raw); 795 | } 796 | 797 | return $raw; 798 | } 799 | 800 | /** 801 | * @return Analyzer 802 | */ 803 | protected function getConditionAnalyzer() 804 | { 805 | return with(new Analyzer) 806 | ->on($this->model) 807 | ->withIndex($this->index) 808 | ->analyze($this->wheres); 809 | } 810 | 811 | protected function isMultipleIds($id) 812 | { 813 | $keys = collect($this->model->getKeyNames()); 814 | 815 | // could be ['id' => 'foo'], ['id1' => 'foo', 'id2' => 'bar'] 816 | $single = $keys->first(function ($name) use ($id) { 817 | return !isset($id[$name]); 818 | }) === null; 819 | 820 | if ($single) { 821 | return false; 822 | } 823 | 824 | // could be ['foo', 'bar'], [['id1' => 'foo', 'id2' => 'bar'], ...] 825 | return $this->model->hasCompositeKey() ? is_array(H::array_first($id)) : is_array($id); 826 | } 827 | 828 | /** 829 | * @return DynamoDbModel 830 | */ 831 | public function getModel() 832 | { 833 | return $this->model; 834 | } 835 | 836 | /** 837 | * @return \Aws\DynamoDb\DynamoDbClient 838 | */ 839 | public function getClient() 840 | { 841 | return $this->client; 842 | } 843 | 844 | /** 845 | * Register a new global scope. 846 | * 847 | * @param string $identifier 848 | * @param \Illuminate\Database\Eloquent\Scope|\Closure $scope 849 | * @return $this 850 | */ 851 | public function withGlobalScope($identifier, $scope) 852 | { 853 | $this->scopes[$identifier] = $scope; 854 | 855 | if (method_exists($scope, 'extend')) { 856 | $scope->extend($this); 857 | } 858 | 859 | return $this; 860 | } 861 | 862 | /** 863 | * Remove a registered global scope. 864 | * 865 | * @param \Illuminate\Database\Eloquent\Scope|string $scope 866 | * @return $this 867 | */ 868 | public function withoutGlobalScope($scope) 869 | { 870 | if (! is_string($scope)) { 871 | $scope = get_class($scope); 872 | } 873 | 874 | unset($this->scopes[$scope]); 875 | 876 | $this->removedScopes[] = $scope; 877 | 878 | return $this; 879 | } 880 | 881 | /** 882 | * Remove all or passed registered global scopes. 883 | * 884 | * @param array|null $scopes 885 | * @return $this 886 | */ 887 | public function withoutGlobalScopes(array $scopes = null) 888 | { 889 | if (is_array($scopes)) { 890 | foreach ($scopes as $scope) { 891 | $this->withoutGlobalScope($scope); 892 | } 893 | } else { 894 | $this->scopes = []; 895 | } 896 | 897 | return $this; 898 | } 899 | 900 | /** 901 | * Get an array of global scopes that were removed from the query. 902 | * 903 | * @return array 904 | */ 905 | public function removedScopes() 906 | { 907 | return $this->removedScopes; 908 | } 909 | 910 | /** 911 | * Apply the scopes to the Eloquent builder instance and return it. 912 | * 913 | * @return DynamoDbQueryBuilder 914 | */ 915 | public function applyScopes() 916 | { 917 | if (! $this->scopes) { 918 | return $this; 919 | } 920 | 921 | $builder = $this; 922 | 923 | foreach ($builder->scopes as $identifier => $scope) { 924 | if (! isset($builder->scopes[$identifier])) { 925 | continue; 926 | } 927 | 928 | $builder->callScope(function (DynamoDbQueryBuilder $builder) use ($scope) { 929 | // If the scope is a Closure we will just go ahead and call the scope with the 930 | // builder instance. The "callScope" method will properly group the clauses 931 | // that are added to this query so "where" clauses maintain proper logic. 932 | if ($scope instanceof Closure) { 933 | $scope($builder); 934 | } 935 | 936 | // If the scope is a scope object, we will call the apply method on this scope 937 | // passing in the builder and the model instance. After we run all of these 938 | // scopes we will return back the builder instance to the outside caller. 939 | if ($scope instanceof Scope) { 940 | throw new NotSupportedException('Scope object is not yet supported'); 941 | } 942 | }); 943 | 944 | $builder->withoutGlobalScope($identifier); 945 | } 946 | 947 | return $builder; 948 | } 949 | 950 | /** 951 | * Apply the given scope on the current builder instance. 952 | * 953 | * @param callable $scope 954 | * @param array $parameters 955 | * @return mixed 956 | */ 957 | protected function callScope(callable $scope, $parameters = []) 958 | { 959 | array_unshift($parameters, $this); 960 | 961 | // $query = $this->getQuery(); 962 | 963 | // // We will keep track of how many wheres are on the query before running the 964 | // // scope so that we can properly group the added scope constraints in the 965 | // // query as their own isolated nested where statement and avoid issues. 966 | // $originalWhereCount = is_null($query->wheres) 967 | // ? 0 : count($query->wheres); 968 | 969 | $result = $scope(...array_values($parameters)) ?: $this; 970 | 971 | // if (count((array) $query->wheres) > $originalWhereCount) { 972 | // $this->addNewWheresWithinGroup($query, $originalWhereCount); 973 | // } 974 | 975 | return $result; 976 | } 977 | 978 | /** 979 | * Dynamically handle calls into the query instance. 980 | * 981 | * @param string $method 982 | * @param array $parameters 983 | * @return mixed 984 | */ 985 | public function __call($method, $parameters) 986 | { 987 | if (method_exists($this->model, $scope = 'scope'.ucfirst($method))) { 988 | return $this->callScope([$this->model, $scope], $parameters); 989 | } 990 | 991 | return $this; 992 | } 993 | } 994 | --------------------------------------------------------------------------------