├── .travis.yml ├── tests ├── bootstrap.php └── EntityMapper │ └── Tests │ └── MapperTest.php ├── phpunit.xml.dist ├── example ├── Tweet.php └── twitter.php ├── README.md └── src └── EntityMapper └── Mapper.php /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | php: 5.3 3 | script: phpunit -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | 16 | ./tests/EntityMapper/ 17 | 18 | 19 | -------------------------------------------------------------------------------- /example/Tweet.php: -------------------------------------------------------------------------------- 1 | entities; 14 | } 15 | 16 | public function getHashes() 17 | { 18 | if (isset($this->entities['hashtags'])) { 19 | return $this->entities['hashtags']; 20 | } else { 21 | return array(); 22 | } 23 | } 24 | 25 | public function getUserName() 26 | { 27 | return $this->userName; 28 | } 29 | 30 | public function getText() 31 | { 32 | return $this->text; 33 | } 34 | 35 | public function getCreatedAt($format = 'l jS \of F Y ') 36 | { 37 | if ($this->createdAt instanceof DateTime) { 38 | return $this->createdAt->format($format); 39 | } 40 | } 41 | 42 | } -------------------------------------------------------------------------------- /example/twitter.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/php 2 | array( 26 | 'from_user_name' => array('name' => 'userName'), 27 | 'created_at' => array('name' => 'createdAt', 'class' => 'DateTime'), 28 | 'entities'=> array('name' => 'entities', 'class' => 'Entity', 'depth' => 2) 29 | ), 30 | 'Entity' => array('_new' => $creatEntity) 31 | ); 32 | 33 | 34 | $mapper = new Mapper($map, true); 35 | 36 | $tweets = $mapper->hydrate($tweetsContainer['results'], 'Tweet', 1); 37 | 38 | foreach ($tweets as $tweet) { 39 | echo "\n\n" . $tweet->getUserName() . ' - ' . $tweet->getCreatedAt() ; 40 | echo "\n ---> " . $tweet->getText(); 41 | foreach ($tweet->getHashes() as $key => $hash) { 42 | echo "\nHash $key: $hash->text"; 43 | } 44 | 45 | } 46 | 47 | class Entity 48 | { 49 | protected $indices; 50 | } 51 | 52 | class Hash extends Entity 53 | { 54 | public $text; // im being lazy :) 55 | } 56 | 57 | class Url extends Entity 58 | { 59 | protected $url; 60 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Travis CI Status](https://secure.travis-ci.org/jcleveley/EntityMapper.png) 2 | 3 | # Entity Mapper 4 | 5 | The entity mapper is used to hydrate an array of data, usually from json_decode via a web service, to custom nested PHP objects. 6 | 7 | To map the array to PHP objects you need to provide a map describing how the data should be transformed. 8 | 9 | ## Features 10 | 11 | * Simple way to transform raw data into your objects 12 | * Provides a way to use bespoke objects rather than accessing a large array 13 | * Helps to keep all your business logic in domain models 14 | * No inheritance required on your objects 15 | * Can hydrate complex / nested arrays 16 | 17 | ## Setup 18 | 19 | The constructor takes the raw data array and an allowAutoPropertySetting option - Whether a property with the same name as the data key should be auto set: 20 | 21 | * true: properties will be mapped automatically if they have the same name 22 | * false: you have to explicitly add properties to the map 23 | 24 | ## Mapping array 25 | 26 | The main way to control the hydration is through the mapping array. Each top level key corresponds to a PHP class and the value is an array consisting of the input data keys. 27 | 28 | Each input data key has an array to describe how to deal with it: 29 | 30 | * name: (string) The object property name the data will be mapped to. 31 | * class: (string) Name of the class to be mapped to (optional) 32 | 33 | ## Example 34 | 35 | ```php 36 | array( 51 | 'from_user_name' => array('name' => 'userName'), 52 | 'created_at' => array('name' => 'createdAt', 'class' => 'DateTime') 53 | ) 54 | ); 55 | 56 | // Create the mapper - seting the mapper to automatically set properties of the same name 57 | $mapper = new Mapper($map, true); 58 | 59 | // Hydrate the twitter data to 'Tweet' objects at a depth of 1 60 | $tweets = $mapper->hydrate($tweetsContainer['results'], 'Tweet', 1); 61 | 62 | // Use the new objects 63 | foreach ($tweets as $tweet) { 64 | echo "\n\n" . $tweet->getUserName() . ' - ' . $tweet->getCreatedAt() ; 65 | echo "\n ---> " . $tweet->getText(); 66 | } 67 | ``` 68 | ### Result: 69 | ``` 70 | Daniel Chandra - Wednesday 28th of December 2011 71 | ---> RT @simplepassing: Cant wait for next (year) London derby, West Ham vs Chelsea :p RT @TruebluesIndo: *uhuk adminnya westham toh, ngeri juga kaya hooligan NYA 72 | ``` 73 | 74 | ### Advanced Usage 75 | 76 | ## Depth property 77 | Sometime the data you're interested in is nested within arrays. You can use the depth property to tell the mapper to only hydrate at a certain nested level. 78 | 79 | ## _new function 80 | The _new function can be used to customise the creation of objects. 81 | The function is passed the raw child data and the last key string which helps give it context. 82 | 83 | 84 | 85 | ```php 86 | array( 102 | 'from_user_name' => array('name' => 'userName'), 103 | 'created_at' => array('name' => 'createdAt', 'class' => 'DateTime'), 104 | 'entities'=> array('name' => 'entities', 'class' => 'Entity', 'depth' => 2) 105 | ), 106 | 'Entity' => array('_new' => $createEntity) 107 | ); 108 | ``` 109 | -------------------------------------------------------------------------------- /src/EntityMapper/Mapper.php: -------------------------------------------------------------------------------- 1 | map = $map; 50 | $this->allowAutoMapping = $allowAutoMapping; 51 | $this->allowMethodSetting = $allowMethodSetting; 52 | } 53 | 54 | /** 55 | * Recursively hydrates (makes objects) from an array of data 56 | * 57 | * @param $data Raw data 58 | * @param String $className class name of object which be used to hydrate 59 | * @param Int $depth (used during recursion) depth of target class within arrays 60 | */ 61 | public function hydrate($data, $className = null, $depth = 0, $lastStringKey = null) 62 | { 63 | // Maps to a PHP object - properties will be mapped including nested obj 64 | if (is_array($data) && $className && $depth == 0) { 65 | $output = $this->createEntity($data, $className, $lastStringKey); 66 | // Maps to an Array - classname and depth are carried forward for nested obj 67 | } elseif (is_array($data) && $depth) { 68 | $output = $this->createArray($data, $className, $depth, $lastStringKey); 69 | // Maps to a PHP object - data will be injected into constructor 70 | } elseif (!is_array($data) && $className && $depth == 0) { 71 | $output = $this->createInjectedEntity($data, $className); 72 | // Maps to normal variable 73 | } else { 74 | $output = $data; 75 | } 76 | 77 | return $output; 78 | } 79 | 80 | /** 81 | * Creates an object based on mapping 82 | * Uses _new closure if present to customise 83 | * 84 | * @param Mixed $data Subset of data 85 | * @param String $className 86 | * @param String last string key in the array path 87 | */ 88 | protected function createEntity($data, $className, $lastStringKey) 89 | { 90 | if ($factory = $this->getFactoryFunction($className)) { 91 | $entity = call_user_func($factory, $data, $lastStringKey); 92 | } else { 93 | $entity = new $className; 94 | } 95 | 96 | $className = get_class($entity); 97 | $reflClass = new ReflectionClass($className); 98 | 99 | foreach ($data as $key => $value) { 100 | $field = $this->mapField($className, $key); 101 | 102 | $value = $this->hydrate( 103 | $value, 104 | $this->getChildClass($field), 105 | $this->getDepth($field), 106 | $this->getStringKey($key, $lastStringKey) 107 | ); 108 | 109 | $setter = $this->getSetter($field); 110 | if($this->allowMethodSetting && is_callable(array($entity, $setter))) { 111 | $entity->$setter($value); 112 | } else { 113 | $property = $this->getProperty($field); 114 | if ($property && $reflClass->hasProperty($property)) { 115 | $reflProp = $reflClass->getProperty($property); 116 | $reflProp->setAccessible(true); 117 | $reflProp->setValue($entity, $value); 118 | } 119 | } 120 | } 121 | return $entity; 122 | } 123 | 124 | /** 125 | * Maps data to an Array, every value goes through recursion 126 | * Depth gets reduced on every array level created 127 | * 128 | * @param Mixed $data Raw data 129 | * @param String $className Class name of nested object(s) 130 | * @param Int $depth Number of levels until we can expect an object 131 | * @param String last string key in the array path 132 | */ 133 | protected function createArray($data, $className, $depth, $lastStringKey) 134 | { 135 | $newArray = array(); 136 | $depth--; 137 | foreach ($data as $key => $value) { 138 | $newArray[$key] = $this->hydrate($value, $className, $depth, $this->getStringKey($key, $lastStringKey)); 139 | } 140 | return $newArray; 141 | } 142 | 143 | /** 144 | * Creates an injected object based on mapping 145 | * Ignores exceptions such as DateTime('notaddate'); 146 | * 147 | * @param String $className 148 | * @param Mixed $data Subset of data 149 | */ 150 | protected function createInjectedEntity($data, $className) 151 | { 152 | try { 153 | return new $className($data); 154 | } catch (Exception $e) { 155 | return null; 156 | } 157 | } 158 | 159 | protected function getStringKey($key, $lastStringKey) 160 | { 161 | return is_string($key) ? $key : $lastStringKey; 162 | } 163 | 164 | protected function mapField($className, $key) 165 | { 166 | $fallback = ($this->allowAutoMapping) ? array('name' => $key) : null; 167 | 168 | return isset($this->map[$className][$key]) ? $this->map[$className][$key] : $fallback; 169 | } 170 | 171 | protected function getFactoryFunction($className) 172 | { 173 | if (isset($this->map[$className]['_new']) && is_callable($this->map[$className]['_new'])) { 174 | return $this->map[$className]['_new']; 175 | } else { 176 | return null; 177 | } 178 | } 179 | 180 | protected function getDepth($field) 181 | { 182 | return isset($field['depth']) ? $field['depth'] : 0; 183 | } 184 | 185 | protected function getChildClass($field) 186 | { 187 | return isset($field['class']) ? $field['class'] : null; 188 | } 189 | 190 | protected function getProperty($field) 191 | { 192 | return isset($field['name']) ? $field['name'] : null; 193 | } 194 | 195 | protected function getSetter($field) 196 | { 197 | return 'set'.$this->getProperty($field); 198 | } 199 | 200 | } -------------------------------------------------------------------------------- /tests/EntityMapper/Tests/MapperTest.php: -------------------------------------------------------------------------------- 1 | array('name' => 'title'), 12 | 'contents' => array('name' => 'body'), 13 | 'authors' => array('name' => 'authors'), 14 | 'thumbnail' => array('name' => 'thumbnail', 'depth' => 0, 'class' => 'Image'), 15 | 'images' => array('name' => 'images', 'depth' => 1, 'class' => 'Image'), 16 | 'media' => array('name' => 'media', 'depth' => 2, 'class' => 'Image'), 17 | 'relatedStory' => array('name' => 'relatedStory', 'class' => 'Story') 18 | ); 19 | 20 | $imageMeta = array( 21 | 'href' => array('name' => 'href'), 22 | 'alt' => array('name' => 'alt'), 23 | ); 24 | 25 | $liveEventMeta = array( 26 | 'channelId' => array('name' => 'channelId'), 27 | ) + $storyMeta; 28 | 29 | $this->map = array('Story' => $storyMeta, 'Image' => $imageMeta, 'LiveEvent' => $liveEventMeta); 30 | 31 | $this->data = array( 32 | 'title' => 'Once upon a time', 33 | 'contents' => 'Here we go .... the end', 34 | 'authors' => array('John', 'Frank'), 35 | 'thumbnail' => array('href' => 'http://foo.com', 'alt' => 'nice pic'), 36 | 'images' => array(array('href' => 'http://foo.com', 'alt' => 'nice pic')), 37 | 'media' => array( 38 | 'bigImages' => array(array('href' => 'http://foo.com', 'alt' => 'nice pic')), 39 | 'smallImages' => array(array('href' => 'http://foo.com', 'alt' => 'nice pic')) 40 | ), 41 | ); 42 | 43 | $this->mapper = new Mapper($this->map); 44 | } 45 | 46 | public function testSimpleProperties() 47 | { 48 | $entity = $this->mapper->hydrate($this->data, 'Story'); 49 | 50 | $this->assertEquals($this->data['title'], $entity->getTitle(), 'Should map plain properties'); 51 | 52 | $this->assertEquals($this->data['contents'], $entity->getBody(), 'Should map renamed properties'); 53 | 54 | $this->assertSame($this->data['authors'], $entity->getAuthors(), 'Should map arrays'); 55 | } 56 | 57 | public function testAutoProperties() 58 | { 59 | $map['Story']['title'] = array('name' => 'title'); 60 | $data['title'] = 'Esists in map'; 61 | $data['body'] = 'not in map'; 62 | 63 | $mapper = new Mapper($map, false); 64 | $entity = $mapper->hydrate($data, 'Story'); 65 | 66 | $this->assertNull($entity->getBody(), 'Should ignore non-mapped data'); 67 | $this->assertEquals('Esists in map', $entity->getTitle()); 68 | 69 | $mapper = new Mapper($map, true); 70 | $entity = $mapper->hydrate($data, 'Story'); 71 | 72 | $this->assertEquals('not in map', $entity->getBody(), 'Should set non-mapped properties'); 73 | $this->assertEquals('Esists in map', $entity->getTitle()); 74 | } 75 | 76 | public function testObjectCreation() 77 | { 78 | $entity = $this->mapper->hydrate($this->data, 'Story'); 79 | 80 | //test depth 0 81 | $this->AssertTrue($entity->getThumbnail() instanceof Image, 'Should map to custom oject'); 82 | $this->assertSame( 83 | $this->data['thumbnail']['alt'], 84 | $entity->getThumbnail()->getAlt(), 85 | 'Should map child properties' 86 | ); 87 | } 88 | 89 | public function testNestedObjectCreation() 90 | { 91 | $entity = $this->mapper->hydrate($this->data, 'Story'); 92 | 93 | //test depth 1 94 | $this->AssertTrue(is_array($entity->getImages()), 'Should create an array of objects'); 95 | $images = $entity->getImages(); 96 | $image = array_pop($images); 97 | $this->AssertTrue( 98 | $image instanceof Image, 99 | 'Should create nested object of right class' 100 | ); 101 | 102 | // test depth 2 103 | $this->assertTrue(is_array($entity->getMedia())); 104 | $media = $entity->getMedia(); 105 | $this->assertTrue(is_array($media['bigImages'])); 106 | $this->assertTrue($media['bigImages'][0] instanceof Image, 'Should map deep arays with class at end'); 107 | } 108 | 109 | public function testCustomOjectCreation() 110 | { 111 | $this->map['Story']['_new'] = function($data) { 112 | if (@$data['type'] == 'LEP') { 113 | return new LiveEvent(); 114 | } else { 115 | return new Story(); 116 | } 117 | }; 118 | $this->data['relatedStory'] = array('type' => 'LEP', 'channelId' => 'ash-cloud'); 119 | $this->mapper = new Mapper($this->map); 120 | 121 | $entity = $this->mapper->hydrate($this->data, 'Story'); 122 | $this->assertTrue($entity->getRelatedStory() instanceof LiveEvent, 'Should call _new if present'); 123 | $this->assertEquals( 124 | 'ash-cloud', 125 | $entity->getRelatedStory()->getChannelId(), 126 | 'Should use class map of object returned in _new' 127 | ); 128 | } 129 | 130 | public function testBadCallableCreation() 131 | { 132 | $this->map['Story']['_new'] = 'this is not callable!'; 133 | $this->mapper = new Mapper($this->map); 134 | 135 | $entity = $this->mapper->hydrate($this->data, 'Story'); 136 | 137 | $this->assertTrue($entity instanceof Story, 'Should silently fail if _new is not callable'); 138 | } 139 | 140 | public function testNativeObjectCreation() 141 | { 142 | $this->map['Story']['date'] = array('name' => 'date', 'class' => 'DateTime'); 143 | $this->data['date'] = '2011-11-07T14:40:40+00:00'; 144 | $this->mapper = new Mapper($this->map); 145 | 146 | $entity = $this->mapper->hydrate($this->data, 'Story'); 147 | 148 | $this->assertTrue($entity->getDate() instanceof DateTime, 'Should map to native objects'); 149 | $this->assertEquals('1320676840', $entity->getDate()->getTimestamp(), 'Should construct native correctly'); 150 | } 151 | 152 | public function testNativeObjectException() 153 | { 154 | $this->map['Story']['date'] = array('name' => 'date', 'class' => 'DateTime'); 155 | $this->data['date'] = 'thisisnotadate'; 156 | $this->mapper = new Mapper($this->map); 157 | 158 | $entity = $this->mapper->hydrate($this->data, 'Story'); 159 | $this->assertNull($entity->getDate()); 160 | } 161 | 162 | public function testObjectCreationBasedOnStringKey() 163 | { 164 | $createMediaObject = function($data, $lastStringKey) { 165 | switch ($lastStringKey) { 166 | case 'images': 167 | return new Image(); 168 | case 'videos': 169 | return new Video(); 170 | } 171 | }; 172 | 173 | $map = array( 174 | 'Story' => 175 | array('media' => array('name' => 'media', 'depth' => 2, 'class' => 'Media')), 176 | 'Media' => array('_new' => $createMediaObject) 177 | ); 178 | 179 | $data = array( 180 | 'media' => array( 181 | 'images' => array(array('href' => 'http://foo.com', 'alt' => 'nice pic')), 182 | 'videos' => array(array('type' => 'mp4')) 183 | ), 184 | ); 185 | $mapper = new Mapper($map, true); 186 | 187 | $entity = $mapper->hydrate($data, 'Story'); 188 | 189 | $media = $entity->getMedia(); 190 | 191 | $this->assertTrue($media['images'][0] instanceof Image); 192 | $this->assertTrue($media['videos'][0] instanceof Video); 193 | 194 | } 195 | 196 | public function testNullDataWithDepth() 197 | { 198 | $this->data['media'] = null; 199 | $entity = $this->mapper->hydrate($this->data, 'Story'); 200 | $this->assertNull($entity->getMedia()); 201 | } 202 | 203 | public function testSetterHydration() 204 | { 205 | $storyMeta = array( 206 | 'title' => array('name' => 'title'), 207 | ); 208 | $mapper = new Mapper(array('Story' => $storyMeta), false, true); 209 | $data = array('title' => 'once upon a time'); 210 | 211 | $entity = $mapper->hydrate($data, 'Story'); 212 | 213 | $this->assertEquals($entity->getTitle(), 'Once Upon A Time'); 214 | } 215 | 216 | } 217 | 218 | class Story 219 | { 220 | protected $title; 221 | protected $body; 222 | protected $thumbnail; 223 | protected $images; 224 | protected $authors; 225 | protected $media; 226 | protected $date; 227 | protected $relatedStory; 228 | 229 | public function getTitle() 230 | { 231 | return $this->title; 232 | } 233 | 234 | public function setTitle($title) 235 | { 236 | $this->title = ucwords($title); 237 | } 238 | 239 | public function getBody() 240 | { 241 | return $this->body; 242 | } 243 | 244 | public function getImages() 245 | { 246 | return $this->images; 247 | } 248 | 249 | public function getThumbnail() 250 | { 251 | return $this->thumbnail; 252 | } 253 | 254 | public function getAuthors() 255 | { 256 | return $this->authors; 257 | } 258 | 259 | public function getMedia() 260 | { 261 | return $this->media; 262 | } 263 | 264 | public function getDate() 265 | { 266 | return $this->date; 267 | } 268 | 269 | public function getRelatedStory() 270 | { 271 | return $this->relatedStory; 272 | } 273 | } 274 | 275 | class LiveEvent extends Story 276 | { 277 | protected $channelId; 278 | 279 | public function getChannelId() 280 | { 281 | return $this->channelId; 282 | } 283 | } 284 | 285 | class Image 286 | { 287 | protected $href; 288 | protected $alt; 289 | 290 | public function getAlt() 291 | { 292 | return $this->alt; 293 | } 294 | 295 | } 296 | 297 | class Video 298 | { 299 | protected $source; 300 | protected $type; 301 | 302 | public function getType() 303 | { 304 | return $this->type; 305 | } 306 | 307 | } --------------------------------------------------------------------------------