diff --git a/src/ConditionAnalyzer/Analyzer.php b/src/ConditionAnalyzer/Analyzer.php new file mode 100644 index 0000000..053a332 --- /dev/null +++ b/src/ConditionAnalyzer/Analyzer.php @@ -0,0 +1,215 @@ +on($model) + * ->withIndex($index) + * ->analyze($conditions); + * + * $analyzer->isExactSearch(); + * $analyzer->keyConditions(); + * $analyzer->filterConditions(); + * $analyzer->index(); + */ +class Analyzer +{ + /** + * @var DynamoDbModel + */ + private $model; + + /** + * @var array + */ + private $conditions = []; + + /** + * @var string + */ + private $indexName; + + public function on(DynamoDbModel $model) + { + $this->model = $model; + + return $this; + } + + public function withIndex($index) + { + $this->indexName = $index; + + return $this; + } + + public function analyze($conditions) + { + $this->conditions = $conditions; + + return $this; + } + + public function isExactSearch() + { + if (empty($this->conditions)) { + return false; + } + + if (empty($this->identifierConditions())) { + return false; + } + + foreach ($this->conditions as $condition) { + if (array_get($condition, 'type') !== ComparisonOperator::EQ) { + return false; + } + } + + return true; + } + + /** + * @return Index|null + */ + public function index() + { + return $this->getIndex(); + } + + public function keyConditions() + { + $index = $this->getIndex(); + + if ($index) { + return $this->getConditions($index->columns()); + } + + return $this->identifierConditions(); + } + + public function filterConditions() + { + $keyConditions = $this->keyConditions() ?: []; + + return array_filter($this->conditions, function ($condition) use ($keyConditions) { + return array_search($condition, $keyConditions) === false; + }); + } + + public function identifierConditions() + { + $keyNames = $this->model->getKeyNames(); + + $conditions = $this->getConditions($keyNames); + + if (!$this->hasValidQueryOperator(...$keyNames)) { + return null; + } + + return $conditions; + } + + public function identifierConditionValues() + { + $idConditions = $this->identifierConditions(); + + if (!$idConditions) { + return []; + } + + $values = []; + + foreach ($idConditions as $condition) { + $values[$condition['column']] = $condition['value']; + } + + return $values; + } + + /** + * @param $column + * + * @return array + */ + private function getCondition($column) + { + return array_first($this->conditions, function ($condition) use ($column) { + return $condition['column'] === $column; + }); + } + + /** + * @param $columns + * + * @return array + */ + private function getConditions($columns) + { + return array_filter($this->conditions, function ($condition) use ($columns) { + return in_array($condition['column'], $columns); + }); + } + + /** + * @return Index|null + */ + private function getIndex() + { + if (empty($this->conditions)) { + return null; + } + + $index = null; + + foreach ($this->model->getDynamoDbIndexKeys() as $name => $keysInfo) { + $conditionKeys = array_pluck($this->conditions, 'column'); + $keys = array_values($keysInfo); + + if (count(array_intersect($conditionKeys, $keys)) === count($keys)) { + if (!isset($this->indexName) || $this->indexName === $name) { + $index = new Index( + $name, + array_get($keysInfo, 'hash'), + array_get($keysInfo, 'range') + ); + + break; + } + } + } + + if ($index && !$this->hasValidQueryOperator($index->hash, $index->range)) { + $index = null; + } + + return $index; + } + + private function hasValidQueryOperator($hash, $range = null) + { + $hashCondition = $this->getCondition($hash); + + $validQueryOp = ComparisonOperator::isValidQueryDynamoDbOperator($hashCondition['type']); + + if ($validQueryOp && $range) { + $rangeCondition = $this->getCondition($range); + + $validQueryOp = ComparisonOperator::isValidQueryDynamoDbOperator( + $rangeCondition['type'], + true + ); + } + + return $validQueryOp; + } +} diff --git a/src/ConditionAnalyzer/Index.php b/src/ConditionAnalyzer/Index.php new file mode 100644 index 0000000..a73fef1 --- /dev/null +++ b/src/ConditionAnalyzer/Index.php @@ -0,0 +1,48 @@ +name = $name; + $this->hash = $hash; + $this->range = $range; + } + + public function isComposite() + { + return isset($this->hash) && isset($this->range); + } + + public function columns() + { + $columns = []; + + if ($this->hash) { + $columns[] = $this->hash; + } + + if ($this->range) { + $columns[] = $this->range; + } + + return $columns; + } +} diff --git a/src/DynamoDbQueryBuilder.php b/src/DynamoDbQueryBuilder.php index f116247..512fae3 100644 --- a/src/DynamoDbQueryBuilder.php +++ b/src/DynamoDbQueryBuilder.php @@ -3,6 +3,7 @@ namespace BaoPham\DynamoDb; use BaoPham\DynamoDb\Concerns\HasParsers; +use BaoPham\DynamoDb\ConditionAnalyzer\Analyzer; use Closure; use Aws\DynamoDb\DynamoDbClient; use Illuminate\Contracts\Support\Arrayable; @@ -154,9 +155,10 @@ public function after(DynamoDbModel $after = null) $afterKey = $after->getKeys(); - if ($index = $this->conditionsContainIndexKey()) { - $columns = array_values($index['keysInfo']); - foreach ($columns as $column) { + $analyzer = $this->getConditionAnalyzer(); + + if ($index = $analyzer->index()) { + foreach ($index->columns() as $column) { $afterKey[$column] = $after->getAttribute($column); } } @@ -499,13 +501,14 @@ public function removeAttribute(...$attributes) $key = $this->getDynamoDbKey(); if (empty($key)) { - $conditionValue = $this->conditionsContainKey(); + $analyzer = $this->getConditionAnalyzer(); - if (!$conditionValue || !$this->conditionsAreExactSearch()) { + if (!$analyzer->isExactSearch()) { throw new InvalidQuery('Need to provide the key in your query'); } - $this->model->setId($conditionValue); + $id = $analyzer->identifierConditionValues(); + $this->model->setId($id); $key = $this->getDynamoDbKey(); } @@ -586,12 +589,12 @@ protected function getAll( $limit = DynamoDbQueryBuilder::MAX_LIMIT, $useIterator = DynamoDbQueryBuilder::DEFAULT_TO_ITERATOR ) { - if ($conditionValue = $this->conditionsContainKey()) { - if ($this->conditionsAreExactSearch()) { - $item = $this->find($conditionValue, $columns); + $analyzer = $this->getConditionAnalyzer(); - return $this->getModel()->newCollection([$item]); - } + if ($analyzer->isExactSearch()) { + $item = $this->find($analyzer->identifierConditionValues(), $columns); + + return $this->getModel()->newCollection([$item]); } $raw = $this->toDynamoDbQuery($columns, $limit); @@ -685,57 +688,19 @@ protected function buildExpressionQuery() return new RawDynamoDbQuery($op, $query); } - // Index key condition exists, then use Query instead of Scan. - // However, Query only supports a few conditions. - if ($index = $this->conditionsContainIndexKey()) { - $keysInfo = $index['keysInfo']; - - $isCompositeKey = isset($keysInfo['range']); - - $hashKeyCondition = array_first($this->wheres, function ($condition) use ($keysInfo) { - return $condition['column'] === $keysInfo['hash']; - }); - - $isValidQueryOperator = ComparisonOperator::isValidQueryDynamoDbOperator($hashKeyCondition['type']); - - if ($isValidQueryOperator && $isCompositeKey) { - $rangeKeyCondition = array_first($this->wheres, function ($condition) use ($keysInfo) { - return $condition['column'] === $keysInfo['range']; - }); - - $isValidQueryOperator = ComparisonOperator::isValidQueryDynamoDbOperator( - $rangeKeyCondition['type'], - true - ); - } - - if ($isValidQueryOperator) { - $op = 'Query'; - - $indexes = array_values($keysInfo); - - $keyConditions = array_filter($this->wheres, function ($condition) use ($indexes) { - return in_array($condition['column'], $indexes); - }); - - $nonKeyConditions = array_filter($this->wheres, function ($condition) use ($indexes) { - return !in_array($condition['column'], $indexes); - }); - - $query['IndexName'] = $index['name']; + $analyzer = $this->getConditionAnalyzer(); - $query['KeyConditionExpression'] = $this->keyConditionExpression->parse($keyConditions); - - $query['FilterExpression'] = $this->filterExpression->parse($nonKeyConditions); - } - } elseif ($this->conditionsContainKey()) { + if ($keyConditions = $analyzer->keyConditions()) { $op = 'Query'; + $query['KeyConditionExpression'] = $this->keyConditionExpression->parse($keyConditions); + } - $query['KeyConditionExpression'] = $this->keyConditionExpression->parse($this->wheres); + if ($filterConditions = $analyzer->filterConditions()) { + $query['FilterExpression'] = $this->filterExpression->parse($filterConditions); } - if ($op === 'Scan') { - $query['FilterExpression'] = $this->filterExpression->parse($this->wheres); + if ($index = $analyzer->index()) { + $query['IndexName'] = $index->name; } $query['ExpressionAttributeNames'] = $this->expressionAttributeNames->all(); @@ -745,91 +710,15 @@ protected function buildExpressionQuery() return new RawDynamoDbQuery($op, $query); } - protected function conditionsAreExactSearch() - { - if (empty($this->wheres)) { - return false; - } - - foreach ($this->wheres as $condition) { - if (array_get($condition, 'type') !== ComparisonOperator::EQ) { - return false; - } - } - - return true; - } - - /** - * Check if conditions "where" contain primary key or composite key. - * For composite key, it will return false if the conditions don't have all composite key. - * - * For example: - * Consider a composite key condition: - * $model->where('partition_key', 'foo')->where('sort_key', 'bar') - * We return ['partition_key' => 'foo', 'sort_key' => 'bar'] since the conditions - * contain all the composite key. - * - * @return array|bool the condition value - */ - protected function conditionsContainKey() - { - if (empty($this->wheres)) { - return false; - } - - $conditionKeys = array_pluck($this->wheres, 'column'); - - $model = $this->model; - - $keys = $model->getKeyNames(); - - $conditionsContainKey = count(array_intersect($conditionKeys, $keys)) === count($keys); - - if (!$conditionsContainKey) { - return false; - } - - $conditionValue = []; - - foreach ($this->wheres as $condition) { - $column = array_get($condition, 'column'); - if (in_array($column, $keys)) { - $conditionValue[$column] = array_get($condition, 'value'); - } - } - - return $conditionValue; - } - /** - * Check if conditions "where" contain index key - * For composite index key, it will return false if the conditions don't have all composite key. - * - * @return array|bool false or array - * ['name' => 'index_name', 'keysInfo' => ['hash' => 'hash_key', 'range' => 'range_key']] + * @return Analyzer */ - protected function conditionsContainIndexKey() + protected function getConditionAnalyzer() { - if (empty($this->wheres)) { - return false; - } - - foreach ($this->model->getDynamoDbIndexKeys() as $name => $keysInfo) { - $conditionKeys = array_pluck($this->wheres, 'column'); - $keys = array_values($keysInfo); - - if (count(array_intersect($conditionKeys, $keys)) === count($keys)) { - if ($this->index === $name || !isset($this->index)) { - return [ - 'name' => $name, - 'keysInfo' => $keysInfo - ]; - } - } - } - - return false; + return with(new Analyzer) + ->on($this->model) + ->withIndex($this->index) + ->analyze($this->wheres); } /** diff --git a/tests/DynamoDbCompositeModelTest.php b/tests/DynamoDbCompositeModelTest.php index ae012c7..d2a28e3 100644 --- a/tests/DynamoDbCompositeModelTest.php +++ b/tests/DynamoDbCompositeModelTest.php @@ -2,6 +2,7 @@ namespace BaoPham\DynamoDb\Tests; +use BaoPham\DynamoDb\DynamoDbModel; use BaoPham\DynamoDb\NotSupportedException; use BaoPham\DynamoDb\RawDynamoDbQuery; use \Illuminate\Database\Eloquent\ModelNotFoundException; @@ -15,7 +16,7 @@ class DynamoDbCompositeModelTest extends DynamoDbModelTest { protected function getTestModel() { - return new CompositeTestModel([]); + return new CompositeKeyWithIndex(); } public function testCreateRecord() @@ -90,15 +91,16 @@ public function testFirstOrFailRecordPass() $seedId2 = array_get($seed, 'id2.S'); $seedName = array_get($seed, 'name.S'); - $first = $this->testModel + $query = $this->testModel ->where('id', $seedId) - ->where('id2', $seedId2) - ->firstOrFail(); + ->where('id2', $seedId2); + $first = $query->firstOrFail(); $this->assertNotEmpty($first); $this->assertEquals($seedId, $first->id); $this->assertEquals($seedId2, $first->id2); $this->assertEquals($seedName, $first->name); + $this->assertEquals('Query', $query->toDynamoDbQuery()->op); } public function testFindOrFailRecordFail() @@ -193,13 +195,14 @@ public function testLookUpByKey() $item = $this->seed(); - $foundItems = $this->testModel + $query = $this->testModel ->where('id', $item['id']['S']) - ->where('id2', $item['id2']['S']) - ->get(); + ->where('id2', $item['id2']['S']); - $this->assertEquals(1, $foundItems->count()); + $this->assertEquals('Query', $query->toDynamoDbQuery()->op); + $foundItems = $query->get(); + $this->assertEquals(1, $foundItems->count()); $this->assertEquals($this->testModel->unmarshalItem($item), $foundItems->first()->toArray()); } @@ -219,11 +222,15 @@ public function testSearchByHashAndSortKey() 'id2' => ['S' => 'foo_1'] ]); - $foundItems = $this->testModel + $query = $this->testModel ->where('id', $partitionKey) - ->where('id2', 'begins_with', 'bar') - ->get(); + ->where('id2', 'begins_with', 'bar'); + + $dynamoDbQuery = $query->toDynamoDbQuery(); + $this->assertEquals('Query', $dynamoDbQuery->op); + $this->assertArrayNotHasKey('FilterExpression', $dynamoDbQuery->query); + $foundItems = $query->get(); $this->assertEquals(2, $foundItems->count()); $this->assertEquals($this->testModel->unmarshalItem($item1), $foundItems->first()->toArray()); $this->assertEquals($this->testModel->unmarshalItem($item2), $foundItems->last()->toArray()); @@ -235,13 +242,15 @@ public function testStaticMethods() $item = $this->testModel->unmarshalItem($item); - $this->assertEquals([$item], CompositeTestModel::all()->toArray()); + $klass = get_class($this->testModel); + + $this->assertEquals([$item], $klass::all()->toArray()); - $this->assertEquals(1, CompositeTestModel::where('name', 'Foo')->where('description', 'Bar')->get()->count()); + $this->assertEquals(1, $klass::where('name', 'Foo')->where('description', 'Bar')->get()->count()); - $this->assertEquals($item, CompositeTestModel::first()->toArray()); + $this->assertEquals($item, $klass::first()->toArray()); - $this->assertEquals($item, CompositeTestModel::find([ + $this->assertEquals($item, $klass::find([ 'id' => $item['id'], 'id2' => $item['id2'] ])->toArray()); @@ -271,28 +280,38 @@ public function testConditionContainingIndexKey() ]); // Test condition contains all composite keys with valid operator - $foundItems = $this->testModel + $query = $this->testModel ->where('id', 'id1') - ->where('count', '>=', 10) // Test range key support comparison operator other than EQ - ->get(); + // Test range key support comparison operator other than EQ + ->where('count', '>=', 10); - // If id_count_index is used, $bazItem must be the first found item - $expectedItem = $this->testModel->unmarshalItem($bazItem); + $dynamoDbQuery = $query->toDynamoDbQuery(); + $this->assertEquals('Query', $dynamoDbQuery->op); + $this->assertEquals('#id = :a1 AND #count >= :a2', $dynamoDbQuery->query['KeyConditionExpression']); + $foundItems = $query->get(); $this->assertEquals(2, $foundItems->count()); + + // If id_count_index is used, $bazItem must be the first found item + $expectedItem = $this->testModel->unmarshalItem($bazItem); $this->assertEquals($expectedItem, $foundItems->first()->toArray()); // Test condition contains all composite keys with invalid operator - $foundItems = $this->testModel - ->where('id', 'begins_with', 'id') // Invalid operator for hash key - ->where('count', '>', 0) - ->get(); + $query = $this->testModel + // Invalid operator for hash key + ->where('id', 'begins_with', 'id') + ->where('count', '>', 0); + + $dynamoDbQuery = $query->toDynamoDbQuery(); + $this->assertEquals('Scan', $dynamoDbQuery->op); + $this->assertEquals('begins_with(#id, :a1) AND #count > :a2', $dynamoDbQuery->query['FilterExpression']); + + $foundItems = $query->get(); + $this->assertEquals(3, $foundItems->count()); // id_count_index is not used because of invalid operator for hash key // A normal Scan operation is used, results are sorted by id2 $expectedItem = $this->testModel->unmarshalItem($barItem); - - $this->assertEquals(3, $foundItems->count()); $this->assertEquals($expectedItem, $foundItems->first()->toArray()); } @@ -429,6 +448,92 @@ public function testDecorateRawQuery() $this->assertEquals(range(9, 0, -1), $reverse->pluck('count')->toArray()); } + public function testUsingBothKeyAndFilterConditionsForModelWithoutIndex() + { + foreach (range(0, 9) as $i) { + $this->seed([ + 'id' => ['S' => 'id'], + 'id2' => ['S' => "$i"], + 'count' => ['N' => $i], + ]); + } + + $query = with(new CompositeKeyWithoutIndex()) + ->where('id', 'id') + ->where('id2', '>', '4') + ->where('count', '<=', 8); + + $dynamoDbQuery = $query->toDynamoDbQuery(); + + $this->assertEquals('Query', $dynamoDbQuery->op); + + $this->assertEquals( + '#id = :a1 AND #id2 > :a2', + $dynamoDbQuery->query['KeyConditionExpression'] + ); + + $this->assertEquals( + '#count <= :a3', + $dynamoDbQuery->query['FilterExpression'] + ); + + $result = $query->get(); + $expected = range(5, 8); + + $this->assertEquals( + $expected, + $result->pluck('count')->toArray() + ); + + $this->assertEquals( + array_map('strval', $expected), + $result->pluck('id2')->toArray() + ); + } + + public function testUsingBothKeyAndFilterConditionsForModelWithIndex() + { + foreach (range(0, 9) as $i) { + $this->seed([ + 'id' => ['S' => 'id'], + 'id2' => ['S' => "$i"], + 'count' => ['N' => $i], + ]); + } + + $query = with(new CompositeKeyWithIndex()) + ->where('id', 'id') + ->where('id2', '>', '4') + ->where('count', '<=', 8); + + $dynamoDbQuery = $query->toDynamoDbQuery(); + + $this->assertEquals('Query', $dynamoDbQuery->op); + + $this->assertEquals( + '#id = :a1 AND #count <= :a2', + $dynamoDbQuery->query['KeyConditionExpression'] + ); + + $this->assertEquals( + '#id2 > :a3', + $dynamoDbQuery->query['FilterExpression'] + ); + + $result = $query->get(); + $expected = range(5, 8); + + $this->assertEquals( + $expected, + $result->pluck('count')->toArray() + ); + + $this->assertEquals( + array_map('strval', $expected), + $result->pluck('id2')->toArray() + ); + } + public function seed($attributes = [], $exclude = []) { $item = [ @@ -466,7 +571,7 @@ public function seed($attributes = [], $exclude = []) } // phpcs:disable PSR1.Classes.ClassDeclaration.MultipleClasses -class CompositeTestModel extends \BaoPham\DynamoDb\DynamoDbModel +class CompositeKeyWithIndex extends DynamoDbModel { protected $fillable = [ 'name', @@ -493,4 +598,20 @@ class CompositeTestModel extends \BaoPham\DynamoDb\DynamoDbModel ], ]; } + +class CompositeKeyWithoutIndex extends DynamoDbModel +{ + protected $fillable = [ + 'name', + 'description', + 'count', + 'author', + 'nested', + 'nestedArray', + ]; + + protected $table = 'composite_test_model'; + + protected $compositeKey = ['id', 'id2']; +} // phpcs:enable PSR1.Classes.ClassDeclaration.MultipleClasses diff --git a/tests/DynamoDbModelTest.php b/tests/DynamoDbModelTest.php index 2821ef5..38addef 100644 --- a/tests/DynamoDbModelTest.php +++ b/tests/DynamoDbModelTest.php @@ -2,6 +2,7 @@ namespace BaoPham\DynamoDb\Tests; +use BaoPham\DynamoDb\DynamoDbModel; use BaoPham\DynamoDb\NotSupportedException; use BaoPham\DynamoDb\RawDynamoDbQuery; use \Illuminate\Database\Eloquent\ModelNotFoundException; @@ -15,7 +16,7 @@ class DynamoDbModelTest extends ModelTest { protected function getTestModel() { - return new TestModel([]); + return new PrimaryKeyWithIndexModel(); } public function testCreateRecord() @@ -82,13 +83,14 @@ public function testFirstOrFailRecordPass() $seedId = array_get($seed, 'id.S'); $seedName = array_get($seed, 'name.S'); - $first = $this->testModel - ->where('id', $seedId) - ->firstOrFail(); + $query = $this->testModel + ->where('id', $seedId); + $first = $query->firstOrFail(); $this->assertNotEmpty($first); $this->assertEquals($seedId, $first->id); $this->assertEquals($seedName, $first->name); + $this->assertEquals('Query', $query->toDynamoDbQuery()->op); } public function testFindOrFailRecordFail() @@ -587,13 +589,15 @@ public function testStaticMethods() $item = $this->testModel->unmarshalItem($item); - $this->assertEquals([$item], TestModel::all()->toArray()); + $klass = get_class($this->testModel); + + $this->assertEquals([$item], $klass::all()->toArray()); - $this->assertEquals(1, TestModel::where('name', 'Foo')->where('description', 'Bar')->get()->count()); + $this->assertEquals(1, $klass::where('name', 'Foo')->where('description', 'Bar')->get()->count()); - $this->assertEquals($item, TestModel::first()->toArray()); + $this->assertEquals($item, $klass::first()->toArray()); - $this->assertEquals($item, TestModel::find($item['id'])->toArray()); + $this->assertEquals($item, $klass::find($item['id'])->toArray()); } public function testNestedConditions() @@ -775,6 +779,48 @@ public function testDecorateRawQuery() $this->assertEquals(range(1, 9), $items->pluck('count')->sort()->values()->toArray()); } + private function assertUsingKeyAndFilterConditions($model) + { + foreach (range(0, 9) as $i) { + $this->seed([ + 'id' => ['S' => "$i"], + 'count' => ['N' => $i], + ]); + } + + $query = $model + ->where('id', '8') + ->where('count', '<=', 8); + + $dynamoDbQuery = $query->toDynamoDbQuery(); + + $this->assertEquals('Query', $dynamoDbQuery->op); + + $this->assertEquals( + '#id = :a1', + $dynamoDbQuery->query['KeyConditionExpression'] + ); + + $this->assertEquals( + '#count <= :a2', + $dynamoDbQuery->query['FilterExpression'] + ); + + $result = $query->get(); + $this->assertEquals([8], $result->pluck('count')->toArray()); + $this->assertEquals(['8'], $result->pluck('id')->toArray()); + } + + public function testUsingBothKeyAndFilterConditionsForModelWithoutIndex() + { + $this->assertUsingKeyAndFilterConditions(new PrimaryKeyWithoutIndexModel()); + } + + public function testUsingBothKeyAndFilterConditionsForModelWithIndex() + { + $this->assertUsingKeyAndFilterConditions(new PrimaryKeyWithIndexModel()); + } + protected function assertRemoveAttributes($item) { $this->assertNull($item->name); @@ -824,7 +870,7 @@ public function seed($attributes = [], $exclude = []) } // phpcs:disable PSR1.Classes.ClassDeclaration.MultipleClasses -class TestModel extends \BaoPham\DynamoDb\DynamoDbModel +class PrimaryKeyWithIndexModel extends DynamoDbModel { protected $fillable = [ 'name', @@ -843,4 +889,18 @@ class TestModel extends \BaoPham\DynamoDb\DynamoDbModel ], ]; } + +class PrimaryKeyWithoutIndexModel extends DynamoDbModel +{ + protected $fillable = [ + 'name', + 'description', + 'count', + 'author', + 'nested', + 'nestedArray', + ]; + + protected $table = 'test_model'; +} // phpcs:enable PSR1.Classes.ClassDeclaration.MultipleClasses diff --git a/tests/ModelTest.php b/tests/ModelTest.php index e2254a8..8c2cbcc 100644 --- a/tests/ModelTest.php +++ b/tests/ModelTest.php @@ -12,7 +12,7 @@ abstract class ModelTest extends TestCase { /** - * @var TestModel + * @var \BaoPham\DynamoDb\DynamoDbModel */ protected $testModel;