├── ut ├── .gitignore ├── tpl.ut.yml ├── bootstrap.php ├── UTConfig.php ├── CasDemoUser.php ├── BasicGameInfo.php ├── ConsoleGame.php ├── Game.php ├── User.php └── ItemManagerTest.php ├── .gitignore ├── bin ├── oasis-dynamodb-odm └── oasis-dynamodb-odm.php ├── src ├── Exceptions │ ├── NotAnnotatedException.php │ ├── AnnotationParsingException.php │ ├── UnderlyingDatabaseException.php │ ├── ODMException.php │ └── DataConsistencyException.php ├── Annotations │ ├── PartitionedHashKey.php │ ├── Field.php │ ├── Item.php │ └── Index.php ├── Console │ ├── Commands │ │ ├── DropSchemaCommand.php │ │ ├── UpdateSchemaCommand.php │ │ ├── CreateSchemaCommand.php │ │ └── AbstractSchemaCommand.php │ └── ConsoleHelper.php ├── DBAL │ ├── Drivers │ │ ├── AbstractDbConnection.php │ │ ├── Connection.php │ │ └── DynamoDbConnection.php │ └── Schema │ │ ├── AbstractSchemaTool.php │ │ └── DynamoDbSchemaTool.php ├── ManagedItemState.php ├── ItemManager.php ├── ItemReflection.php └── ItemRepository.php ├── phpunit.xml ├── odm-config.php ├── test.php ├── composer.json ├── LICENSE └── README.md /ut/.gitignore: -------------------------------------------------------------------------------- 1 | /ut.yml 2 | /cache/ 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /.idea/ 3 | /cache/ 4 | -------------------------------------------------------------------------------- /bin/oasis-dynamodb-odm: -------------------------------------------------------------------------------- 1 | oasis-dynamodb-odm.php -------------------------------------------------------------------------------- /ut/tpl.ut.yml: -------------------------------------------------------------------------------- 1 | prefix: odm-ut- 2 | dynamodb: 3 | region: cn-north-1 4 | profile: beijing-minhao 5 | -------------------------------------------------------------------------------- /ut/bootstrap.php: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | ut/ItemManagerTest.php 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /ut/UTConfig.php: -------------------------------------------------------------------------------- 1 | addNamespace('Oasis\Mlib\ODM\Dynamodb\Ut', __DIR__ . "/ut"); 22 | 23 | return new ConsoleHelper($im); 24 | -------------------------------------------------------------------------------- /test.php: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env php 2 | id = 1; 23 | //$user->name = 'John'; 24 | //$user->ver = '1'; 25 | //$im->persist($user); 26 | //$im->flush(); 27 | 28 | $user = $im->get(CasDemoUser::class, ['id' => 1]); 29 | $user->name = 'Alice'; 30 | $user->ver = '2'; 31 | sleep(5); 32 | $im->flush(); 33 | -------------------------------------------------------------------------------- /ut/CasDemoUser.php: -------------------------------------------------------------------------------- 1 | setName('odm:schema-tool:drop') 21 | ->setDescription('Drop the schema tables'); 22 | } 23 | 24 | protected function execute(InputInterface $input, OutputInterface $output) 25 | { 26 | $schemaTool = $this->getItemManager()->createDBConnection()->getSchemaTool( 27 | $this->getItemManager(), 28 | $this->getManagedItemClasses(), 29 | [$output, "writeln"] 30 | ); 31 | 32 | // create tables 33 | $schemaTool->dropSchema(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "oasis/dynamodb-odm", 3 | "description" : "ODM for dynamodb", 4 | "type" : "library", 5 | "require" : { 6 | "php" : ">=7.1", 7 | "doctrine/annotations": "^1.4", 8 | "oasis/aws-wrappers" : "^2.10", 9 | "oasis/logging" : "^1.0", 10 | "doctrine/common" : "^2.7", 11 | "symfony/console" : "^4.4", 12 | "symfony/finder" : "^4.4", 13 | "ext-json": "*" 14 | }, 15 | "require-dev" : { 16 | "phpunit/phpunit": "^5.7" 17 | }, 18 | "license" : "MIT", 19 | "authors" : [ 20 | { 21 | "name" : "Minhao Zhang", 22 | "email": "minhao.zhang@qq.com" 23 | } 24 | ], 25 | "autoload" : { 26 | "psr-4": { 27 | "Oasis\\Mlib\\ODM\\Dynamodb\\": "src/" 28 | } 29 | }, 30 | "autoload-dev": { 31 | "psr-4": { 32 | "Oasis\\Mlib\\ODM\\Dynamodb\\Ut\\": "ut/" 33 | } 34 | }, 35 | "bin" : [ 36 | "bin/oasis-dynamodb-odm" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Minhao Zhang 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/Console/ConsoleHelper.php: -------------------------------------------------------------------------------- 1 | itemManager = $itemManager; 27 | } 28 | 29 | public function addCommands(Application $application) 30 | { 31 | $application->addCommands( 32 | [ 33 | (new CreateSchemaCommand())->withItemManager($this->itemManager), 34 | (new DropSchemaCommand())->withItemManager($this->itemManager), 35 | (new UpdateSchemaCommand())->withItemManager($this->itemManager), 36 | ] 37 | ); 38 | } 39 | 40 | /** 41 | * @return ItemManager 42 | */ 43 | public function getItemManager() 44 | { 45 | return $this->itemManager; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Console/Commands/UpdateSchemaCommand.php: -------------------------------------------------------------------------------- 1 | setName('odm:schema-tool:update') 23 | ->setDescription('Update the schema tables') 24 | ->addOption( 25 | 'dry-run', 26 | 'd', 27 | InputOption::VALUE_NONE, 28 | "dry run: prints out changes without really updating schema" 29 | ); 30 | } 31 | 32 | protected function execute(InputInterface $input, OutputInterface $output) 33 | { 34 | $isDryRun = $input->getOption('dry-run'); 35 | $schemaTool = $this->getItemManager()->createDBConnection()->getSchemaTool( 36 | $this->getItemManager(), 37 | $this->getManagedItemClasses(), 38 | [$output, "writeln"] 39 | ); 40 | 41 | // create tables 42 | $schemaTool->updateSchema($isDryRun); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /ut/BasicGameInfo.php: -------------------------------------------------------------------------------- 1 | family; 43 | } 44 | 45 | /** 46 | * @param string $family 47 | */ 48 | public function setFamily($family) 49 | { 50 | $this->family = $family; 51 | } 52 | 53 | /** 54 | * @return string 55 | */ 56 | public function getGamecode() 57 | { 58 | return $this->gamecode; 59 | } 60 | 61 | /** 62 | * @param string $gamecode 63 | */ 64 | public function setGamecode($gamecode) 65 | { 66 | $this->gamecode = $gamecode; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/DBAL/Drivers/AbstractDbConnection.php: -------------------------------------------------------------------------------- 1 | dbConfig = $dbConfig; 41 | } 42 | 43 | public function getDatabaseConfig() 44 | { 45 | return $this->dbConfig; 46 | } 47 | 48 | public function setTableName($tableName) 49 | { 50 | $this->tableName = $tableName; 51 | } 52 | 53 | public function setAttributeTypes($attributeTypes) 54 | { 55 | $this->attributeTypes = $attributeTypes; 56 | } 57 | 58 | public function setItemReflection(ItemReflection $itemReflection) 59 | { 60 | $this->itemReflection = $itemReflection; 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/Annotations/Field.php: -------------------------------------------------------------------------------- 1 | setName('odm:schema-tool:create') 23 | ->setDescription('Processes the schema and create corresponding tables and indices.') 24 | ->addOption('skip-existing-table', null, InputOption::VALUE_NONE, "skip creating existing table!") 25 | ->addOption( 26 | 'dry-run', 27 | null, 28 | InputOption::VALUE_NONE, 29 | "output possible table creations without actually creating them." 30 | ); 31 | } 32 | 33 | protected function execute(InputInterface $input, OutputInterface $output) 34 | { 35 | $skipExisting = $input->getOption('skip-existing-table'); 36 | $dryRun = $input->getOption('dry-run'); 37 | $schemaTool = $this->getItemManager()->createDBConnection()->getSchemaTool( 38 | $this->getItemManager(), 39 | $this->getManagedItemClasses(), 40 | [$output, "writeln"] 41 | ); 42 | 43 | // create tables 44 | $schemaTool->createSchema($skipExisting, $dryRun); 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /ut/ConsoleGame.php: -------------------------------------------------------------------------------- 1 | achievements; 52 | } 53 | 54 | /** 55 | * @param array $achievements 56 | */ 57 | public function setAchievements($achievements) 58 | { 59 | $this->achievements = $achievements; 60 | } 61 | 62 | /** 63 | * @return array 64 | */ 65 | public function getAuthors() 66 | { 67 | return $this->authors; 68 | } 69 | 70 | /** 71 | * @param array $authors 72 | */ 73 | public function setAuthors($authors) 74 | { 75 | $this->authors = $authors; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/DBAL/Schema/AbstractSchemaTool.php: -------------------------------------------------------------------------------- 1 | itemManager = $im; 37 | $this->classReflections = $classReflections; 38 | $this->outputFunction = $outputFunction; 39 | } 40 | 41 | abstract public function createSchema($skipExisting, $dryRun); 42 | 43 | abstract public function updateSchema($isDryRun); 44 | 45 | abstract public function dropSchema(); 46 | 47 | /** 48 | * @return ItemManager 49 | */ 50 | protected function getItemManager() 51 | { 52 | return $this->itemManager; 53 | } 54 | 55 | protected function getManagedItemClasses() 56 | { 57 | return $this->classReflections; 58 | } 59 | 60 | protected function outputWrite($message) 61 | { 62 | if (is_callable($this->outputFunction)) { 63 | $output = $this->outputFunction; 64 | $output($message); 65 | } 66 | else { 67 | mnotice($message); 68 | } 69 | } 70 | 71 | 72 | } 73 | -------------------------------------------------------------------------------- /src/Console/Commands/AbstractSchemaCommand.php: -------------------------------------------------------------------------------- 1 | itemManager = $itemManager; 30 | 31 | return $this; 32 | } 33 | 34 | /** 35 | * @return ItemManager 36 | */ 37 | public function getItemManager() 38 | { 39 | return $this->itemManager; 40 | } 41 | 42 | /** 43 | * @noinspection PhpRedundantCatchClauseInspection 44 | */ 45 | protected function getManagedItemClasses() 46 | { 47 | $classes = []; 48 | foreach ($this->itemManager->getPossibleItemClasses() as $class) { 49 | try { 50 | $reflection = $this->itemManager->getItemReflection($class); 51 | } catch (NotAnnotatedException $e) { 52 | continue; 53 | } catch (ReflectionException $e) { 54 | continue; 55 | } catch (Exception $e) { 56 | mtrace($e, "Annotation parsing exception found: ", 'error'); 57 | throw $e; 58 | } 59 | $classes[$class] = $reflection; 60 | } 61 | 62 | return $classes; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /bin/oasis-dynamodb-odm.php: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env php 2 | setCatchExceptions(true); 68 | $consoleHelper->addCommands($cli); 69 | $cli->run(); 70 | -------------------------------------------------------------------------------- /src/Annotations/Item.php: -------------------------------------------------------------------------------- 1 | $value) { 56 | if (property_exists(self::class, $name)) { 57 | 58 | switch ($name) { 59 | case 'primaryIndex': 60 | if (!$value instanceof Index) { 61 | $value = new Index($value); 62 | } 63 | break; 64 | case 'globalSecondaryIndices': 65 | case 'localSecondaryIndices': 66 | $orig = $value; 67 | $value = []; 68 | foreach ($orig as $indexValue) { 69 | if (!$indexValue instanceof Index) { 70 | $indexValue = new Index($indexValue); 71 | } 72 | $value[] = $indexValue; 73 | } 74 | break; 75 | } 76 | 77 | $this->$name = $value; 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /ut/Game.php: -------------------------------------------------------------------------------- 1 | family; 63 | } 64 | 65 | /** 66 | * @param string $family 67 | */ 68 | public function setFamily($family) 69 | { 70 | $this->family = $family; 71 | } 72 | 73 | /** 74 | * @return string 75 | */ 76 | public function getGamecode() 77 | { 78 | return $this->gamecode; 79 | } 80 | 81 | /** 82 | * @param string $gamecode 83 | */ 84 | public function setGamecode($gamecode) 85 | { 86 | $this->gamecode = $gamecode; 87 | } 88 | 89 | /** 90 | * @return string 91 | */ 92 | public function getLanguage() 93 | { 94 | return $this->language; 95 | } 96 | 97 | /** 98 | * @param string $language 99 | */ 100 | public function setLanguage($language) 101 | { 102 | $this->language = $language; 103 | } 104 | 105 | /** 106 | * @return int 107 | */ 108 | public function getLastUpdatedAt() 109 | { 110 | return $this->lastUpdatedAt; 111 | } 112 | 113 | /** 114 | * @param int $lastUpdatedAt 115 | */ 116 | public function setLastUpdatedAt($lastUpdatedAt) 117 | { 118 | $this->lastUpdatedAt = $lastUpdatedAt; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Annotations/Index.php: -------------------------------------------------------------------------------- 1 | hash = $values[0]; 43 | if (isset($values[1])) { 44 | $this->range = $values[1]; 45 | } 46 | if (isset($values[2])) { 47 | $this->name = $values[2]; 48 | } 49 | } 50 | elseif (isset($values['hash'])) { 51 | $this->hash = $values['hash']; 52 | if (isset($values['range'])) { 53 | $this->range = $values['range']; 54 | } 55 | if (isset($values['name'])) { 56 | $this->name = $values['name']; 57 | } 58 | } 59 | else { 60 | throw new AnnotationParsingException("Index must be constructed with an array of hash and range keys"); 61 | } 62 | } 63 | 64 | public function getKeys() 65 | { 66 | $ret = [ 67 | $this->hash, 68 | ]; 69 | if ($this->range) { 70 | $ret[] = $this->range; 71 | } 72 | 73 | return $ret; 74 | } 75 | 76 | public function getDynamodbIndex(array $fieldNameMapping, array $attributeTypes) 77 | { 78 | $hash = $fieldNameMapping[$this->hash]; 79 | $range = $this->range ? $fieldNameMapping[$this->range] : ''; 80 | 81 | if (!isset($attributeTypes[$hash]) 82 | || ($range && !isset($attributeTypes[$range])) 83 | ) { 84 | throw new ODMException("Index key is not defined as Field!"); 85 | } 86 | 87 | $hashType = $attributeTypes[$hash]; 88 | $rangeKey = $range ? : null; 89 | $rangeType = $range ? $attributeTypes[$range] : 'string'; 90 | $hashType = constant(DynamoDbItem::class . '::ATTRIBUTE_TYPE_' . strtoupper($hashType)); 91 | $rangeType = constant(DynamoDbItem::class . '::ATTRIBUTE_TYPE_' . strtoupper($rangeType)); 92 | $idx = new DynamoDbIndex($hash, $hashType, $rangeKey, $rangeType); 93 | if ($this->name) { 94 | $idx->setName($this->name); 95 | } 96 | 97 | return $idx; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /ut/User.php: -------------------------------------------------------------------------------- 1 | age; 87 | } 88 | 89 | /** 90 | * @param mixed $age 91 | */ 92 | public function setAge($age) 93 | { 94 | $this->age = $age; 95 | } 96 | 97 | /** 98 | * @return mixed 99 | */ 100 | public function getAlias() 101 | { 102 | return $this->alias; 103 | } 104 | 105 | /** 106 | * @param mixed $alias 107 | */ 108 | public function setAlias($alias) 109 | { 110 | $this->alias = $alias; 111 | } 112 | 113 | /** 114 | * @return mixed 115 | */ 116 | public function getHometown() 117 | { 118 | return $this->hometown; 119 | } 120 | 121 | /** 122 | * @param mixed $hometown 123 | */ 124 | public function setHometown($hometown) 125 | { 126 | $this->hometown = $hometown; 127 | } 128 | 129 | /** 130 | * @return int 131 | */ 132 | public function getId() 133 | { 134 | return $this->id; 135 | } 136 | 137 | /** 138 | * @param int $id 139 | */ 140 | public function setId($id) 141 | { 142 | $this->id = $id; 143 | } 144 | 145 | /** 146 | * @return mixed 147 | */ 148 | public function getLastUpdated() 149 | { 150 | return $this->lastUpdated; 151 | } 152 | 153 | /** 154 | * @param mixed $lastUpdated 155 | */ 156 | public function setLastUpdated($lastUpdated) 157 | { 158 | $this->lastUpdated = $lastUpdated; 159 | } 160 | 161 | /** 162 | * @return mixed 163 | */ 164 | public function getName() 165 | { 166 | return $this->name; 167 | } 168 | 169 | /** 170 | * @param mixed $name 171 | */ 172 | public function setName($name) 173 | { 174 | $this->name = $name; 175 | } 176 | 177 | /** 178 | * @return mixed 179 | */ 180 | public function getWage() 181 | { 182 | return $this->wage; 183 | } 184 | 185 | /** 186 | * @param mixed $wage 187 | */ 188 | public function setWage($wage) 189 | { 190 | $this->wage = $wage; 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/DBAL/Drivers/Connection.php: -------------------------------------------------------------------------------- 1 | itemReflection = $itemReflection; 36 | $this->item = $item; 37 | $this->originalData = $originalData; 38 | } 39 | 40 | public function hasDirtyData() 41 | { 42 | if ($this->state != self::STATE_MANAGED) { 43 | return false; 44 | } 45 | 46 | $data = $this->itemReflection->dehydrate($this->item); 47 | if (!$this->isDataEqual($data, $this->originalData)) { 48 | return true; 49 | } 50 | else { 51 | return false; 52 | } 53 | } 54 | 55 | /** 56 | * @return bool 57 | */ 58 | public function isNew() 59 | { 60 | return $this->state == self::STATE_NEW; 61 | } 62 | 63 | /** 64 | * @return bool 65 | */ 66 | public function isRemoved() 67 | { 68 | return $this->state == self::STATE_REMOVED; 69 | } 70 | 71 | public function updatePartitionedHashKeys($hashFunction = null) 72 | { 73 | foreach ($this->itemReflection->getPartitionedHashKeys() as $partitionedHashKey => $def) { 74 | $baseValue = $this->itemReflection->getPropertyValue($this->item, $def->baseField); 75 | $hashSource = $this->itemReflection->getPropertyValue($this->item, $def->hashField); 76 | if (is_callable($hashFunction)) { 77 | $hashSource = call_user_func($hashFunction, $hashSource); 78 | } 79 | $hashNumber = hexdec(substr(md5($hashSource), 0, 8)); 80 | $hashRemainder = dechex($hashNumber % $def->size); 81 | $hashResult = sprintf("%s-%s", $baseValue, $hashRemainder); 82 | $this->itemReflection->updateProperty($this->item, $partitionedHashKey, $hashResult); 83 | } 84 | } 85 | 86 | public function updateCASTimestamps($timestampOffset = 0) 87 | { 88 | $now = time() + $timestampOffset; 89 | foreach ($this->itemReflection->getCasProperties() as $propertyName => $casType) { 90 | if ($casType == Field::CAS_TIMESTAMP) { 91 | $this->itemReflection->updateProperty($this->item, $propertyName, $now); 92 | } 93 | } 94 | } 95 | 96 | /** 97 | * @return array 98 | */ 99 | public function getCheckConditionData() 100 | { 101 | $checkValues = []; 102 | foreach ($this->itemReflection->getCasProperties() as $propertyName => $casType) { 103 | $fieldName = $this->itemReflection->getFieldNameByPropertyName($propertyName); 104 | $checkValues[$fieldName] = isset($this->originalData[$fieldName]) ? $this->originalData[$fieldName] : null; 105 | } 106 | 107 | return $checkValues; 108 | } 109 | 110 | /** 111 | * @return mixed 112 | */ 113 | public function getItem() 114 | { 115 | return $this->item; 116 | } 117 | 118 | /** 119 | * @param mixed $item 120 | */ 121 | public function setItem($item) 122 | { 123 | $this->item = $item; 124 | } 125 | 126 | /** 127 | * @return array 128 | */ 129 | public function getOriginalData() 130 | { 131 | return $this->originalData; 132 | } 133 | 134 | /** 135 | * @param array $originalData 136 | */ 137 | public function setOriginalData($originalData) 138 | { 139 | $this->originalData = $originalData; 140 | } 141 | 142 | public function getOriginalValue($key) 143 | { 144 | if (isset($this->originalData[$key])) { 145 | return $this->originalData[$key]; 146 | } 147 | else { 148 | return null; 149 | } 150 | } 151 | 152 | /** 153 | * @param int $state 154 | */ 155 | public function setState($state) 156 | { 157 | $this->state = $state; 158 | } 159 | 160 | public function setUpdated() 161 | { 162 | $this->originalData = $this->itemReflection->dehydrate($this->item); 163 | } 164 | 165 | /** @noinspection PhpParamsInspection */ 166 | protected function isDataEqual(&$a, &$b) 167 | { 168 | // empty string is considered null in dynamodb 169 | if ( 170 | (is_null($a) && is_string($b) && $b === '') 171 | || (is_null($b) && is_string($a) && $a === '') 172 | ) { 173 | return true; 174 | } 175 | 176 | if (gettype($a) != gettype($b)) { 177 | return false; 178 | } 179 | 180 | switch (true) { 181 | case (is_double($a)): 182 | return "$a" == "$b"; 183 | break; 184 | case (is_array($a)): 185 | if (count($a) !== count($b)) { 186 | return false; 187 | } 188 | foreach ($a as $k => &$v) { 189 | if (!key_exists($k, $b)) { 190 | return false; 191 | } 192 | if (!$this->isDataEqual($v, $b[$k])) { 193 | return false; 194 | } 195 | } 196 | 197 | // every $k in $a can be found in $b and is equal 198 | return true; 199 | break; 200 | case (is_resource($a)): 201 | case (is_object($a)): 202 | throw new ODMException("DynamoDb data cannot contain value of resource/object"); 203 | break; 204 | default: 205 | return $a === $b; 206 | } 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/ItemManager.php: -------------------------------------------------------------------------------- 1 | dbConnection = $dbCnn; 78 | } 79 | else { 80 | $this->databaseConfig = $dbCnn; 81 | } 82 | 83 | $this->defaultTablePrefix = $defaultTablePrefix; 84 | 85 | AnnotationRegistry::registerLoader([$this, 'loadAnnotationClass']); 86 | 87 | $this->reader = new CachedReader( 88 | new AnnotationReader(), 89 | new FilesystemCache($cacheDir), 90 | $isDev 91 | ); 92 | } 93 | 94 | public function addNamespace($namespace, $srcDir) 95 | { 96 | if (!is_dir($srcDir)) { 97 | mwarning("Directory %s doesn't exist.", $srcDir); 98 | 99 | return; 100 | } 101 | $finder = new Finder(); 102 | $finder->in($srcDir) 103 | ->path('/\.php$/'); 104 | foreach ($finder as $splFileInfo) { 105 | $classname = sprintf( 106 | "%s\\%s\\%s", 107 | $namespace, 108 | str_replace("/", "\\", $splFileInfo->getRelativePath()), 109 | $splFileInfo->getBasename(".php") 110 | ); 111 | $classname = preg_replace('#\\\\+#', '\\', $classname); 112 | //mdebug("Class name is %s", $classname); 113 | $this->possibleItemClasses[] = $classname; 114 | } 115 | } 116 | 117 | public function addReservedAttributeNames(...$args) 118 | { 119 | foreach ($args as $arg) { 120 | $this->reservedAttributeNames[] = $arg; 121 | } 122 | } 123 | 124 | public function clear() 125 | { 126 | foreach ($this->repositories as $itemRepository) { 127 | $itemRepository->clear(); 128 | } 129 | } 130 | 131 | public function detach($item) 132 | { 133 | if (!is_object($item)) { 134 | throw new ODMException("You can only detach a managed object!"); 135 | } 136 | $this->getRepository(get_class($item))->detach($item); 137 | } 138 | 139 | public function flush() 140 | { 141 | foreach ($this->repositories as $repository) { 142 | $repository->flush(); 143 | } 144 | } 145 | 146 | public function get($itemClass, array $keys, $consistentRead = false) 147 | { 148 | return $this->getRepository($itemClass)->get($keys, $consistentRead); 149 | } 150 | 151 | /** 152 | * @deprecated use shouldSkipCheckAndSet() instead 153 | * @return bool 154 | */ 155 | public function isSkipCheckAndSet() 156 | { 157 | return $this->skipCheckAndSet; 158 | } 159 | 160 | /** 161 | * @param bool $skipCheckAndSet 162 | */ 163 | public function setSkipCheckAndSet($skipCheckAndSet) 164 | { 165 | $this->skipCheckAndSet = $skipCheckAndSet; 166 | } 167 | 168 | /** 169 | * @param $className 170 | * 171 | * @internal 172 | * @return bool 173 | */ 174 | public function loadAnnotationClass($className) 175 | { 176 | if (class_exists($className)) { 177 | return true; 178 | } 179 | else { 180 | return false; 181 | } 182 | } 183 | 184 | public function persist($item) 185 | { 186 | $this->getRepository(get_class($item))->persist($item); 187 | } 188 | 189 | public function refresh($item, $persistIfNotManaged = false) 190 | { 191 | $this->getRepository(get_class($item))->refresh($item, $persistIfNotManaged); 192 | } 193 | 194 | public function remove($item) 195 | { 196 | $this->getRepository(get_class($item))->remove($item); 197 | } 198 | 199 | /** 200 | * @return bool 201 | */ 202 | public function shouldSkipCheckAndSet() 203 | { 204 | return $this->skipCheckAndSet; 205 | } 206 | 207 | /** 208 | * @return mixed 209 | */ 210 | public function getDefaultTablePrefix() 211 | { 212 | return $this->defaultTablePrefix; 213 | } 214 | 215 | /** 216 | * @return array 217 | * todo: set deprecated and recommend getDatabaseConfig() instead 218 | */ 219 | public function getDynamodbConfig() 220 | { 221 | return $this->getDatabaseConfig(); 222 | } 223 | 224 | /** 225 | * @return array 226 | */ 227 | public function getDatabaseConfig() 228 | { 229 | if ($this->dbConnection instanceof Connection) { 230 | return $this->dbConnection->getDatabaseConfig(); 231 | } 232 | 233 | return $this->databaseConfig; 234 | } 235 | 236 | /** 237 | * @param $itemClass 238 | * 239 | * @return ItemReflection 240 | */ 241 | public function getItemReflection($itemClass) 242 | { 243 | if (!isset($this->itemReflections[$itemClass])) { 244 | $reflection = new ItemReflection($itemClass, $this->reservedAttributeNames); 245 | $reflection->parse($this->reader); 246 | $this->itemReflections[$itemClass] = $reflection; 247 | } 248 | else { 249 | $reflection = $this->itemReflections[$itemClass]; 250 | } 251 | 252 | return $reflection; 253 | } 254 | 255 | /** 256 | * @return string[] 257 | */ 258 | public function getPossibleItemClasses() 259 | { 260 | return $this->possibleItemClasses; 261 | } 262 | 263 | /** 264 | * @return AnnotationReader 265 | */ 266 | public function getReader() 267 | { 268 | return $this->reader; 269 | } 270 | 271 | /** 272 | * @param $itemClass 273 | * 274 | * @return ItemRepository 275 | */ 276 | public function getRepository($itemClass) 277 | { 278 | if (!isset($this->repositories[$itemClass])) { 279 | $reflection = $this->getItemReflection($itemClass); 280 | $repoClass = $reflection->getRepositoryClass(); 281 | $repo = new $repoClass( 282 | $reflection, 283 | $this 284 | ); 285 | $this->repositories[$itemClass] = $repo; 286 | } 287 | else { 288 | $repo = $this->repositories[$itemClass]; 289 | } 290 | 291 | return $repo; 292 | } 293 | 294 | /** 295 | * @return array 296 | */ 297 | public function getReservedAttributeNames() 298 | { 299 | return $this->reservedAttributeNames; 300 | } 301 | 302 | /** 303 | * @param array $reservedAttributeNames 304 | */ 305 | public function setReservedAttributeNames($reservedAttributeNames) 306 | { 307 | $this->reservedAttributeNames = $reservedAttributeNames; 308 | } 309 | 310 | /** 311 | * 312 | * Get a new database connection 313 | * 314 | * @return Connection 315 | */ 316 | public function createDBConnection() 317 | { 318 | if ($this->dbConnection instanceof Connection) { 319 | return clone $this->dbConnection; 320 | } 321 | else { 322 | // default database connection 323 | return new DynamoDbConnection($this->getDatabaseConfig()); 324 | } 325 | } 326 | 327 | } 328 | -------------------------------------------------------------------------------- /src/DBAL/Drivers/DynamoDbConnection.php: -------------------------------------------------------------------------------- 1 | dynamodbTable !== null) { 35 | return $this->dynamodbTable; 36 | } 37 | 38 | if (empty($this->tableName)) { 39 | throw new ODMException("Unknown table name to initialize DynamoDbTable client"); 40 | } 41 | 42 | if (empty($this->attributeTypes)) { 43 | throw new ODMException("Unknown attribute types to initialize DynamoDbTable client"); 44 | } 45 | 46 | $this->dynamodbTable = new DynamoDbTable( 47 | $this->dbConfig, 48 | $this->tableName, 49 | $this->attributeTypes 50 | ); 51 | 52 | return $this->dynamodbTable; 53 | } 54 | 55 | public function batchGet( 56 | array $keys, 57 | $isConsistentRead = false, 58 | $concurrency = 10, 59 | $projectedFields = [], 60 | $keyIsTyped = false, 61 | $retryDelay = 0, 62 | $maxDelay = 15000 63 | ) { 64 | return $this->getDynamodbTable()->batchGet( 65 | $keys, 66 | $isConsistentRead, 67 | $concurrency, 68 | $projectedFields, 69 | $keyIsTyped, 70 | $retryDelay, 71 | $maxDelay 72 | ); 73 | } 74 | 75 | public function batchDelete(array $objs, $concurrency = 10, $maxDelay = 15000) 76 | { 77 | $this->getDynamodbTable()->batchDelete($objs, $concurrency, $maxDelay); 78 | } 79 | 80 | public function batchPut(array $objs, $concurrency = 10, $maxDelay = 15000) 81 | { 82 | $this->getDynamodbTable()->batchPut($objs, $concurrency, $maxDelay); 83 | } 84 | 85 | public function set(array $obj, $checkValues = []) 86 | { 87 | return $this->getDynamodbTable()->set($obj, $checkValues); 88 | } 89 | 90 | public function get(array $keys, $is_consistent_read = false, $projectedFields = []) 91 | { 92 | return $this->getDynamodbTable()->get($keys, $is_consistent_read, $projectedFields); 93 | } 94 | 95 | public function query( 96 | $keyConditions, 97 | array $fieldsMapping, 98 | array $paramsMapping, 99 | $indexName = DynamoDbIndex::PRIMARY_INDEX, 100 | $filterExpression = '', 101 | &$lastKey = null, 102 | $evaluationLimit = 30, 103 | $isConsistentRead = false, 104 | $isAscendingOrder = true, 105 | $projectedFields = [] 106 | ) { 107 | return $this->getDynamodbTable()->query( 108 | $keyConditions, 109 | $fieldsMapping, 110 | $paramsMapping, 111 | $indexName, 112 | $filterExpression, 113 | $lastKey, 114 | $evaluationLimit, 115 | $isConsistentRead, 116 | $isAscendingOrder, 117 | $projectedFields 118 | ); 119 | } 120 | 121 | public function queryAndRun( 122 | callable $callback, 123 | $keyConditions, 124 | array $fieldsMapping, 125 | array $paramsMapping, 126 | $indexName = DynamoDbIndex::PRIMARY_INDEX, 127 | $filterExpression = '', 128 | $isConsistentRead = false, 129 | $isAscendingOrder = true, 130 | $projectedFields = [] 131 | ) { 132 | $this->getDynamodbTable()->queryAndRun( 133 | $callback, 134 | $keyConditions, 135 | $fieldsMapping, 136 | $paramsMapping, 137 | $indexName, 138 | $filterExpression, 139 | $isConsistentRead, 140 | $isAscendingOrder, 141 | $projectedFields 142 | ); 143 | } 144 | 145 | public function queryCount( 146 | $keyConditions, 147 | array $fieldsMapping, 148 | array $paramsMapping, 149 | $indexName = DynamoDbIndex::PRIMARY_INDEX, 150 | $filterExpression = '', 151 | $isConsistentRead = false, 152 | $isAscendingOrder = true 153 | ) { 154 | return $this->getDynamodbTable()->queryCount( 155 | $keyConditions, 156 | $fieldsMapping, 157 | $paramsMapping, 158 | $indexName, 159 | $filterExpression, 160 | $isConsistentRead, 161 | $isAscendingOrder 162 | ); 163 | } 164 | 165 | public function multiQueryAndRun( 166 | callable $callback, 167 | $hashKeyName, 168 | $hashKeyValues, 169 | $rangeKeyConditions, 170 | array $fieldsMapping, 171 | array $paramsMapping, 172 | $indexName = DynamoDbIndex::PRIMARY_INDEX, 173 | $filterExpression = '', 174 | $evaluationLimit = 30, 175 | $isConsistentRead = false, 176 | $isAscendingOrder = true, 177 | $concurrency = 10, 178 | $projectedFields = [] 179 | ) { 180 | $this->getDynamodbTable()->multiQueryAndRun( 181 | $callback, 182 | $hashKeyName, 183 | $hashKeyValues, 184 | $rangeKeyConditions, 185 | $fieldsMapping, 186 | $paramsMapping, 187 | $indexName, 188 | $filterExpression, 189 | $evaluationLimit, 190 | $isConsistentRead, 191 | $isAscendingOrder, 192 | $concurrency, 193 | $projectedFields 194 | ); 195 | } 196 | 197 | public function scan( 198 | $filterExpression = '', 199 | array $fieldsMapping = [], 200 | array $paramsMapping = [], 201 | $indexName = DynamoDbIndex::PRIMARY_INDEX, 202 | &$lastKey = null, 203 | $evaluationLimit = 30, 204 | $isConsistentRead = false, 205 | $isAscendingOrder = true, 206 | $projectedFields = [] 207 | ) { 208 | return $this->getDynamodbTable()->scan( 209 | $filterExpression, 210 | $fieldsMapping, 211 | $paramsMapping, 212 | $indexName, 213 | $lastKey, 214 | $evaluationLimit, 215 | $isConsistentRead, 216 | $isAscendingOrder, 217 | $projectedFields 218 | ); 219 | } 220 | 221 | public function scanAndRun( 222 | callable $callback, 223 | $filterExpression = '', 224 | array $fieldsMapping = [], 225 | array $paramsMapping = [], 226 | $indexName = DynamoDbIndex::PRIMARY_INDEX, 227 | $isConsistentRead = false, 228 | $isAscendingOrder = true, 229 | $projectedFields = [] 230 | ) { 231 | $this->getDynamodbTable()->scanAndRun( 232 | $callback, 233 | $filterExpression, 234 | $fieldsMapping, 235 | $paramsMapping, 236 | $indexName, 237 | $isConsistentRead, 238 | $isAscendingOrder, 239 | $projectedFields 240 | ); 241 | } 242 | 243 | public function parallelScanAndRun( 244 | $parallel, 245 | callable $callback, 246 | $filterExpression = '', 247 | array $fieldsMapping = [], 248 | array $paramsMapping = [], 249 | $indexName = DynamoDbIndex::PRIMARY_INDEX, 250 | $isConsistentRead = false, 251 | $isAscendingOrder = true, 252 | $projectedFields = [] 253 | ) { 254 | $this->getDynamodbTable()->parallelScanAndRun( 255 | $parallel, 256 | $callback, 257 | $filterExpression, 258 | $fieldsMapping, 259 | $paramsMapping, 260 | $indexName, 261 | $isConsistentRead, 262 | $isAscendingOrder, 263 | $projectedFields 264 | ); 265 | } 266 | 267 | public function scanCount( 268 | $filterExpression = '', 269 | array $fieldsMapping = [], 270 | array $paramsMapping = [], 271 | $indexName = DynamoDbIndex::PRIMARY_INDEX, 272 | $isConsistentRead = false, 273 | $parallel = 10 274 | ) { 275 | return $this->getDynamodbTable()->scanCount( 276 | $filterExpression, 277 | $fieldsMapping, 278 | $paramsMapping, 279 | $indexName, 280 | $isConsistentRead, 281 | $parallel 282 | ); 283 | } 284 | 285 | /** 286 | * @param ItemManager $im 287 | * @param $classReflections 288 | * @param callable|null $outputFunction 289 | * @return DynamoDbSchemaTool 290 | */ 291 | public function getSchemaTool(ItemManager $im, $classReflections, callable $outputFunction = null) 292 | { 293 | return new DynamoDbSchemaTool($im, $classReflections, $outputFunction); 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /src/ItemReflection.php: -------------------------------------------------------------------------------- 1 | cas type 45 | */ 46 | protected $casProperties; 47 | /** 48 | * @var PartitionedHashKey[] 49 | * partitioned hash keys, in the format of property name => partitioned hash key definition 50 | */ 51 | protected $partitionedHashKeys; 52 | /** 53 | * @var Field[] 54 | * Maps class property name to its field definition 55 | */ 56 | protected $fieldDefinitions; 57 | /** 58 | * @var ReflectionProperty[] 59 | * Maps each class property name to its reflection property 60 | */ 61 | protected $reflectionProperties; 62 | /** 63 | * @var array 64 | * Reserved attribute names will be cleared when hydrating an object 65 | */ 66 | protected $reservedAttributeNames; 67 | 68 | public function __construct($itemClass, $reservedAttributeNames) 69 | { 70 | $this->itemClass = $itemClass; 71 | $this->reservedAttributeNames = $reservedAttributeNames; 72 | } 73 | 74 | public function dehydrate($obj) 75 | { 76 | if (!is_object($obj)) { 77 | throw new ODMException("You may only dehydrate an object!"); 78 | } 79 | 80 | if (!$obj instanceof $this->itemClass) { 81 | throw new ODMException( 82 | "Object dehydrated is not of correct type, expected: " . $this->itemClass . ", got: " . get_class($obj) 83 | ); 84 | } 85 | 86 | $array = []; 87 | foreach ($this->fieldDefinitions as $propertyName => $field) { 88 | $value = $this->getPropertyValue($obj, $propertyName); 89 | if (is_null($value) && $field->gsiKey) { 90 | continue; 91 | } 92 | $key = $field->name ? : $propertyName; 93 | $array[$key] = $value; 94 | } 95 | 96 | return $array; 97 | } 98 | 99 | public function hydrate(array $array, $obj = null) 100 | { 101 | if ($obj === null) { 102 | $obj = $this->getReflectionClass()->newInstanceWithoutConstructor(); 103 | } 104 | elseif (!is_object($obj) || !$obj instanceof $this->itemClass) { 105 | throw new ODMException("You can not hydrate an object of wrong type, expected: " . $this->itemClass); 106 | } 107 | 108 | foreach ($array as $key => $value) { 109 | if (in_array($key, $this->reservedAttributeNames)) { 110 | // this attribute is reserved for other use 111 | continue; 112 | } 113 | if (!isset($this->propertyMapping[$key])) { 114 | // this property is not defined, skip it 115 | mwarning("Got an unknown attribute: %s with value %s", $key, print_r($value, true)); 116 | continue; 117 | } 118 | $propertyName = $this->propertyMapping[$key]; 119 | $fieldDefinition = $this->fieldDefinitions[$propertyName]; 120 | if ($fieldDefinition->type == "string") { 121 | // cast to string because dynamo stores "" as null 122 | $value = strval($value); 123 | } 124 | $this->updateProperty($obj, $propertyName, $value); 125 | } 126 | 127 | return $obj; 128 | } 129 | 130 | public function parse(Reader $reader) 131 | { 132 | // initialize class annotation info 133 | $this->reflectionClass = new ReflectionClass($this->itemClass); 134 | $this->itemDefinition = $reader->getClassAnnotation($this->reflectionClass, Item::class); 135 | if (!$this->itemDefinition) { 136 | throw new NotAnnotatedException("Class " . $this->itemClass . " is not configured as an Item"); 137 | } 138 | 139 | // initialize property annotation info 140 | $this->propertyMapping = []; 141 | $this->fieldDefinitions = []; 142 | $this->reflectionProperties = []; 143 | $this->attributeTypes = []; 144 | $this->casProperties = []; 145 | $this->partitionedHashKeys = []; 146 | foreach ($this->reflectionClass->getProperties() as $reflectionProperty) { 147 | if ($reflectionProperty->isStatic()) { 148 | continue; 149 | } 150 | $propertyName = $reflectionProperty->getName(); 151 | $this->reflectionProperties[$propertyName] = $reflectionProperty; 152 | 153 | /** @var Field $field */ 154 | $field = $reader->getPropertyAnnotation($reflectionProperty, Field::class); 155 | if (!$field) { 156 | continue; 157 | } 158 | $fieldName = $field->name ? : $propertyName; 159 | $this->propertyMapping[$fieldName] = $propertyName; 160 | $this->fieldDefinitions[$propertyName] = $field; 161 | $this->attributeTypes[$fieldName] = $field->type; 162 | if ($field->cas != Field::CAS_DISABLED) { 163 | $this->casProperties[$propertyName] = $field->cas; 164 | } 165 | 166 | /** @var PartitionedHashKey $partitionedHashKeyDef */ 167 | $partitionedHashKeyDef = $reader->getPropertyAnnotation($reflectionProperty, PartitionedHashKey::class); 168 | if ($partitionedHashKeyDef) { 169 | $this->partitionedHashKeys[$propertyName] = $partitionedHashKeyDef; 170 | } 171 | } 172 | } 173 | 174 | public function getAllPartitionedValues($hashKeyName, $baseValue) 175 | { 176 | if (!isset($this->partitionedHashKeys[$hashKeyName])) { 177 | // mdebug("The field %s is not declared as a PartitionedHashKey!", $hashKeyName) 178 | return [$baseValue]; 179 | } 180 | 181 | $def = $this->partitionedHashKeys[$hashKeyName]; 182 | $ret = []; 183 | for ($i = 0; $i < $def->size; ++$i) { 184 | $ret[] = sprintf("%s-%s", $baseValue, dechex($i)); 185 | } 186 | 187 | return $ret; 188 | } 189 | 190 | public function getPropertyValue($obj, $propertyName) 191 | { 192 | if (!$obj instanceof $this->itemClass) { 193 | throw new ODMException( 194 | "Object accessed is not of correct type, expected: " . $this->itemClass . ", got: " . get_class($obj) 195 | ); 196 | } 197 | 198 | if (!isset($this->reflectionProperties[$propertyName])) { 199 | throw new ODMException( 200 | "Object " . $this->itemClass . " doesn't have a property named: " . $propertyName 201 | ); 202 | } 203 | $reflectionProperty = $this->reflectionProperties[$propertyName]; 204 | $oldAccessibility = $reflectionProperty->isPublic(); 205 | $reflectionProperty->setAccessible(true); 206 | $ret = $reflectionProperty->getValue($obj); 207 | $reflectionProperty->setAccessible($oldAccessibility); 208 | 209 | return $ret; 210 | } 211 | 212 | public function updateProperty($obj, $propertyName, $value) 213 | { 214 | if (!$obj instanceof $this->itemClass) { 215 | throw new ODMException( 216 | "Object updated is not of correct type, expected: " . $this->itemClass . ", got: " . get_class($obj) 217 | ); 218 | } 219 | 220 | if (!isset($this->reflectionProperties[$propertyName])) { 221 | throw new ODMException( 222 | "Object " . $this->itemClass . " doesn't have a property named: " . $propertyName 223 | ); 224 | } 225 | $reflectionProperty = $this->reflectionProperties[$propertyName]; 226 | $oldAccessibility = $reflectionProperty->isPublic(); 227 | $reflectionProperty->setAccessible(true); 228 | $reflectionProperty->setValue($obj, $value); 229 | $reflectionProperty->setAccessible($oldAccessibility); 230 | } 231 | 232 | /** 233 | * @return mixed 234 | */ 235 | public function getAttributeTypes() 236 | { 237 | return $this->attributeTypes; 238 | } 239 | 240 | /** 241 | * @return array 242 | */ 243 | public function getCasProperties() 244 | { 245 | return $this->casProperties; 246 | } 247 | 248 | /** 249 | * Returns field name (attribute key for dynamodb) according to property name 250 | * 251 | * @param $propertyName 252 | * 253 | * @return string 254 | */ 255 | public function getFieldNameByPropertyName($propertyName) 256 | { 257 | $field = $this->fieldDefinitions[$propertyName]; 258 | 259 | return $field->name ? : $propertyName; 260 | } 261 | 262 | /** 263 | * @return array a map of property name to attribute key 264 | */ 265 | public function getFieldNameMapping() 266 | { 267 | $ret = []; 268 | foreach ($this->fieldDefinitions as $propertyName => $field) { 269 | $ret[$propertyName] = $field->name ? : $propertyName; 270 | } 271 | 272 | return $ret; 273 | } 274 | 275 | public function getProjectedAttributes() 276 | { 277 | if ($this->getItemDefinition()->projected) { 278 | return array_keys($this->propertyMapping); 279 | } 280 | else { 281 | return []; 282 | } 283 | } 284 | 285 | /** 286 | * @return mixed 287 | */ 288 | public function getItemClass() 289 | { 290 | return $this->itemClass; 291 | } 292 | 293 | /** 294 | * @return Item 295 | */ 296 | public function getItemDefinition() 297 | { 298 | return $this->itemDefinition; 299 | } 300 | 301 | /** 302 | * @return PartitionedHashKey[] 303 | */ 304 | public function getPartitionedHashKeys() 305 | { 306 | return $this->partitionedHashKeys; 307 | } 308 | 309 | public function getPrimaryIdentifier($obj) 310 | { 311 | $id = ''; 312 | foreach ($this->getPrimaryKeys($obj) as $key => $value) { 313 | $id .= md5($value); 314 | } 315 | 316 | return md5($id); 317 | } 318 | 319 | public function getPrimaryKeys($obj, $asAttributeKeys = true) 320 | { 321 | $keys = []; 322 | foreach ($this->itemDefinition->primaryIndex->getKeys() as $key) { 323 | if (!isset($this->fieldDefinitions[$key])) { 324 | throw new AnnotationParsingException("Primary field " . $key . " is not defined."); 325 | } 326 | $attributeKey = $this->fieldDefinitions[$key]->name ? : $key; 327 | 328 | if (is_array($obj)) { 329 | if (!isset($obj[$attributeKey])) { 330 | throw new ODMException( 331 | "Cannot get identifier for incomplete object! <" . $attributeKey . "> is empty!" 332 | ); 333 | } 334 | $value = $obj[$attributeKey]; 335 | } 336 | else { 337 | $value = $this->getPropertyValue($obj, $key); 338 | } 339 | 340 | if ($asAttributeKeys) { 341 | $keys[$attributeKey] = $value; 342 | } 343 | else { 344 | $keys[$key] = $value; 345 | } 346 | } 347 | 348 | return $keys; 349 | } 350 | 351 | /** 352 | * @return ReflectionClass 353 | */ 354 | public function getReflectionClass() 355 | { 356 | return $this->reflectionClass; 357 | } 358 | 359 | public function getRepositoryClass() 360 | { 361 | return $this->itemDefinition->repository ? : ItemRepository::class; 362 | } 363 | 364 | public function getTableName() 365 | { 366 | return $this->itemDefinition->table; 367 | } 368 | } 369 | -------------------------------------------------------------------------------- /src/DBAL/Schema/DynamoDbSchemaTool.php: -------------------------------------------------------------------------------- 1 | getItemManager()->getDynamodbConfig()); 31 | 32 | $classes = $this->getManagedItemClasses(); 33 | foreach ($classes as $class => $reflection) { 34 | $tableName = $this->itemManager->getDefaultTablePrefix().$reflection->getTableName(); 35 | if ($dynamoManager->listTables(sprintf("/^%s\$/", preg_quote($tableName, "/")))) { 36 | if (!$skipExisting && !$dryRun) { 37 | throw new ODMException("Table ".$tableName." already exists!"); 38 | } 39 | } 40 | } 41 | 42 | $waits = []; 43 | 44 | /** 45 | * @var $class 46 | * @var ItemReflection $reflection 47 | */ 48 | foreach ($classes as $class => $reflection) { 49 | $itemDef = $reflection->getItemDefinition(); 50 | if ($itemDef->projected) { 51 | $this->outputWrite(sprintf("Class %s is projected class, will not create table.", $class)); 52 | continue; 53 | } 54 | 55 | $attributeTypes = $reflection->getAttributeTypes(); 56 | $fieldNameMapping = $reflection->getFieldNameMapping(); 57 | 58 | $lsis = []; 59 | /** @var Index $localSecondaryIndex */ 60 | foreach ($itemDef->localSecondaryIndices as $localSecondaryIndex) { 61 | $lsis[] = $localSecondaryIndex->getDynamodbIndex($fieldNameMapping, $attributeTypes); 62 | } 63 | $gsis = []; 64 | /** @var Index $globalSecondaryIndex */ 65 | foreach ($itemDef->globalSecondaryIndices as $globalSecondaryIndex) { 66 | $gsis[] = $globalSecondaryIndex->getDynamodbIndex($fieldNameMapping, $attributeTypes); 67 | } 68 | 69 | $tableName = $this->itemManager->getDefaultTablePrefix().$reflection->getTableName(); 70 | 71 | $this->outputWrite("Will create table $tableName for class $class ..."); 72 | if (!$dryRun) { 73 | $dynamoManager->createTable( 74 | $tableName, 75 | $itemDef->primaryIndex->getDynamodbIndex($fieldNameMapping, $attributeTypes), 76 | $lsis, 77 | $gsis 78 | ); 79 | 80 | if ($gsis) { 81 | // if there is gsi, we nee to wait before creating next table 82 | $this->outputWrite("Will wait for GSI creation ..."); 83 | $dynamoManager->waitForTablesToBeFullyReady($tableName, 60, 2); 84 | } 85 | else { 86 | $waits[] = $dynamoManager->waitForTableCreation( 87 | $tableName, 88 | 60, 89 | 1, 90 | false 91 | ); 92 | } 93 | $this->outputWrite('Created.'); 94 | } 95 | } 96 | 97 | if (!$dryRun) { 98 | $this->outputWrite("Waiting for all tables to be active ..."); 99 | all($waits)->wait(); 100 | $this->outputWrite("Done."); 101 | } 102 | } 103 | 104 | /** @noinspection PhpStatementHasEmptyBodyInspection */ 105 | public function updateSchema($isDryRun) 106 | { 107 | $dynamoManager = new DynamoDbManager($this->getItemManager()->getDynamodbConfig()); 108 | $classes = $this->getManagedItemClasses(); 109 | $classCreation = []; 110 | $gsiChanges = []; 111 | $im = $this->itemManager; 112 | 113 | /** @var ItemReflection $reflection */ 114 | foreach ($classes as $class => $reflection) { 115 | if ($reflection->getItemDefinition()->projected) { 116 | // will skip projected table 117 | continue; 118 | } 119 | $tableName = $this->itemManager->getDefaultTablePrefix().$reflection->getTableName(); 120 | 121 | if (!$dynamoManager->listTables(sprintf("/^%s\$/", preg_quote($tableName, "/")))) { 122 | // will create 123 | $classCreation[] = function () use ($isDryRun, $im, $class, $reflection, $dynamoManager, $tableName) { 124 | $itemDef = $reflection->getItemDefinition(); 125 | $attributeTypes = $reflection->getAttributeTypes(); 126 | $fieldNameMapping = $reflection->getFieldNameMapping(); 127 | 128 | $lsis = []; 129 | foreach ($itemDef->localSecondaryIndices as $localSecondaryIndex) { 130 | $lsis[] = $localSecondaryIndex->getDynamodbIndex($fieldNameMapping, $attributeTypes); 131 | } 132 | $gsis = []; 133 | foreach ($itemDef->globalSecondaryIndices as $globalSecondaryIndex) { 134 | $gsis[] = $globalSecondaryIndex->getDynamodbIndex($fieldNameMapping, $attributeTypes); 135 | } 136 | 137 | $tableName = $im->getDefaultTablePrefix().$reflection->getTableName(); 138 | 139 | $this->outputWrite("Will create table $tableName for class $class."); 140 | 141 | if (!$isDryRun) { 142 | $dynamoManager->createTable( 143 | $tableName, 144 | $itemDef->primaryIndex->getDynamodbIndex($fieldNameMapping, $attributeTypes), 145 | $lsis, 146 | $gsis 147 | ); 148 | $this->outputWrite('Created.'); 149 | } 150 | 151 | return $tableName; 152 | }; 153 | } 154 | else { 155 | // will update 156 | $table = new DynamoDbTable($this->getItemManager()->getDynamodbConfig(), $tableName); 157 | $itemDef = $reflection->getItemDefinition(); 158 | $attributeTypes = $reflection->getAttributeTypes(); 159 | $fieldNameMapping = $reflection->getFieldNameMapping(); 160 | 161 | $oldPrimaryIndex = $table->getPrimaryIndex(); 162 | $primaryIndex = $itemDef->primaryIndex->getDynamodbIndex($fieldNameMapping, $attributeTypes); 163 | 164 | if (!$oldPrimaryIndex->equals($primaryIndex)) { 165 | throw new ODMException( 166 | sprintf( 167 | "Primary index changed, which is not possible when table is already created! [Table = %s]", 168 | $tableName 169 | ) 170 | ); 171 | } 172 | 173 | $oldLsis = $table->getLocalSecondaryIndices(); 174 | foreach ($itemDef->localSecondaryIndices as $localSecondaryIndex) { 175 | $idx = $localSecondaryIndex->getDynamodbIndex($fieldNameMapping, $attributeTypes); 176 | if (!isset($oldLsis[$idx->getName()])) { 177 | throw new ODMException( 178 | sprintf( 179 | "LSI named %s did not exist, you cannot update LSI when table is created! [Table = %s]", 180 | $idx->getName(), 181 | $tableName 182 | ) 183 | ); 184 | } 185 | else { 186 | unset($oldLsis[$idx->getName()]); 187 | } 188 | } 189 | if ($oldLsis) { 190 | throw new ODMException( 191 | sprintf( 192 | "LSI named %s removed, you cannot remove any LSI when table is created!", 193 | implode(",", array_keys($oldLsis)) 194 | ) 195 | ); 196 | } 197 | 198 | $oldGsis = $table->getGlobalSecondaryIndices(); 199 | foreach ($itemDef->globalSecondaryIndices as $globalSecondaryIndex) { 200 | $idx = $globalSecondaryIndex->getDynamodbIndex($fieldNameMapping, $attributeTypes); 201 | 202 | if (!isset($oldGsis[$idx->getName()])) { 203 | // new GSI 204 | $gsiChanges[] = function () use ( 205 | $isDryRun, 206 | $dynamoManager, 207 | $class, 208 | $tableName, 209 | $table, 210 | $idx 211 | ) { 212 | $this->outputWrite( 213 | "Will add GSI [" 214 | .$idx->getName() 215 | ."] to table $tableName for class $class ..." 216 | ); 217 | if (!$isDryRun) { 218 | $table->addGlobalSecondaryIndex($idx); 219 | // if there is gsi alteration, we nee to wait before continue 220 | $this->outputWrite("Will wait for creation of GSI ".$idx->getName()." ..."); 221 | $dynamoManager->waitForTablesToBeFullyReady($tableName, 300, 5); 222 | $this->outputWrite('Done.'); 223 | } 224 | 225 | return $tableName; 226 | }; 227 | } 228 | else { 229 | // GSI with same name 230 | 231 | if ($idx->equals($oldGsis[$idx->getName()])) { 232 | // nothing to update 233 | } 234 | else { 235 | $gsiChanges[] = function () use ( 236 | $isDryRun, 237 | $dynamoManager, 238 | $class, 239 | $tableName, 240 | $table, 241 | $idx 242 | ) { 243 | $this->outputWrite( 244 | "Will update GSI [" 245 | .$idx->getName() 246 | ."] on table $tableName for class $class ..." 247 | ); 248 | if (!$isDryRun) { 249 | // if there is gsi alteration, we nee to wait before continue 250 | $table->deleteGlobalSecondaryIndex($idx->getName()); 251 | $this->outputWrite("Will wait for deletion of GSI ".$idx->getName()." ..."); 252 | $dynamoManager->waitForTablesToBeFullyReady($tableName, 300, 5); 253 | //$output->writeln( 254 | // "Will sleep 3 seconds before creating new GSI. If the creation fails, you can feel free to run update command again." 255 | //); 256 | //sleep(3); 257 | $table->addGlobalSecondaryIndex($idx); 258 | $this->outputWrite("Will wait for creation of GSI ".$idx->getName()." ..."); 259 | $dynamoManager->waitForTablesToBeFullyReady($tableName, 300, 5); 260 | $this->outputWrite('Done.'); 261 | } 262 | 263 | return $tableName; 264 | }; 265 | } 266 | 267 | unset($oldGsis[$idx->getName()]); 268 | } 269 | } 270 | if ($oldGsis) { 271 | /** @var DynamoDbIndex $removedGsi */ 272 | foreach ($oldGsis as $removedGsi) { 273 | $gsiChanges[] = function () use ( 274 | $isDryRun, 275 | $dynamoManager, 276 | $class, 277 | $tableName, 278 | $table, 279 | $removedGsi 280 | ) { 281 | $this->outputWrite( 282 | "Will remove GSI [" 283 | .$removedGsi->getName() 284 | ."] from table $tableName for class $class ..." 285 | ); 286 | if (!$isDryRun) { 287 | $table->deleteGlobalSecondaryIndex($removedGsi->getName()); 288 | $this->outputWrite("Will wait for deletion of GSI ".$removedGsi->getName()." ..."); 289 | $dynamoManager->waitForTablesToBeFullyReady($tableName, 300, 5); 290 | $this->outputWrite('Done.'); 291 | } 292 | 293 | return $tableName; 294 | }; 295 | } 296 | } 297 | } 298 | } 299 | 300 | if (!$classCreation && !$gsiChanges) { 301 | $this->outputWrite("Nothing to change."); 302 | } 303 | else { 304 | $waits = []; 305 | foreach ($classCreation as $callable) { 306 | $tableName = call_user_func($callable); 307 | if (!$isDryRun) { 308 | $waits[] = $dynamoManager->waitForTableCreation($tableName, 60, 1, false); 309 | } 310 | } 311 | if ($waits) { 312 | $this->outputWrite("Waiting for all created tables to be active ..."); 313 | all($waits)->wait(); 314 | $this->outputWrite("Done."); 315 | } 316 | 317 | $changedTables = []; 318 | foreach ($gsiChanges as $callable) { 319 | $tableName = call_user_func($callable); 320 | if (!$isDryRun) { 321 | $changedTables[] = $tableName; 322 | } 323 | } 324 | } 325 | } 326 | 327 | public function dropSchema() 328 | { 329 | $dynamoManager = new DynamoDbManager($this->getItemManager()->getDynamodbConfig()); 330 | $classes = $this->getManagedItemClasses(); 331 | $im = $this->getItemManager(); 332 | 333 | $waits = []; 334 | foreach ($classes as $class => $reflection) { 335 | $tableName = $im->getDefaultTablePrefix().$reflection->getTableName(); 336 | $this->outputWrite("Will drop table $tableName for class $class ..."); 337 | try { 338 | $dynamoManager->deleteTable($tableName); 339 | } catch (DynamoDbException $e) { 340 | if ("ResourceNotFoundException" == $e->getAwsErrorCode()) { 341 | $this->outputWrite('Table not found.'); 342 | } 343 | else { 344 | throw $e; 345 | } 346 | } 347 | $waits[] = $dynamoManager->waitForTableDeletion( 348 | $tableName, 349 | 60, 350 | 1, 351 | false 352 | ); 353 | $this->outputWrite('Deleted.'); 354 | } 355 | $this->outputWrite("Waiting for all tables to be inactive"); 356 | all($waits)->wait(); 357 | $this->outputWrite("Done."); 358 | } 359 | } 360 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Object Data Mapping component for DynamoDb 2 | 3 | The oasis/dynamodb-odm is an ODM (object data mapping) library for easy use of AWS' powerful key-value database: DynamoDb. 4 | 5 | > **NOTE**: this document assumes you have some understanding of what DynamoDB is and the difference between DynamoDB and traditional RDBMS (e.g. MySQL). Some terms and ideas discussed in this document are DynamoDB specific and will not be explained in this documentation. To study DynamoDB, please refer to the [official dev guide](http://docs.aws.amazon.com/amazondynamodb/latest/developerguide) 6 | 7 | ## Installation & Configuration 8 | 9 | To get oasis/dynamodb-odm, you can simple require it via `composer`: 10 | 11 | ```bash 12 | $ composer require oasis/dynamodb-odm 13 | ``` 14 | 15 | ### Class Loading 16 | 17 | Autoloading for DynamoDb ODM is taken care of by `composer`. You just need to include the composer autoload file in your project: 18 | 19 | ```php 20 | "oasis-minhao", 35 | "region" => "ap-northeast-1" 36 | ]; 37 | $tablePrefix = "odm-"; 38 | $cacheDir = __DIR__ . "/ut/cache"; 39 | $isDev = true; 40 | $itemNamespace = 'Oasis\Mlib\ODM\Dynamodb\Ut'; // in practice, this usually looks like: My\Root\Namespace\Items 41 | $itemSrcDir = __DIR__ . "/ut"; // in practice, this usually points to src/Items directory 42 | 43 | $im = new ItemManager( 44 | $awsConfig, 45 | $tablePrefix, 46 | $cacheDir, 47 | $isDev 48 | ); 49 | $im->addNamespace( 50 | $itemNamespace, 51 | $itemSrcDir 52 | ); 53 | 54 | ``` 55 | 56 | The explanation of each argument can be found below: 57 | 58 | argument | description | default value 59 | --- | --- | --- 60 | awsConfig | configuration array for aws SDK, `profile` and `region` are mandatory. | **mandatory** 61 | tablePrefix | a prefix to table names | **mandatory** 62 | cacheDir | cache direcotry to store metadata | **mandatory** 63 | isDev | is development environment or not. Under dev environment, changes to Item class will automatically invalidate cached metadata. Under production environment, this has to be done manually. | `true` 64 | itemSrcDir | a source directory under which Item classes can be found | **mandatory** 65 | itemNamespace | the base namespace for the managed Item classes source directory | **mandatory** 66 | 67 | > **NOTE**: an Item class defines a type of item managed by ODM. Some typical examples are: User, Order, GameRoom, and Card 68 | 69 | ### Setting Up Command Line Tool 70 | 71 | DynamoDb ODM ships with a number of command line tools that are very helpful during development. You can call this command from the Composer binary directory: 72 | 73 | ```bash 74 | $ ./vendor/bin/oasis-dynamodb-odm 75 | ``` 76 | 77 | You need to register your application's `ItemManager` to the console tool to make use of the built-in command. This is done by creating an **odm-config.php** file under the calling directory, with the following content: 78 | 79 | ```php 80 | **NOTE**: Check-and-set validation is done only when you call `ItemManger#flush()`. Failure to meet the check and set condition(s) will lead to an `Oasis\Mlib\ODM\Dynamodb\Exceptions\DataConsistencyException` being thrown. 217 | 218 | ## Working with Objects 219 | 220 | All objects (items) in ODM are managed. Operations on objects are managed like object-level transaction. Once an object is managed, either by persisted as a new object or fetched from database, its managed state is stored in the ItemManager. Any change to the object will be recorded in memory. Changes to object can then be commited by invoking the `ItemManager#flush()` method on the ItemManager. 221 | 222 | The ItemManager can be manually cleared by calling `ItemManager#clear()`. However, any changes that are not committed yet will be lost. 223 | 224 | > **NOTE**: it is very **important** to understand, that only `ItemManager#flush()` will cause write operations against the database. Any other methods such as `ItemManager#persist($item)` or `ItemManager#remove($item)` only notify the ItemManager to perform these operations during flush. Not calling `ItemManager#flush()` will lead to all changes during that request being lost. 225 | 226 | #### Persisting Item 227 | 228 | An item can be made persistent by passing it to the `ItemManager#persist($item)` method. By applying the persist operation on some item, that item becomes **MANAGED**, which means that its persistence is from now on managed by an ItemManager. As a result the persistent state of such an item will subsequently be properly synchronized with the database when `ItemManager#flush()` is invoked. 229 | 230 | Example: 231 | 232 | ```php 233 | setName('Mr.Right'); 237 | $im->persist($user); 238 | $im->flush(); 239 | 240 | ``` 241 | 242 | #### Removing Item 243 | 244 | An item can be removed from persistent storage by passing it to the `ItemManager#remove($item)` method. By applying the remove operation on some item, that item becomes **REMOVED**, which means that its persistent state will be deleted once `ItemManager#flush()` is invoked. 245 | 246 | Example: 247 | 248 | ```php 249 | remove($user); 253 | $im->flush(); 254 | 255 | ``` 256 | 257 | #### Detaching Item 258 | 259 | An item is detached from an ItemManager and thus no longer managed by invoking the `ItemManager#detach($item)` method on it. Changes made to the detached item, if any (including removal of the item), will not be synchronized to the database after the item has been detached. 260 | 261 | DynamoDb ODM will not hold on to any references to a detached item. 262 | 263 | 264 | Example: 265 | 266 | ```php 267 | detach($user); 271 | $user->setName('Mr.Left'); 272 | $im->flush(); // changes to $user will not be synchronized 273 | 274 | ``` 275 | 276 | #### Synchronization with the Database 277 | 278 | The state of persistent items is synchronized with the database on `flush()` of an ItemManager. The synchronization involves writing any updates to persistent items to the database. When `ItemManager#flush()` is called, ODM inspects all managed, new and removed items and will perform the following operations: 279 | 280 | - create new object in database 281 | - update changed attributes for managed items in database 282 | - delete removed item from database 283 | 284 | ## Fetching Item(s) 285 | 286 | DynamoDb ODM provides the following ways, in increasing level of power and flexibility, to fetch persistent object(s). You should always start with the simplest one that suits your needs. 287 | 288 | #### By Primary Index 289 | 290 | The most basic way to fetch a persistent object is by its primary index using the `ItemManager#get($itemClass, $primayKeys)` method. Here is an example: 291 | 292 | ```php 293 | get(User::class, ["id" => 1]); 296 | 297 | ``` 298 | 299 | The return value is either the found item instance or null if no instance could be found with the given identifier. 300 | 301 | Essentially, `ItemManager#get()` is just a shortcut for the following: 302 | 303 | ```php 304 | /** @var ItemManager $im */ 305 | /** @var ItemRepository $userRepo */ 306 | $userRepo = $im->getRepository(User::class); 307 | $user = $userRepo->get(["id" => 1]); 308 | 309 | ``` 310 | 311 | #### By Simple Conditions on Queriable Index 312 | 313 | To query for one or more items based on simple conditions, use the `ItemManager#query()` and `ItemManager#queryAndRun()` methods on a repository as follows: 314 | 315 | ```php 316 | /** @var ItemManager $im */ 317 | /** @var ItemRepository $userRepo */ 318 | $userRepo = $im->getRepository(User::class); 319 | /** @var Users[] $users */ 320 | $users = $userRepo->query( 321 | "#class = :class AND #age >= :minAge", 322 | [ 323 | ":class" => "A", 324 | ":minAge" => 25, 325 | ], 326 | "class-age-index" 327 | ); 328 | 329 | ``` 330 | 331 | > **NOTE**: a simple condition is a condition that uses one and only one index. If the used index contains both _hash key_ and _range key_, the _range key_ may only be used when _hash key_ is also presented in the condition. Furthermore, only equal test operation can be performed against the _hash key_. 332 | 333 | #### By Using Multi Query on Partitioned Hash Key 334 | 335 | To query for one or more items based on a PartitionedHashKey, use `ItemManager#multiQueryAndRun()` methods on a repository as follows: 336 | 337 | ```php 338 | /** @var ItemManager $im */ 339 | /** @var ItemRepository $userRepo */ 340 | $userRepo = $im->getRepository(User::class); 341 | /** @var Users[] $users */ 342 | $users = $userRepo->multiQueryAndRun( 343 | function ($item) { 344 | // each item returned can be accessed here in the callback 345 | }, 346 | "classPartition", // PartitionedHashKey field name 347 | "A", // value expected in the base field (not the partition field) 348 | "#age >= :minAge", // only range conditions here 349 | [ 350 | ":minAge" => 25, 351 | ], 352 | "class-partition-age-index" // index for PartitionedHashKey 353 | ); 354 | 355 | ``` 356 | 357 | #### By Filters on Non-Queriable Index 358 | 359 | To query for one or more items which has no associated index, use the `ItemManager#scan()` and `ItemManager#scanAndRun()` methods on a repository as follows: 360 | 361 | ```php 362 | /** @var ItemManager $im */ 363 | /** @var ItemRepository $userRepo */ 364 | $userRepo = $im->getRepository(User::class); 365 | /** @var Users[] $users */ 366 | $users = $userRepo->scan( 367 | "#class = :class AND #age >= :minAge AND #name = :name", 368 | [ 369 | ":class" => "A", 370 | ":minAge" => 25, 371 | ":name" => "John", 372 | ], 373 | ); 374 | 375 | ``` 376 | 377 | ## Using the Command Line Tool 378 | 379 | DynamoDb ODM ships an executable tool together with the library. After installation, there are following built-commands which helps you manage the database schema for the items: 380 | 381 | #### Create 382 | 383 | ```bash 384 | $ ./vendor/bin/oasis-dynamodb-odm odm:schema-tool:create 385 | ``` 386 | 387 | The create command will iterate all managed items and create tables correspondingly. All primary index, LSIs and GSIs are created as well. 388 | 389 | > **NOTE**: if a table with the same name under the same prefix already exists, an exception will be thrown. No table will be created in this case. 390 | 391 | > **NOTE**: if you would like to skip creating existing table (i.e. only create non-existing tables), you can use the "--skip-existing-table" option 392 | 393 | #### Update 394 | 395 | ```bash 396 | $ ./vendor/bin/oasis-dynamodb-odm odm:schema-tool:update 397 | ``` 398 | 399 | The update command is actually a more powerful (and slower too) version of create command. It checks all managed items and creates the table if it doesn't exist. Furthermore, if a table exists but have different GSIs defined, the update command will update the GSIs accordingly. 400 | 401 | > **NOTE**: due to the nature of DynamoDb, it is not possible to update the primary index or LSI when a table is already created. Under dev environment, it is suggested to drop the table and re-create them when needed. 402 | 403 | > **NOTE**: if you would like to only see the changes to database schemas without perfoming actual update, you can specify the "--dry-run" option in command line. The program will only prompts possible changes withou actually performing them. 404 | 405 | #### Drop 406 | 407 | ```bash 408 | $ ./vendor/bin/oasis-dynamodb-odm odm:schema-tool:drop 409 | ``` 410 | 411 | The drop command will drop all tables associated with the managed items. **DO NOT** run this command in production environment! 412 | -------------------------------------------------------------------------------- /ut/ItemManagerTest.php: -------------------------------------------------------------------------------- 1 | itemManager = new ItemManager( 29 | UTConfig::$dynamodbConfig, UTConfig::$tablePrefix, __DIR__ . "/cache", true 30 | ); 31 | $this->itemManager2 = new ItemManager( 32 | new DynamoDbConnection(UTConfig::$dynamodbConfig), UTConfig::$tablePrefix, __DIR__ . "/cache", true 33 | ); 34 | } 35 | 36 | public function testPersistAndGet() 37 | { 38 | $id = mt_rand(1000, PHP_INT_MAX); 39 | $user = new User(); 40 | $user->setId($id); 41 | $user->setName('Alice'); 42 | $this->itemManager->persist($user); 43 | $this->itemManager->flush(); 44 | 45 | /** @var User $user2 */ 46 | $user2 = $this->itemManager->get(User::class, ['id' => $id]); 47 | 48 | $this->assertEquals($user, $user2); // user object will be reused when same primary keys are used 49 | $this->assertEquals('Alice', $user2->getName()); 50 | 51 | return $id; 52 | } 53 | 54 | /** 55 | * @depends testPersistAndGet 56 | * 57 | * @param $id 58 | */ 59 | public function testDoublePersist($id) 60 | { 61 | $id2 = $id + 1; 62 | $user = new User(); 63 | $user->setId($id2); 64 | $user->setName('Howard'); 65 | $this->itemManager->persist($user); 66 | 67 | /** @var User $user2 */ 68 | $user2 = $this->itemManager->get(User::class, ['id' => $id2]); 69 | 70 | $this->assertEquals($user, $user2); // user object will be reused when same primary keys are used 71 | $this->assertEquals('Howard', $user2->getName()); 72 | 73 | /** @var User $user3 */ 74 | $user3 = $this->itemManager->get(User::class, ['id' => $id2], true); 75 | 76 | $this->assertNull($user3); 77 | } 78 | 79 | /** 80 | * @depends testPersistAndGet 81 | * 82 | * @param $id 83 | * @return string 84 | */ 85 | public function testEdit($id) 86 | { 87 | /** @var User $user */ 88 | $user = $this->itemManager->get(User::class, ['id' => $id]); 89 | $this->assertInstanceOf(User::class, $user); 90 | $this->assertNotEquals('John', $user->getName()); 91 | $user->setName('John'); 92 | $user->haha = 22; 93 | $this->itemManager->flush(); 94 | 95 | $this->itemManager->clear(); 96 | /** @var User $user2 */ 97 | $user2 = $this->itemManager->get(User::class, ['id' => $id]); 98 | 99 | $this->assertInstanceOf(User::class, $user2); 100 | $this->assertTrue($user !== $user2); 101 | $this->assertEquals('John', $user2->getName()); 102 | 103 | return $id; 104 | } 105 | 106 | /** 107 | * @depends testEdit 108 | * 109 | * @param $id 110 | */ 111 | public function testCASEnabled($id) 112 | { 113 | /** @var User $user */ 114 | $user = $this->itemManager->get(User::class, ['id' => $id]); 115 | /** @var User $user2 */ 116 | $user2 = $this->itemManager2->get(User::class, ['id' => $id]); 117 | 118 | $user->setName('Chris'); 119 | $this->itemManager->flush(); 120 | 121 | $user2->setName('Michael'); 122 | self::expectException(DataConsistencyException::class); 123 | $this->itemManager2->flush(); 124 | } 125 | 126 | /** 127 | * @depends testEdit 128 | * 129 | * @param $id 130 | * @return string 131 | */ 132 | public function testCASTimestamp($id) 133 | { 134 | /** @var User $user */ 135 | $user = $this->itemManager->get(User::class, ['id' => $id]); 136 | sleep(1); 137 | $user->setWage(777); 138 | $time = time(); 139 | $this->itemManager->flush(); 140 | $this->itemManager->clear(); 141 | 142 | $user = $this->itemManager->get(User::class, ['id' => $id]); 143 | $lastUpdated = $user->getLastUpdated(); 144 | $this->assertLessThanOrEqual(1, abs($lastUpdated - $time)); 145 | 146 | return $id; 147 | } 148 | 149 | /** 150 | * @depends testCASTimestamp 151 | * 152 | * @param $id 153 | */ 154 | public function testCreatingInconsistentData($id) 155 | { 156 | /** @var User $user */ 157 | $user = $this->itemManager->get(User::class, ['id' => $id]); 158 | $this->itemManager->clear(); 159 | 160 | //$user->setLastUpdated(0); 161 | //$user->setWage(999); 162 | $this->itemManager->persist($user); 163 | 164 | self::expectException(DataConsistencyException::class); 165 | $this->itemManager->flush(); 166 | 167 | } 168 | 169 | /** 170 | * @depends testCASTimestamp 171 | * 172 | * @param $id 173 | */ 174 | public function testUpdatingInconsistentDataWhenUsingCASTimestamp($id) 175 | { 176 | /** @var User $user */ 177 | $user = $this->itemManager->get(User::class, ['id' => $id]); 178 | /** @var User $user2 */ 179 | $user2 = $this->itemManager2->get(User::class, ['id' => $id]); 180 | 181 | //$user->setLastUpdated(time() + 10); 182 | sleep(1); 183 | $user->setAlias('emperor'); 184 | $this->itemManager->flush(); 185 | $user2->setWage(999); 186 | 187 | self::expectException(DataConsistencyException::class); 188 | $this->itemManager2->flush(); 189 | 190 | } 191 | 192 | /** 193 | * @depends testCASTimestamp 194 | * 195 | * @param $id 196 | */ 197 | public function testNoDoubleSetWhenFlushingTwice($id) 198 | { 199 | /** @var User $user */ 200 | $user = $this->itemManager->get(User::class, ['id' => $id]); 201 | $user->setAlias('pope'); 202 | $time = time(); 203 | $this->itemManager->flush(); 204 | sleep(2); 205 | $this->itemManager->flush(); 206 | $lastUpdated = $user->getLastUpdated(); 207 | $this->assertLessThanOrEqual(1, abs($lastUpdated - $time)); 208 | } 209 | 210 | /** 211 | * @depends testCASTimestamp 212 | */ 213 | public function testNoDoubleSetWhenInsertedAreFlushedTwice() 214 | { 215 | $id = mt_rand(1000, PHP_INT_MAX); 216 | $user = new User(); 217 | $user->setId($id); 218 | $user->setName('Alice'); 219 | $this->itemManager->persist($user); 220 | $time = time(); 221 | $this->itemManager->flush(); 222 | sleep(2); 223 | $this->itemManager->flush(); 224 | $lastUpdated = $user->getLastUpdated(); 225 | $this->assertLessThanOrEqual(1, abs($lastUpdated - $time)); 226 | 227 | $this->itemManager->remove($user); 228 | $this->itemManager->flush(); 229 | $this->itemManager->flush(); 230 | } 231 | 232 | /** 233 | * @depends testCASTimestamp 234 | * 235 | * @param $id 236 | * @return string 237 | */ 238 | public function testRefresh($id) 239 | { 240 | /** @var User $user */ 241 | $user = $this->itemManager->get(User::class, ['id' => $id], true); 242 | $user->setWage(888); 243 | $this->itemManager->refresh($user); 244 | 245 | $this->assertEquals(777, $user->getWage()); 246 | 247 | // unmanaged refresh will work when persist-if-not-managed is set to true 248 | $this->itemManager->clear(); 249 | $user = new User(); 250 | $user->setId($id); 251 | $user->setName('Mary'); 252 | $user->setWage(999); 253 | $this->itemManager->refresh($user, true); 254 | 255 | $this->assertEquals(777, $user->getWage()); 256 | $this->assertNotEquals('Mary', $user->getName()); 257 | 258 | // refreshing detached object works too 259 | $this->itemManager->detach($user); 260 | $user = new User(); 261 | $user->setId($id); 262 | $user->setName('Mary'); 263 | $user->setWage(999); 264 | $this->itemManager->refresh($user, true); 265 | 266 | $this->assertEquals(777, $user->getWage()); 267 | $this->assertNotEquals('Mary', $user->getName()); 268 | 269 | $this->itemManager->flush(); 270 | $this->itemManager->clear(); 271 | $user = $this->itemManager->get(User::class, ['id' => $id], true); 272 | $this->assertEquals(777, $user->getWage()); 273 | 274 | $user->setWage(888); 275 | $this->itemManager->flush(); 276 | $this->itemManager->clear(); 277 | $user = $this->itemManager->get(User::class, ['id' => $id], true); 278 | $this->assertEquals(888, $user->getWage()); 279 | 280 | $user->setWage(777); // restore to 777 for other tests 281 | $this->itemManager->flush(); 282 | 283 | return $id; 284 | } 285 | 286 | /** 287 | * @depends testRefresh 288 | * 289 | * @param $id 290 | */ 291 | public function testRefreshingJustPersistedObject($id) 292 | { 293 | $this->itemManager->clear(); 294 | $user = new User(); 295 | $user->setId($id); 296 | $user->setName('Mary'); 297 | $user->setWage(999); 298 | $this->itemManager->persist($user); 299 | $this->expectException(ODMException::class); 300 | $this->itemManager->refresh($user); 301 | } 302 | 303 | /** 304 | * @depends testRefresh 305 | * 306 | * @param $id 307 | */ 308 | public function testRefreshingJustRemovedObject($id) 309 | { 310 | $this->itemManager->clear(); 311 | $user = $this->itemManager->get(User::class, ['id' => $id], true); 312 | $this->itemManager->remove($user); 313 | $this->expectException(ODMException::class); 314 | $this->itemManager->refresh($user); 315 | } 316 | 317 | /** 318 | * @depends testRefresh 319 | * 320 | * @param $id 321 | * @return string 322 | */ 323 | public function testDetach($id) 324 | { 325 | /** @var User $user */ 326 | $user = $this->itemManager->get(User::class, ['id' => $id]); 327 | $user->setWage(888); 328 | $this->itemManager->detach($user); 329 | $this->itemManager->flush(); 330 | $this->itemManager->clear(); 331 | $user = $this->itemManager->get(User::class, ['id' => $id]); 332 | $this->assertEquals(777, $user->getWage()); 333 | 334 | return $id; 335 | } 336 | 337 | /** 338 | * @depends testDetach 339 | * 340 | * @param $id 341 | */ 342 | public function testDelete($id) 343 | { 344 | /** @var User $user */ 345 | $user = $this->itemManager->get(User::class, ['id' => $id]); 346 | $this->assertInstanceOf(User::class, $user); 347 | 348 | $this->itemManager->remove($user); 349 | $this->itemManager->flush(); 350 | 351 | $this->itemManager->clear(); 352 | /** @var User $user2 */ 353 | $user2 = $this->itemManager->get(User::class, ['id' => $id]); 354 | $this->assertNull($user2); 355 | 356 | $this->itemManager->persist($user); 357 | $this->itemManager->flush(); 358 | $user2 = $this->itemManager->get(User::class, ['id' => $id]); 359 | $this->assertTrue($user2 instanceof User); 360 | $this->itemManager->getRepository(User::class)->removeById(['id' => $id]); 361 | $this->itemManager->flush(); 362 | $this->itemManager->clear(); 363 | $user2 = $this->itemManager->get(User::class, ['id' => $id]); 364 | $this->assertNull($user2); 365 | 366 | } 367 | 368 | public function testQueryAndScan() 369 | { 370 | $base = mt_rand(100, PHP_INT_MAX); 371 | 372 | $users = []; 373 | for ($i = 0; $i < 10; ++$i) { 374 | $id = $base + $i; 375 | $user = new User(); 376 | $user->setId($id); 377 | $user->setName('Batch #' . ($i + 1)); 378 | $user->setHometown(((($i % 2) == 0) ? 'LA' : 'NY') . $base); 379 | $user->setAge(46 + $i); // 46 to 55 380 | $user->setWage(12345); 381 | $users[] = $user; 382 | $this->itemManager->persist($user); 383 | } 384 | 385 | $this->itemManager->flush(); 386 | $this->itemManager->clear(); 387 | 388 | $count = $this->itemManager->getRepository(User::class)->queryCount( 389 | '#hometown = :hometown AND #age > :age', 390 | [':hometown' => 'NY' . $base, ':age' => 45], 391 | 'hometown-age-index' 392 | ); 393 | $this->assertEquals(5, $count); 394 | $result = $this->itemManager->getRepository(User::class)->queryAll( 395 | '#hometown = :hometown AND #age > :age', 396 | [':hometown' => 'NY' . $base, ':age' => 45], 397 | 'hometown-age-index' 398 | ); 399 | $this->assertEquals(5, count($result)); 400 | 401 | $count = $this->itemManager->getRepository(User::class)->multiQueryCount( 402 | "hometownPartition", 403 | "NY" . $base, 404 | "#age > :age", 405 | [":age" => 48], 406 | "home-age-gsi" 407 | ); 408 | $this->assertEquals(4, $count); 409 | 410 | $result = []; 411 | $this->itemManager->getRepository(User::class)->multiQueryAndRun( 412 | function ($item) use (&$result) { 413 | $result[] = $item; 414 | }, 415 | "hometownPartition", 416 | "NY" . $base, 417 | "#age > :age", 418 | [":age" => 48], 419 | "home-age-gsi" 420 | ); 421 | $this->assertEquals(4, count($result)); 422 | 423 | // remove all inserted users 424 | $count = $this->itemManager->getRepository(User::class)->scanCount( 425 | '#wage = :wage AND #id BETWEEN :idmin AND :idmax ', 426 | [ 427 | ':wage' => 12345, 428 | ':idmin' => $base, 429 | ':idmax' => $base + 10, 430 | ] 431 | ); 432 | $this->assertEquals(10, $count); 433 | $count = 0; 434 | $this->itemManager->getRepository(User::class)->scanAndRun( 435 | function (User $user) use (&$count) { 436 | $count++; 437 | $this->itemManager->remove($user); 438 | }, 439 | '#wage = :wage AND #id BETWEEN :idmin AND :idmax ', 440 | [ 441 | ':wage' => 12345, 442 | ':idmin' => $base, 443 | ':idmax' => $base + 10, 444 | ], 445 | DynamoDbIndex::PRIMARY_INDEX, 446 | false, 447 | true, 448 | 5 449 | ); 450 | $this->assertEquals(10, $count); 451 | 452 | $this->itemManager->flush(); 453 | } 454 | 455 | public function testBatchNewWithCASDisabled() 456 | { 457 | $base = mt_rand(100, PHP_INT_MAX); 458 | 459 | /** @var User[] $users */ 460 | $users = []; 461 | $keys = []; 462 | for ($i = 0; $i < 10; ++$i) { 463 | $id = $base + $i; 464 | $user = new User(); 465 | $user->setId($id); 466 | $user->setName('Batch #' . ($i + 1)); 467 | $user->setHometown(((($i % 2) == 0) ? 'LA' : 'NY') . $base); 468 | $user->setAge(46 + $i); // 46 to 55 469 | $user->setWage(12345); 470 | $users[$id] = $user; 471 | $this->itemManager->persist($user); 472 | 473 | $keys[] = ["id" => $id]; 474 | } 475 | 476 | $this->itemManager->setSkipCheckAndSet(true); 477 | $this->itemManager->flush(); 478 | $this->itemManager->setSkipCheckAndSet(false); 479 | 480 | return $users; 481 | } 482 | 483 | /** 484 | * @depends testBatchNewWithCASDisabled 485 | * 486 | * @param User[] $users 487 | */ 488 | public function testBatchGet($users) 489 | { 490 | $keys[] = ["id" => -PHP_INT_MAX,]; // some non existing key 491 | $result = $this->itemManager->getRepository(User::class)->batchGet($keys); 492 | $this->assertEquals(count($keys), count($result) + 1); // we get all result except the non-existing one 493 | /** @var User $user */ 494 | foreach ($result as $user) { 495 | $this->assertArrayHasKey($user->getId(), $users); 496 | $this->assertEquals($users[$user->getId()]->getName(), $user->getName()); 497 | } 498 | } 499 | 500 | /** 501 | * @depends testBatchGet 502 | */ 503 | public function testRemoveAll() 504 | { 505 | $this->itemManager->getRepository(User::class)->removeAll(); 506 | $remaining = $this->itemManager->getRepository(User::class)->scanAll( 507 | '', 508 | [], 509 | DynamoDbIndex::PRIMARY_INDEX, 510 | true 511 | ); 512 | $this->assertTrue($remaining->isEmpty(), json_encode($remaining)); 513 | } 514 | 515 | public function testGetWithAttributeKey() 516 | { 517 | self::expectException(ODMException::class); 518 | $this->itemManager->get(User::class, ['uid' => 10]); 519 | } 520 | 521 | public function testQueryWithAttributeKey() 522 | { 523 | self::expectException(ODMException::class); 524 | $this->itemManager->getRepository(User::class) 525 | ->query( 526 | '#hometown = :hometown AND #salary > :wage', 527 | [':hometown' => 'NY', ':wage' => 100], 528 | 'hometown-salary-index' 529 | ); 530 | } 531 | 532 | public function testScanWithAttributeKey() 533 | { 534 | self::expectException(ODMException::class); 535 | $this->itemManager->getRepository(User::class) 536 | ->scan( 537 | '#hometown = :hometown AND #salary > :wage', 538 | [':hometown' => 'NY', ':wage' => 100] 539 | ); 540 | } 541 | 542 | public function testUnmanagedRemove() 543 | { 544 | $user = new User(); 545 | self::expectException(ODMException::class); 546 | $this->itemManager->remove($user); 547 | } 548 | 549 | public function testUnmanagedRefresh() 550 | { 551 | $user = new User(); 552 | self::expectException(ODMException::class); 553 | $this->itemManager->refresh($user); 554 | } 555 | 556 | public function testUnmanagedDetach() 557 | { 558 | $user = new User(); 559 | self::expectException(ODMException::class); 560 | $this->itemManager->detach($user); 561 | } 562 | 563 | public function testMapAndListData() 564 | { 565 | $game = new ConsoleGame(); 566 | $game->setGamecode('ps4koi-' . time()); 567 | $game->setFamily('ps4'); 568 | $game->setLanguage('en'); 569 | $game->setAchievements( 570 | [ 571 | "all" => 10, 572 | "hello" => 30, 573 | "deep" => [ 574 | "a" => "xyz", 575 | "b" => "jjk", 576 | ], 577 | ] 578 | ); 579 | $game->setAuthors( 580 | [ 581 | "james", 582 | "curry", 583 | "love", 584 | ] 585 | ); 586 | $this->itemManager->persist($game); 587 | $this->itemManager->flush(); 588 | 589 | $game->setAuthors( 590 | [ 591 | "durant", 592 | "green", 593 | ] 594 | ); 595 | $this->itemManager->flush(); 596 | 597 | $game->setAchievements( 598 | [ 599 | "all" => 10, 600 | "hello" => 30, 601 | "deep" => [ 602 | "a" => "xyz", 603 | //"b" => "jjk", 604 | ], 605 | ] 606 | ); 607 | $this->itemManager->flush(); 608 | } 609 | 610 | public function testProjectedData() 611 | { 612 | $this->itemManager->getRepository(Game::class)->removeAll(); 613 | 614 | $game = new Game(); 615 | $game->setGamecode('narutofr'); 616 | $game->setFamily('naruto'); 617 | $game->setLanguage('fr'); 618 | $this->itemManager->persist($game); 619 | $this->itemManager->flush(); 620 | 621 | /** @var BasicGameInfo $basicInfo */ 622 | $basicInfo = $this->itemManager->getRepository(BasicGameInfo::class)->get(['gamecode' => 'narutofr']); 623 | $this->assertTrue($basicInfo instanceof BasicGameInfo); 624 | 625 | $basicInfo->setFamily('helll'); 626 | $this->expectException(ODMException::class); 627 | $this->itemManager->flush(); 628 | } 629 | } 630 | -------------------------------------------------------------------------------- /src/ItemRepository.php: -------------------------------------------------------------------------------- 1 | itemManager = $itemManager; 42 | $this->itemReflection = $itemReflection; 43 | 44 | // initialize database connection 45 | $tableName = $itemManager->getDefaultTablePrefix().$itemReflection->getTableName(); 46 | $this->dbConnection = $itemManager->createDBConnection(); 47 | $this->dbConnection->setTableName($tableName); 48 | $this->dbConnection->setAttributeTypes($itemReflection->getAttributeTypes()); 49 | $this->dbConnection->setItemReflection($itemReflection); 50 | } 51 | 52 | public function batchGet($groupOfKeys, $isConsistentRead = false) 53 | { 54 | /** @var string[] $fieldNameMapping */ 55 | $fieldNameMapping = $this->itemReflection->getFieldNameMapping(); 56 | $groupOfTranslatedKeys = []; 57 | foreach ($groupOfKeys as $keys) { 58 | $translatedKeys = []; 59 | foreach ($keys as $k => $v) { 60 | if (!isset($fieldNameMapping[$k])) { 61 | throw new ODMException("Cannot find primary index field: $k!"); 62 | } 63 | $k = $fieldNameMapping[$k]; 64 | $translatedKeys[$k] = $v; 65 | } 66 | $groupOfTranslatedKeys[] = $translatedKeys; 67 | } 68 | $resultSet = $this->dbConnection->batchGet( 69 | $groupOfTranslatedKeys, 70 | $isConsistentRead, 71 | 10, 72 | $this->itemReflection->getProjectedAttributes() 73 | ); 74 | if (is_array($resultSet)) { 75 | $ret = []; 76 | foreach ($resultSet as $singleResult) { 77 | $obj = $this->persistFetchedItemData($singleResult); 78 | $ret[] = $obj; 79 | } 80 | 81 | return $ret; 82 | } 83 | else { 84 | throw new UnderlyingDatabaseException("Result returned from database for BatchGet() is not an array!"); 85 | } 86 | } 87 | 88 | public function clear() 89 | { 90 | $this->itemManaged = []; 91 | } 92 | 93 | public function detach($obj) 94 | { 95 | if (!$this->itemReflection->getReflectionClass()->isInstance($obj)) { 96 | throw new ODMException( 97 | "Object detached is not of correct type, expected: ".$this->itemReflection->getItemClass() 98 | ); 99 | } 100 | $id = $this->itemReflection->getPrimaryIdentifier($obj); 101 | if (!isset($this->itemManaged[$id])) { 102 | throw new ODMException("Object is not managed: ".print_r($obj, true)); 103 | } 104 | 105 | unset($this->itemManaged[$id]); 106 | } 107 | 108 | public function flush() 109 | { 110 | $skipCAS = $this->itemManager->shouldSkipCheckAndSet() 111 | || (count($this->itemReflection->getCasProperties()) == 0); 112 | $removed = []; 113 | $batchRemovalKeys = []; 114 | $batchSetItems = []; 115 | $batchNewItemStates = new SplStack(); 116 | $batchUpdateItemStates = new SplStack(); 117 | foreach ($this->itemManaged as $oid => $managedItemState) { 118 | $item = $managedItemState->getItem(); 119 | if ($managedItemState->isRemoved()) { 120 | $batchRemovalKeys[] = $this->itemReflection->getPrimaryKeys($item); 121 | $removed[] = $oid; 122 | } 123 | elseif ($managedItemState->isNew()) { 124 | if ($this->itemReflection->getItemDefinition()->projected) { 125 | throw new ODMException( 126 | sprintf( 127 | "Not possible to create a projected item of type %s, try create the full-featured item instead!", 128 | $this->itemReflection->getItemClass() 129 | ) 130 | ); 131 | } 132 | 133 | $managedItemState->updateCASTimestamps(); 134 | $managedItemState->updatePartitionedHashKeys(); 135 | 136 | if ($skipCAS) { 137 | $batchSetItems[] = $this->itemReflection->dehydrate($item); 138 | $batchNewItemStates->push($managedItemState); 139 | } 140 | else { 141 | $ret = $this->dbConnection->set( 142 | $this->itemReflection->dehydrate($item), 143 | $managedItemState->getCheckConditionData() 144 | ); 145 | if ($ret === false) { 146 | throw new DataConsistencyException( 147 | "Item exists! type = ".$this->itemReflection->getItemClass() 148 | ); 149 | } 150 | $managedItemState->setState(ManagedItemState::STATE_MANAGED); 151 | $managedItemState->setUpdated(); 152 | } 153 | } 154 | else { 155 | $hasData = $managedItemState->hasDirtyData(); 156 | if ($hasData) { 157 | if ($this->itemReflection->getItemDefinition()->projected) { 158 | throw new ODMException( 159 | sprintf( 160 | "Not possible to update a projected item of type %s, try updating the full-featured item instead!" 161 | ." You could also detach the modified item to bypass this exception!", 162 | $this->itemReflection->getItemClass() 163 | ) 164 | ); 165 | } 166 | 167 | $managedItemState->updateCASTimestamps(); 168 | $managedItemState->updatePartitionedHashKeys(); 169 | if ($skipCAS) { 170 | $batchSetItems[] = $this->itemReflection->dehydrate($item); 171 | $batchUpdateItemStates->push($managedItemState); 172 | } 173 | else { 174 | $ret = $this->dbConnection->set( 175 | $this->itemReflection->dehydrate($item), 176 | $managedItemState->getCheckConditionData() 177 | ); 178 | if (!$ret) { 179 | throw new DataConsistencyException( 180 | "Item updated elsewhere! type = ".$this->itemReflection->getItemClass() 181 | ); 182 | } 183 | $managedItemState->setUpdated(); 184 | } 185 | } 186 | } 187 | } 188 | if ($batchRemovalKeys) { 189 | $this->dbConnection->batchDelete($batchRemovalKeys); 190 | } 191 | if ($batchSetItems) { 192 | $this->dbConnection->batchPut($batchSetItems); 193 | } 194 | /** @var ManagedItemState $managedItemState */ 195 | foreach ($batchNewItemStates as $managedItemState) { 196 | $managedItemState->setState(ManagedItemState::STATE_MANAGED); 197 | $managedItemState->setUpdated(); 198 | } 199 | foreach ($batchUpdateItemStates as $managedItemState) { 200 | $managedItemState->setState(ManagedItemState::STATE_MANAGED); 201 | $managedItemState->setUpdated(); 202 | } 203 | foreach ($removed as $id) { 204 | unset($this->itemManaged[$id]); 205 | } 206 | } 207 | 208 | public function get($keys, $isConsistentRead = false) 209 | { 210 | /** @var string[] $fieldNameMapping */ 211 | $fieldNameMapping = $this->itemReflection->getFieldNameMapping(); 212 | $translatedKeys = []; 213 | foreach ($keys as $k => $v) { 214 | if (!isset($fieldNameMapping[$k])) { 215 | throw new ODMException("Cannot find primary index field: $k!"); 216 | } 217 | $k = $fieldNameMapping[$k]; 218 | $translatedKeys[$k] = $v; 219 | } 220 | 221 | // return existing item 222 | if (!$isConsistentRead) { 223 | $id = $this->itemReflection->getPrimaryIdentifier($translatedKeys); 224 | if (isset($this->itemManaged[$id])) { 225 | return $this->itemManaged[$id]->getItem(); 226 | } 227 | } 228 | 229 | $result = $this->dbConnection->get( 230 | $translatedKeys, 231 | $isConsistentRead, 232 | $this->itemReflection->getProjectedAttributes() 233 | ); 234 | if (is_array($result)) { 235 | return $this->persistFetchedItemData($result); 236 | } 237 | elseif ($result === null) { 238 | return null; 239 | } 240 | else { 241 | throw new UnderlyingDatabaseException("Result returned from database is not an array!"); 242 | } 243 | } 244 | 245 | public function multiQueryAndRun( 246 | callable $callback, 247 | $hashKey, 248 | $hashKeyValues, 249 | $rangeConditions, 250 | array $params, 251 | $indexName, 252 | $filterExpression = '', 253 | $evaluationLimit = 30, 254 | $isConsistentRead = false, 255 | $isAscendingOrder = true, 256 | $concurrency = 10 257 | ) { 258 | if (!is_array($hashKeyValues)) { 259 | $hashKeyValues = [$hashKeyValues]; 260 | } 261 | $partitionedHashKeyValues = []; 262 | foreach ($hashKeyValues as $hashKeyValue) { 263 | $partitionedHashKeyValues = array_merge( 264 | $partitionedHashKeyValues, 265 | $this->itemReflection->getAllPartitionedValues($hashKey, $hashKeyValue) 266 | ); 267 | } 268 | $fields = array_merge($this->getFieldsArray($rangeConditions), $this->getFieldsArray($filterExpression)); 269 | $this->dbConnection->multiQueryAndRun( 270 | function ($result) use ($callback) { 271 | $obj = $this->persistFetchedItemData($result); 272 | 273 | return call_user_func($callback, $obj); 274 | }, 275 | $hashKey, 276 | $partitionedHashKeyValues, 277 | $rangeConditions, 278 | $fields, 279 | $params, 280 | $indexName, 281 | $filterExpression, 282 | $evaluationLimit, 283 | $isConsistentRead, 284 | $isAscendingOrder, 285 | $concurrency, 286 | $this->itemReflection->getProjectedAttributes() 287 | ); 288 | } 289 | 290 | public function multiQueryCount( 291 | $hashKey, 292 | $hashKeyValues, 293 | $rangeConditions, 294 | array $params, 295 | $indexName, 296 | $filterExpression = '', 297 | $isConsistentRead = false, 298 | $concurrency = 10 299 | ) { 300 | if (!is_array($hashKeyValues)) { 301 | $hashKeyValues = [$hashKeyValues]; 302 | } 303 | $partitionedHashKeyValues = []; 304 | foreach ($hashKeyValues as $hashKeyValue) { 305 | $partitionedHashKeyValues = array_merge( 306 | $partitionedHashKeyValues, 307 | $this->itemReflection->getAllPartitionedValues($hashKey, $hashKeyValue) 308 | ); 309 | } 310 | $fields = array_merge($this->getFieldsArray($rangeConditions), $this->getFieldsArray($filterExpression)); 311 | $count = 0; 312 | $this->dbConnection->multiQueryAndRun( 313 | function () use (&$count) { 314 | $count++; 315 | }, 316 | $hashKey, 317 | $partitionedHashKeyValues, 318 | $rangeConditions, 319 | $fields, 320 | $params, 321 | $indexName, 322 | $filterExpression, 323 | 10000, 324 | $isConsistentRead, 325 | true, 326 | $concurrency, 327 | $this->itemReflection->getProjectedAttributes() 328 | ); 329 | 330 | return $count; 331 | } 332 | 333 | public function parallelScanAndRun( 334 | $parallel, 335 | callable $callback, 336 | $conditions = '', 337 | array $params = [], 338 | $indexName = DynamoDbIndex::PRIMARY_INDEX, 339 | $isConsistentRead = false, 340 | $isAscendingOrder = true 341 | ) { 342 | $fields = $this->getFieldsArray($conditions); 343 | $this->dbConnection->parallelScanAndRun( 344 | $parallel, 345 | function ($result) use ($callback) { 346 | $obj = $this->persistFetchedItemData($result); 347 | 348 | return call_user_func($callback, $obj); 349 | }, 350 | $conditions, 351 | $fields, 352 | $params, 353 | $indexName, 354 | $isConsistentRead, 355 | $isAscendingOrder, 356 | $this->itemReflection->getProjectedAttributes() 357 | ); 358 | } 359 | 360 | public function persist($obj) 361 | { 362 | if (!$this->itemReflection->getReflectionClass()->isInstance($obj)) { 363 | throw new ODMException("Persisting wrong object, expecting: ".$this->itemReflection->getItemClass()); 364 | } 365 | $id = $this->itemReflection->getPrimaryIdentifier($obj); 366 | if (isset($this->itemManaged[$id])) { 367 | throw new ODMException("Persisting existing object: ".print_r($obj, true)); 368 | } 369 | 370 | $managedState = new ManagedItemState($this->itemReflection, $obj); 371 | $managedState->setState(ManagedItemState::STATE_NEW); 372 | $this->itemManaged[$id] = $managedState; 373 | } 374 | 375 | public function query( 376 | $conditions, 377 | array $params, 378 | $indexName = DynamoDbIndex::PRIMARY_INDEX, 379 | $filterExpression = '', 380 | &$lastKey = null, 381 | $evaluationLimit = 30, 382 | $isConsistentRead = false, 383 | $isAscendingOrder = true 384 | ) { 385 | $fields = array_merge($this->getFieldsArray($conditions), $this->getFieldsArray($filterExpression)); 386 | $results = $this->dbConnection->query( 387 | $conditions, 388 | $fields, 389 | $params, 390 | $indexName, 391 | $filterExpression, 392 | $lastKey, 393 | $evaluationLimit, 394 | $isConsistentRead, 395 | $isAscendingOrder, 396 | $this->itemReflection->getProjectedAttributes() 397 | ); 398 | $ret = []; 399 | foreach ($results as $result) { 400 | $obj = $this->persistFetchedItemData($result); 401 | $ret[] = $obj; 402 | } 403 | 404 | return $ret; 405 | } 406 | 407 | /** 408 | * @param string $conditions 409 | * @param array $params 410 | * @param bool $indexName 411 | * @param string $filterExpression 412 | * @param bool $isConsistentRead 413 | * @param bool $isAscendingOrder 414 | * 415 | * @return SplDoublyLinkedList 416 | * 417 | */ 418 | public function queryAll( 419 | $conditions = '', 420 | array $params = [], 421 | $indexName = DynamoDbIndex::PRIMARY_INDEX, 422 | $filterExpression = '', 423 | $isConsistentRead = false, 424 | $isAscendingOrder = true 425 | ) { 426 | $ret = new SplDoublyLinkedList(); 427 | $this->queryAndRun( 428 | function ($item) use ($ret) { 429 | $ret->push($item); 430 | }, 431 | $conditions, 432 | $params, 433 | $indexName, 434 | $filterExpression, 435 | $isConsistentRead, 436 | $isAscendingOrder 437 | ); 438 | 439 | return $ret; 440 | } 441 | 442 | public function queryAndRun( 443 | callable $callback, 444 | $conditions = '', 445 | array $params = [], 446 | $indexName = DynamoDbIndex::PRIMARY_INDEX, 447 | $filterExpression = '', 448 | $isConsistentRead = false, 449 | $isAscendingOrder = true 450 | ) { 451 | $fields = array_merge($this->getFieldsArray($conditions), $this->getFieldsArray($filterExpression)); 452 | $this->dbConnection->queryAndRun( 453 | function ($result) use ($callback) { 454 | $obj = $this->persistFetchedItemData($result); 455 | 456 | return call_user_func($callback, $obj); 457 | }, 458 | $conditions, 459 | $fields, 460 | $params, 461 | $indexName, 462 | $filterExpression, 463 | $isConsistentRead, 464 | $isAscendingOrder, 465 | $this->itemReflection->getProjectedAttributes() 466 | ); 467 | } 468 | 469 | public function queryCount( 470 | $conditions, 471 | array $params, 472 | $indexName = DynamoDbIndex::PRIMARY_INDEX, 473 | $filterExpression = '', 474 | $isConsistentRead = false 475 | ) { 476 | $fields = array_merge($this->getFieldsArray($conditions), $this->getFieldsArray($filterExpression)); 477 | 478 | return $this->dbConnection->queryCount( 479 | $conditions, 480 | $fields, 481 | $params, 482 | $indexName, 483 | $filterExpression, 484 | $isConsistentRead 485 | ); 486 | } 487 | 488 | public function refresh($obj, $persistIfNotManaged = false) 489 | { 490 | if (!$this->itemReflection->getReflectionClass()->isInstance($obj)) { 491 | throw new ODMException( 492 | "Object refreshed is not of correct type, expected: ".$this->itemReflection->getItemClass() 493 | ); 494 | } 495 | 496 | // 2017-03-24: we can refresh something that's not managed 497 | //$id = $this->itemReflection->getPrimaryIdentifier($obj); 498 | //if (!isset($this->itemManaged[$id])) { 499 | // throw new ODMException("Object is not managed: " . print_r($obj, true)); 500 | //} 501 | // end of change 2017-03-24 502 | 503 | $id = $this->itemReflection->getPrimaryIdentifier($obj); 504 | if (!isset($this->itemManaged[$id])) { 505 | if ($persistIfNotManaged) { 506 | $this->itemManaged[$id] = new ManagedItemState($this->itemReflection, $obj); 507 | } 508 | else { 509 | throw new ODMException("Object is not managed: ".print_r($obj, true)); 510 | } 511 | } 512 | 513 | $objRefreshed = $this->get($this->itemReflection->getPrimaryKeys($obj, false), true); 514 | 515 | if (!$objRefreshed && $persistIfNotManaged) { 516 | $this->itemManaged[$id]->setState(ManagedItemState::STATE_NEW); 517 | } 518 | } 519 | 520 | public function remove($obj) 521 | { 522 | if (!$this->itemReflection->getReflectionClass()->isInstance($obj)) { 523 | throw new ODMException( 524 | "Object removed is not of correct type, expected: ".$this->itemReflection->getItemClass() 525 | ); 526 | } 527 | $id = $this->itemReflection->getPrimaryIdentifier($obj); 528 | if (!isset($this->itemManaged[$id])) { 529 | throw new ODMException("Object is not managed: ".print_r($obj, true)); 530 | } 531 | 532 | $this->itemManaged[$id]->setState(ManagedItemState::STATE_REMOVED); 533 | } 534 | 535 | public function removeAll() 536 | { 537 | do { 538 | $this->clear(); 539 | $this->scanAndRun( 540 | function ($item) { 541 | $this->remove($item); 542 | if (count($this->itemManaged) > 1000) { 543 | return false; 544 | } 545 | 546 | return true; 547 | }, 548 | '', 549 | [], 550 | DynamoDbIndex::PRIMARY_INDEX, 551 | true, 552 | true, 553 | 10 554 | ); 555 | if (count($this->itemManaged) == 0) { 556 | break; 557 | } 558 | $skipCAS = $this->itemManager->shouldSkipCheckAndSet(); 559 | $this->itemManager->setSkipCheckAndSet(true); 560 | $this->flush(); 561 | $this->itemManager->setSkipCheckAndSet($skipCAS); 562 | } while (true); 563 | } 564 | 565 | public function removeById($keys) 566 | { 567 | $obj = $this->get($keys, true); 568 | if ($obj) { 569 | $this->remove($obj); 570 | } 571 | } 572 | 573 | public function scan( 574 | $conditions = '', 575 | array $params = [], 576 | $indexName = DynamoDbIndex::PRIMARY_INDEX, 577 | &$lastKey = null, 578 | $evaluationLimit = 30, 579 | $isConsistentRead = false, 580 | $isAscendingOrder = true 581 | ) { 582 | $fields = $this->getFieldsArray($conditions); 583 | $results = $this->dbConnection->scan( 584 | $conditions, 585 | $fields, 586 | $params, 587 | $indexName, 588 | $lastKey, 589 | $evaluationLimit, 590 | $isConsistentRead, 591 | $isAscendingOrder, 592 | $this->itemReflection->getProjectedAttributes() 593 | ); 594 | $ret = []; 595 | foreach ($results as $result) { 596 | $obj = $this->persistFetchedItemData($result); 597 | $ret[] = $obj; 598 | } 599 | 600 | return $ret; 601 | } 602 | 603 | /** 604 | * @param string $conditions 605 | * @param array $params 606 | * @param bool $indexName 607 | * @param bool $isConsistentRead 608 | * @param bool $isAscendingOrder 609 | * @param int $parallel 610 | * 611 | * @return SplDoublyLinkedList 612 | */ 613 | public function scanAll( 614 | $conditions = '', 615 | array $params = [], 616 | $indexName = DynamoDbIndex::PRIMARY_INDEX, 617 | $isConsistentRead = false, 618 | $isAscendingOrder = true, 619 | $parallel = 1 620 | ) { 621 | $ret = new SplDoublyLinkedList(); 622 | $this->scanAndRun( 623 | function ($item) use ($ret) { 624 | $ret->push($item); 625 | }, 626 | $conditions, 627 | $params, 628 | $indexName, 629 | $isConsistentRead, 630 | $isAscendingOrder, 631 | $parallel 632 | ); 633 | 634 | return $ret; 635 | } 636 | 637 | public function scanAndRun( 638 | callable $callback, 639 | $conditions = '', 640 | array $params = [], 641 | $indexName = DynamoDbIndex::PRIMARY_INDEX, 642 | $isConsistentRead = false, 643 | $isAscendingOrder = true, 644 | $parallel = 1 645 | ) { 646 | $resultCallback = function ($result) use ($callback) { 647 | $obj = $this->persistFetchedItemData($result); 648 | 649 | return call_user_func($callback, $obj); 650 | }; 651 | 652 | $fields = $this->getFieldsArray($conditions); 653 | 654 | if ($parallel > 1) { 655 | $this->dbConnection->parallelScanAndRun( 656 | $parallel, 657 | $resultCallback, 658 | $conditions, 659 | $fields, 660 | $params, 661 | $indexName, 662 | $isConsistentRead, 663 | $isAscendingOrder, 664 | $this->itemReflection->getProjectedAttributes() 665 | ); 666 | } 667 | elseif ($parallel == 1) { 668 | $this->dbConnection->scanAndRun( 669 | $resultCallback, 670 | $conditions, 671 | $fields, 672 | $params, 673 | $indexName, 674 | $isConsistentRead, 675 | $isAscendingOrder, 676 | $this->itemReflection->getProjectedAttributes() 677 | ); 678 | } 679 | else { 680 | throw new InvalidArgumentException("Parallel can only be an integer greater than 0"); 681 | } 682 | } 683 | 684 | public function scanCount( 685 | $conditions = '', 686 | array $params = [], 687 | $indexName = DynamoDbIndex::PRIMARY_INDEX, 688 | $isConsistentRead = false, 689 | $parallel = 10 690 | ) { 691 | $fields = $this->getFieldsArray($conditions); 692 | 693 | return $this->dbConnection->scanCount( 694 | $conditions, 695 | $fields, 696 | $params, 697 | $indexName, 698 | $isConsistentRead, 699 | $parallel 700 | ); 701 | } 702 | 703 | /** 704 | * @return Connection 705 | * 706 | * 707 | * @deprecated this interface might be removed any time in the future 708 | * 709 | * @internal only for advanced user, avoid using the table client directly whenever possible. 710 | */ 711 | public function getDynamodbTable() 712 | { 713 | return $this->dbConnection; 714 | } 715 | 716 | protected function getFieldsArray($conditions) 717 | { 718 | $ret = preg_match_all('/#(?P[a-zA-Z_][a-zA-Z0-9_]*)/', $conditions, $matches); 719 | if (!$ret) { 720 | return []; 721 | } 722 | 723 | $result = []; 724 | $fieldNameMapping = $this->itemReflection->getFieldNameMapping(); 725 | if (isset($matches['field']) && is_array($matches['field'])) { 726 | foreach ($matches['field'] as $fieldName) { 727 | if (!isset($fieldNameMapping[$fieldName])) { 728 | throw new ODMException("Cannot find field named $fieldName!"); 729 | } 730 | $result["#".$fieldName] = $fieldNameMapping[$fieldName]; 731 | } 732 | } 733 | 734 | return $result; 735 | } 736 | 737 | protected function persistFetchedItemData(array $resultData) 738 | { 739 | $id = $this->itemReflection->getPrimaryIdentifier($resultData); 740 | if (isset($this->itemManaged[$id])) { 741 | if ($this->itemManaged[$id]->isNew()) { 742 | throw new ODMException("Conflict! Fetched remote data is also persisted. ".json_encode($resultData)); 743 | } 744 | elseif ($this->itemManaged[$id]->isRemoved()) { 745 | throw new ODMException("Conflict! Fetched remote data is also removed. ".json_encode($resultData)); 746 | } 747 | 748 | $obj = $this->itemManaged[$id]->getItem(); 749 | $this->itemReflection->hydrate($resultData, $obj); 750 | $this->itemManaged[$id]->setOriginalData($resultData); 751 | } 752 | else { 753 | $obj = $this->itemReflection->hydrate($resultData); 754 | $this->itemManaged[$id] = new ManagedItemState($this->itemReflection, $obj, $resultData); 755 | } 756 | 757 | return $obj; 758 | } 759 | } 760 | --------------------------------------------------------------------------------