├── .gitignore ├── README.md ├── composer.json └── src ├── Collections ├── RelatedCollection.php └── TrackedCollection.php ├── Exceptions ├── AllowSchemaUpdateIsFalseException.php ├── FailedToInsertException.php ├── InvalidOperatorException.php ├── NoQueryException.php ├── PropertyDoesNotExistException.php ├── RepositoryClassNotDefinedException.php ├── RequiredAnnotationMissingException.php └── UnknownColumnTypeException.php ├── Manager.php ├── Mapping.php ├── Models ├── BaseModel.php ├── IdModel.php ├── PostMeta.php ├── Posts.php └── Users.php ├── QueryBuilder.php └── Repositories ├── BaseRepository.php ├── PostsRepository.php └── UsersRepository.php /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | composer.lock 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WORM (Wordpress ORM) 2 | 3 | *Under active development* 4 | 5 | A lightweight, Doctrine style ORM for Wordpress 4.8+. Requires PHP 5.5+ 6 | 7 | This library borrows a lot of concepts from Doctrine for Symfony including the mapper, entity manager, repositories, 8 | one-to-many entity relationships, lazy-loading of related entities and a query builder. 9 | 10 | It acts as a layer sitting on top of the internal Wordpress `$wpdb` class. 11 | 12 | All queries are run through the internal Wordpress `$wpdb->prepare()` function to protect against SQL injection 13 | attacks. 14 | 15 | ## Why? 16 | 17 | I came to Wordpress from a Symfony development background. I wanted to work with objects instead of manually writing 18 | SQL queries. I looked around for a good ORM and was unable to find anything that fulfilled my requirements. 19 | 20 | Getting Doctrine to work with Wordpress was complex and seemed like overkill as it's a very heavy weight library. 21 | 22 | There's also: https://github.com/tareq1988/wp-eloquent which is really good, but is more of a query builder for 23 | existing Wordpress tables. 24 | 25 | ## Version history 26 | 27 | master branch = active development 28 | 29 | 0.1.0 tag = previous master. The version most people would be using circa last 2022. 30 | 31 | ## Installation 32 | 33 | ``` 34 | composer require rjjakes/wordpress-orm 35 | ``` 36 | 37 | ## Usage 38 | 39 | ### Create a model 40 | 41 | To use the ORM, first create a class that extends the ORM base model and add a number of properties as protected 42 | variables. The property names must exactly match the desired SQL column names (there is no name mapping). 43 | 44 | Note that a property of `$id` is added to the model when you do `extends BaseModel` so you do not need to add that. 45 | 46 | Example: 47 | 48 | ```php 49 | updateSchema(Product::class); 134 | ``` 135 | 136 | This function uses the internal Wordpress dbDelta system to compare and update database tables. If your table doesn't 137 | exist, it will be created, otherwise it checks the schema matches the model and modifies the database table if needed. 138 | 139 | You should only run this function either when your plugin is activated or during development when you know you have 140 | made a change to your model schema and want to apply it to the database 141 | 142 | ### Persisting objects to the database. 143 | 144 | Use the ORM manager: 145 | 146 | ```php 147 | use Symlink\ORM\Manager; 148 | ``` 149 | 150 | Create a new instance of your model: 151 | 152 | ```php 153 | $product = new Product(); 154 | $product->set('title', 'Some title'); 155 | $product->set('time', '2017-11-03 10:04:02'); 156 | $product->set('views', 34); 157 | $product->set('short_name', 'something_here'); 158 | ``` 159 | 160 | Get an instance of the ORM manager class. Like the Mapping class, this static function returns 161 | a reusable instance of the manager class. 162 | 163 | ```php 164 | $orm = Manager::getManager(); 165 | ``` 166 | 167 | Now queue up these changes to apply to the database. Calling this does NOT immediately apply the changes to the 168 | database. The idea here is the same as Doctrine: you can queue up many different changes to happen and once you're 169 | ready to apply them, the ORM will combine these changes into single SQL queries where possible. This helps reduce the 170 | number of calls made to the database. 171 | 172 | ```php 173 | $orm->persist($product); 174 | ``` 175 | 176 | Once, you're ready to apply all changes to your database (syncing what you have persisted to the database), call 177 | flush(): 178 | 179 | ```php 180 | $orm->flush(); 181 | ``` 182 | 183 | Now check your database and you'll see a new row containing your model data. 184 | 185 | ### Querying the database 186 | 187 | Use the ORM manager: 188 | 189 | ```php 190 | use Symlink\ORM\Manager; 191 | ``` 192 | 193 | Get an instance of the ORM manager class. 194 | 195 | ```php 196 | $orm = Manager::getManager(); 197 | ``` 198 | 199 | Get the object repository. Repositories are classes that are specific to certain object types. They contain functions 200 | for querying specific object types. 201 | 202 | By default all object types have a base repository which you can get access to by passing in the object type as follows: 203 | 204 | ```php 205 | $repository = $orm->getRepository(Product::class); 206 | ``` 207 | 208 | **With the query builder** 209 | 210 | You can create a query though this repository like so: 211 | 212 | ```php 213 | $query = $repository->createQueryBuilder() 214 | ->where('ID', 3, '=') 215 | ->orderBy('ID', 'ASC') 216 | ->limit(1) 217 | ->buildQuery(); 218 | ``` 219 | 220 | Available where() operators are: 221 | 222 | ```php 223 | '<', '<=', '=', '!=', '>', '>=', 'IN', 'NOT IN' 224 | ``` 225 | 226 | Available orderBy() operators are: 227 | 228 | ```php 229 | 'ASC', 'DESC' 230 | ``` 231 | 232 | To use the "IN" and "NOT IN" clauses of the ->where() function, pass in an array of values like so: 233 | 234 | ```php 235 | $query = $repository->createQueryBuilder() 236 | ->where('id', [1, 12], 'NOT IN') 237 | ->orderBy('id', 'ASC') 238 | ->limit(1) 239 | ->buildQuery(); 240 | ``` 241 | 242 | Now you have your query, you can use it to get some objects back out of the database. 243 | 244 | ```php 245 | $results = $query->getResults(); 246 | ``` 247 | 248 | Note that if there was just one result, `$results` will contain an object of the repository type. Otherwise it will 249 | contain an array of objects. 250 | 251 | To force `getResults()` to always return an array (even if it's just one results), call it with `TRUE` like this: 252 | 253 | ```php 254 | $results = $query->getResults(TRUE); 255 | ``` 256 | 257 | **Built-in repository query functions** 258 | 259 | Building a query every time you want to select objects from the database is not best practice. Ideally you would create 260 | some helper functions that abstract the query builder away from your controller. 261 | 262 | There are several built-in functions in the base repository. 263 | 264 | Return an object by id: 265 | 266 | ```php 267 | $results = Manager::getManager() 268 | ->getRepository(Product::class) 269 | ->find($id); 270 | ``` 271 | 272 | Return all objects sorted by ascending id. 273 | 274 | ```php 275 | $results = Manager::getManager() 276 | ->getRepository(Product::class) 277 | ->findAll(); 278 | ``` 279 | 280 | Return all objects matching pairs of property name and value: 281 | 282 | ```php 283 | $results = Manager::getManager() 284 | ->getRepository(Product::class) 285 | ->findBy([$property_name_1 => $value_1, $property_name_2 => $value_2]); 286 | ``` 287 | 288 | To add more repository query functions, you can subclass the `BaseRepository` class and tell your object to use that 289 | instead of `BaseRepository`. That is covered in the section below called: *Create a custom repository* 290 | 291 | 292 | ### Saving modified objects back to the database 293 | 294 | To modify an object, load the object from the database modfiy one or more of it's values, call `flush()` to apply the 295 | changes back to the database. 296 | 297 | For example: 298 | 299 | ```php 300 | $orm = Manager::getManager(); 301 | $repository = $orm->getRepository(Product::class); 302 | 303 | $product = $repository->find(9); // Load an object by known ID=9 304 | $product->set('title', 'TITLE HAS CHANGED!'); 305 | 306 | $orm->flush(); 307 | ``` 308 | 309 | This works because whenever an object is persisted ot loaded from the database, Wordpres ORM tracks any changes made to 310 | the model data. `flush()` syncronizes the differences made since the load (or last `flush()`). 311 | 312 | ### Deleting objects from the database 313 | 314 | To remove an object from the database, load an object from the database and pass it to the `remove()` method on the 315 | manager class. Then call `flush()` to syncronize the database. 316 | 317 | For example: 318 | 319 | ```php 320 | $orm = Manager::getManager(); 321 | $repository = $orm->getRepository(Product::class); 322 | 323 | $product = $repository->find(9); // Load an object by known ID=9 324 | 325 | $orm->remove($product); // Queue up the object to be removed from the database. 326 | 327 | $orm->flush(); 328 | ``` 329 | 330 | ### Dropping model tables from the database. 331 | 332 | It's good practice for your plugin to clean up any data it has created when the user uninstalls. With that in mind, the 333 | ORM has a method for removing previously created tables. If you have created any custom models, you should use this 334 | function as part of your uninstall hook. 335 | 336 | Use the mapper class: 337 | 338 | ```php 339 | use Symlink\ORM\Mapping; 340 | ``` 341 | 342 | 343 | First, get an instance of the ORM mapper object. 344 | 345 | ```php 346 | $mapper = Mapping::getMapper(); 347 | ``` 348 | 349 | Now pass the model classname to dropTable() like this: 350 | 351 | ```php 352 | $mapper->dropTable(Product::class); 353 | ``` 354 | 355 | 356 | ### Create a custom repository 357 | 358 | @todo 359 | 360 | ### Relationships 361 | 362 | @todo 363 | 364 | ## Exceptions 365 | 366 | Wordpress ORM uses Exceptions to handle most failure states. You'll want to wrap calls to ORM functions in 367 | `try {} catch() {}` blocks to handle these exceptions and send errors or warning messages to the user. 368 | 369 | For example: 370 | 371 | ```php 372 | try { 373 | $query = $repository->createQueryBuilder() 374 | ->where('ID', 3, '==') // Equals operator should be '=' not '==' 375 | ->buildQuery(); 376 | } catch (\Symlink\ORM\Exceptions\InvalidOperatorException $e) { 377 | // ... warn the user about the bad operator or handle it another way. 378 | } 379 | 380 | ``` 381 | 382 | The exceptions are as follows. 383 | 384 | ```php 385 | AllowSchemaUpdateIsFalseException 386 | FailedToInsertException 387 | InvalidOperatorException 388 | NoQueryException 389 | PropertyDoesNotExistException 390 | RepositoryClassNotDefinedException 391 | RequiredAnnotationMissingException 392 | UnknownColumnTypeException 393 | ``` 394 | 395 | 396 | ## Pre-defined models 397 | 398 | @todo 399 | 400 | 401 | ## Dependencies 402 | 403 | (Dependencies are automatically handled by Composer). 404 | 405 | https://github.com/marcioalmada/annotations 406 | 407 | https://github.com/myclabs/deepcopy 408 | 409 | 410 | ## Credits 411 | 412 | Maintained by: https://github.com/rjjakes 413 | 414 | This library is under active development, so I'll happily accept comments, issues and pull requests. 415 | 416 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rjjakes/wordpress-orm", 3 | "description": "WORM. Doctrine style ORM for Wordpress.", 4 | "keywords": ["wordpress", "plugin", "orm", "doctrine", "composer", "sql"], 5 | "homepage": "https://github.com/rjjakes/wordpress-orm", 6 | "require": { 7 | "php": ">=5.5", 8 | "minime/annotations": "^3.1" 9 | }, 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Ray Jakes", 14 | "email": "symlinkdigital@gmail.com" 15 | } 16 | ], 17 | "autoload" : { 18 | "psr-4" : { 19 | "Symlink\\ORM\\": "src/" 20 | } 21 | }, 22 | "minimum-stability": "stable" 23 | } 24 | -------------------------------------------------------------------------------- /src/Collections/RelatedCollection.php: -------------------------------------------------------------------------------- 1 | list); 17 | } 18 | 19 | public function getIterator() 20 | { 21 | return new \ArrayIterator($this->list); 22 | } 23 | 24 | public function offsetSet($offset, $value) { 25 | if (is_null($offset)) { 26 | $this->list[] = $value; 27 | } else { 28 | $this->list[$offset] = $value; 29 | } 30 | } 31 | 32 | public function offsetExists($offset) { 33 | return isset($this->list[$offset]); 34 | } 35 | 36 | public function offsetUnset($offset) { 37 | unset($this->list[$offset]); 38 | } 39 | 40 | public function offsetGet($offset) { 41 | return isset($this->list[$offset]) ? $this->list[$offset] : null; 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/Collections/TrackedCollection.php: -------------------------------------------------------------------------------- 1 | list = []; 29 | } 30 | 31 | /** 32 | * Get data used to INSERT/UPDATE objects in the database. 33 | * 34 | * @param $iterator 35 | * @return array 36 | */ 37 | public function getInsertUpdateTableData($iterator) { 38 | $data = []; 39 | 40 | // Get the structural data. 41 | foreach ($this->$iterator() as $item) { 42 | 43 | // Add the table name and schema data (only once). 44 | if (!isset($data[get_class($item['model'])])) { 45 | $data[get_class($item['model'])] = [ 46 | 'objects' => [], 47 | 'table_name' => $item['model']->getTableName(), 48 | 'columns' => array_keys($item['model']->getSchema()), 49 | 'placeholders' => $item['model']->getPlaceholders(), 50 | 'placeholders_count' => 0, 51 | 'values' => [], 52 | ]; 53 | } 54 | 55 | // Store the object. 56 | $data[get_class($item['model'])]['objects'][] = $item['model']; 57 | 58 | // Now add the placeholder and row data. 59 | $data[get_class($item['model'])]['placeholders_count'] += 1; 60 | 61 | if ($iterator === 'getPersistedObjects') { 62 | $data[get_class($item['model'])]['values'] = array_merge( 63 | $data[get_class($item['model'])]['values'], 64 | $item['model']->getAllUnkeyedValues() 65 | ); 66 | } 67 | else { 68 | $data[get_class($item['model'])]['values'] = array_merge( 69 | $data[get_class($item['model'])]['values'], 70 | [$item['model']->getID()], 71 | $item['model']->getAllUnkeyedValues() 72 | ); 73 | } 74 | } 75 | 76 | return $data; 77 | } 78 | 79 | /** 80 | * @return array 81 | */ 82 | public function getRemoveTableData() { 83 | $data = []; 84 | 85 | 86 | foreach ($this->getRemovedObjects() as $item) { 87 | 88 | if (!isset($data[get_class($item['last_state'])])) { 89 | $data[get_class($item['last_state'])] = [ 90 | 'objects' => [], 91 | 'table_name' => $item['last_state']->getTableName(), 92 | 'values' => [], 93 | ]; 94 | } 95 | 96 | $data[get_class($item['last_state'])]['objects'][] = $item['last_state']; 97 | $data[get_class($item['last_state'])]['values'][] = $item['last_state']->getId(); 98 | 99 | } 100 | 101 | return $data; 102 | } 103 | 104 | /** 105 | * @param mixed $object 106 | * @param mixed $state 107 | */ 108 | public function offsetSet($object, $state) { 109 | 110 | switch ($state) { 111 | // If new, objects will have a 'model' but no 'last_state', 112 | case _OBJECT_NEW: 113 | $this->list[$object->getHash()] = [ 114 | 'model' => $object, 115 | ]; 116 | break; 117 | 118 | // If new, objects will have both a 'model' and a 'last_state', 119 | case _OBJECT_TRACKED: 120 | $this->list[$object->getHash()] = [ 121 | 'model' => $object, 122 | 'last_state' => clone $object 123 | ]; 124 | break; 125 | 126 | // Clean an object out of the $list 127 | case _OBJECT_CLEAN: 128 | unset($this->list[$object->getHash()]); 129 | break; 130 | } 131 | 132 | } 133 | 134 | /** 135 | * @param mixed $object 136 | * 137 | * @return bool 138 | */ 139 | public function offsetExists($object) { 140 | return isset($this->list[$object->getHash()]); 141 | } 142 | 143 | /** 144 | * Queue up an object for removal when calling flush(). 145 | * These objects will have a 'last_state' but no model. 146 | * 147 | * @param mixed $object 148 | */ 149 | public function offsetUnset($object) { 150 | 151 | // If the object exists in the list. 152 | if (isset($this->list[$object->getHash()])) { 153 | 154 | // If a new object (without a last_state) is being deleted, just delete the entire object. 155 | if (!isset($this->list[$object->getHash()]['last_state'])) { 156 | unset($this->list[$object->getHash()]); 157 | } 158 | else { 159 | unset($this->list[$object->getHash()]['model']); 160 | } 161 | } 162 | 163 | } 164 | 165 | /** 166 | * @param mixed $object 167 | * 168 | * @return mixed|null 169 | */ 170 | public function offsetGet($object) { 171 | return isset($this->list[$object->getHash()]) ? $this->list[$object->getHash()]['model'] : NULL; 172 | } 173 | 174 | public function removeFromCollection($obj_hash) { 175 | $this->list[$obj_hash] = []; 176 | } 177 | 178 | /** 179 | * Return an array of the objects to be INSERTed. 180 | */ 181 | public function getPersistedObjects() { 182 | foreach ($this->list as $item) { 183 | if (isset($item['model']) && !isset($item['last_state'])) { 184 | yield $item; 185 | } 186 | } 187 | } 188 | 189 | /** 190 | * Return an array of the objects to be UPDATEd and the changed properties. 191 | */ 192 | public function getChangedObjects() { 193 | foreach ($this->list as $item) { 194 | if (isset($item['model']) && isset($item['last_state']) && $item['model'] != $item['last_state']) { 195 | yield $item; 196 | } 197 | } 198 | } 199 | 200 | /** 201 | * Return an array of the objects to be DELETEd. 202 | */ 203 | public function getRemovedObjects() { 204 | foreach ($this->list as $item) { 205 | if (!isset($item['model']) && isset($item['last_state'])) { 206 | yield $item; 207 | } 208 | } 209 | } 210 | 211 | } 212 | -------------------------------------------------------------------------------- /src/Exceptions/AllowSchemaUpdateIsFalseException.php: -------------------------------------------------------------------------------- 1 | tracked = new TrackedCollection; 45 | } 46 | 47 | /** 48 | * Get repository instance from classname. 49 | * 50 | * @param $classname 51 | * 52 | * @return \Symlink\ORM\Repositories\BaseRepository 53 | */ 54 | public function getRepository($classname) { 55 | 56 | // Get the annotations for this class. 57 | $annotations = Mapping::getMapper()->getProcessed($classname); 58 | 59 | // Get the repository for this class (this has already been validated in the Mapper). . 60 | if (isset($annotations['ORM_Repository'])) { 61 | return $annotations['ORM_Repository']::getInstance($classname, $annotations); 62 | } 63 | // No repository set, so assume the base. 64 | else { 65 | return BaseRepository::getInstance($classname, $annotations); 66 | } 67 | } 68 | 69 | /** 70 | * Queue this model up to be added to the database with flush(). 71 | * 72 | * @param $object 73 | */ 74 | public function persist(BaseModel $object) { 75 | // Start tracking this object (because it is new, it will be tracked as 76 | // something to be INSERTed) 77 | $this->tracked[$object] = _OBJECT_NEW; 78 | } 79 | 80 | /** 81 | * Start tracking a model known to exist in the database. 82 | * 83 | * @param \Symlink\ORM\Models\BaseModel $object 84 | */ 85 | public function track(BaseModel $object) { 86 | // Save it against the key. 87 | $this->tracked[$object] = _OBJECT_TRACKED; 88 | } 89 | 90 | /** 91 | * This model should be removed from the db when flush() is called. 92 | * 93 | * @param $model 94 | */ 95 | public function remove(BaseModel $object) { 96 | unset($this->tracked[$object]); 97 | } 98 | 99 | /** 100 | * Start tracking a model known to exist in the database. 101 | * 102 | * @param \Symlink\ORM\Models\BaseModel $object 103 | */ 104 | public function clean(BaseModel $object) { 105 | // Save it against the key. 106 | $this->tracked[$object] = _OBJECT_CLEAN; 107 | } 108 | 109 | /** 110 | * Add new objects to the database. 111 | * This will perform one query per table no matter how many records need to 112 | * be added. 113 | * 114 | * @throws \Symlink\ORM\Exceptions\FailedToInsertException 115 | */ 116 | private function _flush_insert() { 117 | global $wpdb; 118 | 119 | // Get a list of tables and columns that have data to insert. 120 | $insert = $this->tracked->getInsertUpdateTableData('getPersistedObjects'); 121 | 122 | // Process the INSERTs 123 | if (count($insert)) { 124 | // Build the combined query for table: $tablename 125 | foreach ($insert as $classname => $values) { 126 | 127 | $table_name = $wpdb->prefix . $values['table_name']; 128 | 129 | // Build the placeholder SQL query. 130 | $sql = "INSERT INTO " . $table_name . " 131 | (" . implode(", ", $values['columns']) . ") 132 | VALUES 133 | "; 134 | 135 | while ($values['placeholders_count'] > 0) { 136 | $sql .= "(" . implode(", ", $values['placeholders']) . ")"; 137 | 138 | if ($values['placeholders_count'] > 1) { 139 | $sql .= ", 140 | "; 141 | } 142 | 143 | $values['placeholders_count'] -= 1; 144 | } 145 | 146 | // Insert using Wordpress prepare() which provides SQL injection protection (apparently). 147 | $prepared = $wpdb->prepare($sql, $values['values']); 148 | $count = $wpdb->query($prepared); 149 | 150 | // Start tracking all the added objects. 151 | if ($count) { 152 | array_walk($values['objects'], function ($object) { 153 | $this->track($object); 154 | }); 155 | } 156 | // Something went wrong. 157 | else { 158 | throw new \Symlink\ORM\Exceptions\FailedToInsertException(__('Failed to insert one or more records into the database.')); 159 | } 160 | } 161 | } 162 | 163 | } 164 | 165 | /** 166 | * Compares known database state of tracked objects and compares them with 167 | * the current state. Applies any changes to the database. 168 | * 169 | * This will perform one query per table no matter how many records need to 170 | * be updated. 171 | * https://stackoverflow.com/questions/3432/multiple-updates-in-mysql 172 | */ 173 | private function _flush_update() { 174 | global $wpdb; 175 | 176 | // Get a list of tables and columns that have data to update. 177 | $update = $this->tracked->getInsertUpdateTableData('getChangedObjects'); 178 | 179 | // Process the INSERTs 180 | if (count($update)) { 181 | // Build the combined query for table: $tablename 182 | foreach ($update as $classname => $values) { 183 | 184 | $table_name = $wpdb->prefix . $values['table_name']; 185 | 186 | $sql = "INSERT INTO " . $table_name . " (ID, " . implode(", ", $values['columns']) . ") 187 | VALUES 188 | "; 189 | 190 | while ($values['placeholders_count'] > 0) { 191 | $sql .= "(%d, " . implode(", ", $values['placeholders']) . ")"; 192 | 193 | if ($values['placeholders_count'] > 1) { 194 | $sql .= ", 195 | "; 196 | } 197 | 198 | $values['placeholders_count'] -= 1; 199 | } 200 | 201 | $sql .= " 202 | ON DUPLICATE KEY UPDATE 203 | "; 204 | 205 | $update_set = []; 206 | foreach ($values['columns'] as $column) { 207 | $update_set[] = $column . "=VALUES(" . $column . ")"; 208 | } 209 | $sql .= implode(", ", $update_set) . ";"; 210 | 211 | // Insert using Wordpress prepare() which provides SQL injection protection (apparently). 212 | $prepared = $wpdb->prepare($sql, $values['values']); 213 | 214 | $count = $wpdb->query($prepared); 215 | 216 | // Start tracking all the added objects. 217 | if ($count) { 218 | array_walk($values['objects'], function ($object) { 219 | $this->track($object); 220 | }); 221 | } 222 | // Something went wrong. 223 | else { 224 | throw new \Symlink\ORM\Exceptions\FailedToInsertException(__('Failed to update one or more records in the database.')); 225 | } 226 | 227 | } 228 | } 229 | } 230 | 231 | /** 232 | * 233 | */ 234 | private function _flush_delete() { 235 | 236 | global $wpdb; 237 | 238 | // Get a list of tables and columns that have data to update. 239 | $update = $this->tracked->getRemoveTableData(); 240 | 241 | // Process the INSERTs 242 | if (count($update)) { 243 | // Build the combined query for table: $tablename 244 | foreach ($update as $classname => $values) { 245 | 246 | $table_name = $wpdb->prefix . $values['table_name']; 247 | 248 | // Build the SQL. 249 | $sql = "DELETE FROM " . $table_name . " WHERE ID IN (" . implode(", ", array_fill(0, count($values['values']), "%d")) . ");"; 250 | 251 | // Process all deletes for a particular table together as a single query. 252 | $prepared = $wpdb->prepare($sql, $values['values']); 253 | $count = $wpdb->query($prepared); 254 | 255 | // Really remove the object from the tracking list. 256 | foreach ($values['objects'] as $obj_hash => $object) { 257 | $this->clean($object); 258 | } 259 | } 260 | } 261 | 262 | } 263 | 264 | /** 265 | * Apply changes to all models queued up with persist(). 266 | * Attempts to combine queries to reduce MySQL load. 267 | * 268 | * @throws \Symlink\ORM\Exceptions\FailedToInsertException 269 | */ 270 | public function flush() { 271 | $this->_flush_update(); 272 | $this->_flush_insert(); 273 | $this->_flush_delete(); 274 | } 275 | 276 | } -------------------------------------------------------------------------------- /src/Mapping.php: -------------------------------------------------------------------------------- 1 | reader) { 51 | $this->reader = Reader::createFromDefaults(); 52 | } 53 | 54 | return $this->reader; 55 | } 56 | 57 | /** 58 | * @param $classname 59 | * @param $property 60 | * @return \Minime\Annotations\Interfaces\AnnotationsBagInterface 61 | */ 62 | public function getPropertyAnnotationValue($classname, $property, $key) { 63 | // Get the annotations. 64 | $annotations = $this->getReader()->getPropertyAnnotations($classname, $property); 65 | 66 | // Return the key we want from this list of property annotations. 67 | return $annotations->get($key); 68 | } 69 | 70 | /** 71 | * Process the class annotations, adding an entry the $this->models array. 72 | * 73 | * @param $classname 74 | * 75 | * @return mixed 76 | * @throws \Symlink\ORM\Exceptions\RepositoryClassNotDefinedException 77 | * @throws \Symlink\ORM\Exceptions\RequiredAnnotationMissingException 78 | * @throws \Symlink\ORM\Exceptions\UnknownColumnTypeException 79 | */ 80 | public function getProcessed($classname) { 81 | 82 | if (!isset($this->models[$classname])) { 83 | // Get the annotation reader instance. 84 | $class_annotations = $this->getReader()->getClassAnnotations($classname); 85 | 86 | // Validate @ORM_Type 87 | if (!$class_annotations->get('ORM_Type')) { 88 | $this->models[$classname]['validated'] = FALSE; 89 | 90 | throw new \Symlink\ORM\Exceptions\RequiredAnnotationMissingException(sprintf(__('The annotation ORM_Type does not exist on the model %s.'), $classname)); 91 | } 92 | else { 93 | $this->models[$classname]['ORM_Type'] = $class_annotations->get('ORM_Type'); 94 | } 95 | 96 | // Validate @ORM_Table 97 | if (!$class_annotations->get('ORM_Table')) { 98 | $this->models[$classname]['validated'] = FALSE; 99 | throw new \Symlink\ORM\Exceptions\RequiredAnnotationMissingException(sprintf(__('The annotation ORM_Table does not exist on the model %s.'), $classname)); 100 | } 101 | else { 102 | $this->models[$classname]['ORM_Table'] = $class_annotations->get('ORM_Table'); 103 | } 104 | 105 | // Validate @ORM_AllowSchemaUpdate 106 | if (!$class_annotations->get('ORM_AllowSchemaUpdate')) { 107 | $this->models[$classname]['validated'] = FALSE; 108 | throw new \Symlink\ORM\Exceptions\RequiredAnnotationMissingException(sprintf(__('The annotation ORM_AllowSchemaUpdate does not exist on the model.'), $classname)); 109 | } 110 | else { 111 | $this->models[$classname]['ORM_AllowSchemaUpdate'] = filter_var($class_annotations->get('ORM_AllowSchemaUpdate'), FILTER_VALIDATE_BOOLEAN);; 112 | } 113 | 114 | // Validate @ORM_Repository 115 | if ($class_annotations->get('ORM_Repository')) { 116 | if (!class_exists($class_annotations->get('ORM_Repository'))) { 117 | throw new \Symlink\ORM\Exceptions\RepositoryClassNotDefinedException(sprintf(__('Repository class %s does not exist on model %s.'), $class_annotations->get('ORM_Repository'), $classname)); 118 | } 119 | else { 120 | $this->models[$classname]['ORM_Repository'] = $class_annotations->get('ORM_Repository'); 121 | } 122 | } 123 | 124 | // Check the property annotations. 125 | $reflection_class = new \ReflectionClass($classname); 126 | 127 | // Start with blank schema. 128 | $this->models[$classname]['schema'] = []; 129 | 130 | // Loop through the class properties. 131 | foreach ($reflection_class->getProperties() as $property) { 132 | 133 | // Get the annotation of this property. 134 | $property_annotation = $this->getReader() 135 | ->getPropertyAnnotations($classname, $property->name); 136 | 137 | // Silently ignore properties that do not have the ORM_Column_Type annotation. 138 | if ($property_annotation->get('ORM_Column_Type')) { 139 | 140 | $column_type = strtolower($property_annotation->get('ORM_Column_Type')); 141 | 142 | // Test the ORM_Column_Type 143 | if (!in_array($column_type, [ 144 | 'datetime', 145 | 'tinyint', 146 | 'smallint', 147 | 'int', 148 | 'bigint', 149 | 'varchar', 150 | 'tinytext', 151 | 'text', 152 | 'mediumtext', 153 | 'longtext', 154 | 'float', 155 | ]) 156 | ) { 157 | throw new \Symlink\ORM\Exceptions\UnknownColumnTypeException(sprintf(__('Unknown model property column type %s set in @ORM_Column_Type on model %s..'), $column_type, $classname)); 158 | } 159 | 160 | // Build the rest of the schema partial. 161 | $schema_string = $property->name . ' ' . $column_type; 162 | 163 | if ($property_annotation->get('ORM_Column_Length')) { 164 | $schema_string .= '(' . $property_annotation->get('ORM_Column_Length') . ')'; 165 | } 166 | 167 | if ($property_annotation->get('ORM_Column_Null')) { 168 | $schema_string .= ' ' . $property_annotation->get('ORM_Column_Null'); 169 | } 170 | 171 | // Add the schema to the mapper array for this class. 172 | $placeholder_values_type = '%s'; // Initially assume column is string type. 173 | 174 | if (in_array($column_type, [ // If the column is a decimal type. 175 | 'tinyint', 176 | 'smallint', 177 | 'bigint', 178 | ])) { 179 | $placeholder_values_type = '%d'; 180 | } 181 | 182 | if (in_array($column_type, [ // If the column is a float type. 183 | 'float', 184 | ])) { 185 | $placeholder_values_type = '%f'; 186 | } 187 | 188 | $this->models[$classname]['schema'][$property->name] = $schema_string; 189 | $this->models[$classname]['placeholder'][$property->name] = $placeholder_values_type; 190 | } 191 | } 192 | 193 | 194 | } 195 | 196 | // Return the processed annotations. 197 | return $this->models[$classname]; 198 | } 199 | 200 | /** 201 | * Compares a database table schema to the model schema (as defined in th 202 | * annotations). If there any differences, the database schema is modified to 203 | * match the model. 204 | * 205 | * @param $classname 206 | * 207 | * @throws \Symlink\ORM\Exceptions\AllowSchemaUpdateIsFalseException 208 | */ 209 | public function updateSchema($classname) { 210 | global $wpdb; 211 | 212 | // Get the model annotation data. 213 | $mapped = $this->getProcessed($classname); 214 | 215 | // Are we allowed to update the schema of this model in the db? 216 | if (!$mapped['ORM_AllowSchemaUpdate']) { 217 | throw new \Symlink\ORM\Exceptions\AllowSchemaUpdateIsFalseException(sprintf(__('Refused to update model schema %s. ORM_AllowSchemaUpdate is FALSE.'), $classname)); 218 | } 219 | 220 | // Create an ID type string. 221 | $id_type = 'ID'; 222 | $id_type_string = 'ID bigint(20) NOT NULL AUTO_INCREMENT'; 223 | 224 | // Build the SQL CREATE TABLE command for use with dbDelta. 225 | $table_name = $wpdb->prefix . $mapped['ORM_Table']; 226 | 227 | $charset_collate = $wpdb->get_charset_collate(); 228 | 229 | $sql = "CREATE TABLE " . $table_name . " ( 230 | " . $id_type_string . ", 231 | " . implode(",\n ", $mapped['schema']) . ", 232 | PRIMARY KEY (" . $id_type . ") 233 | ) $charset_collate;"; 234 | 235 | // Use dbDelta to do all the hard work. 236 | require_once(ABSPATH . 'wp-admin/includes/upgrade.php'); 237 | dbDelta($sql); 238 | } 239 | 240 | public function dropTable($classname) { 241 | global $wpdb; 242 | 243 | // Get the model annotation data. 244 | $mapped = $this->getProcessed($classname); 245 | 246 | // Are we allowed to update the schema of this model in the db? 247 | if (!$mapped['ORM_AllowSchemaUpdate']) { 248 | throw new \Symlink\ORM\Exceptions\AllowSchemaUpdateIsFalseException(sprintf(__('Refused to drop table for model %s. ORM_AllowSchemaUpdate is FALSE.'), $classname)); 249 | } 250 | 251 | // Drop the table. 252 | $table_name = $wpdb->prefix . $mapped['ORM_Table']; 253 | $sql = "DROP TABLE IF EXISTS " . $table_name; 254 | $wpdb->query($sql); 255 | } 256 | 257 | } 258 | -------------------------------------------------------------------------------- /src/Models/BaseModel.php: -------------------------------------------------------------------------------- 1 | hash = spl_object_hash($this); 26 | } 27 | 28 | /** 29 | * Perform a manual clone of this object. 30 | */ 31 | public function __clone() { 32 | $class_name = get_class($this); 33 | $object = new $class_name; 34 | $schema = Mapping::getMapper()->getProcessed($class_name)['schema']; 35 | 36 | foreach (array_keys($schema) as $property) { 37 | $object->set($property, $this->get($property)); 38 | } 39 | } 40 | 41 | /** 42 | * Getter. 43 | * 44 | * @return string 45 | */ 46 | public function getId() { 47 | return $this->ID; 48 | } 49 | 50 | /** 51 | * @return mixed 52 | */ 53 | public function getHash() { 54 | return $this->hash; 55 | } 56 | 57 | /** 58 | * @return mixed 59 | */ 60 | public function getTableName() { 61 | return Mapping::getMapper()->getProcessed(get_class($this))['ORM_Table']; 62 | } 63 | 64 | /** 65 | * @return mixed 66 | */ 67 | public function getSchema() { 68 | return Mapping::getMapper()->getProcessed(get_class($this))['schema']; 69 | } 70 | 71 | /** 72 | * @return mixed 73 | */ 74 | public function getPlaceholders() { 75 | return Mapping::getMapper()->getProcessed(get_class($this))['placeholder']; 76 | } 77 | 78 | /** 79 | * Return keyed values from this object as per the schema (no ID). 80 | * @return array 81 | */ 82 | public function getAllValues() { 83 | $values = []; 84 | foreach (array_keys($this->getSchema()) as $property) { 85 | $values[$property] = $this->get($property); 86 | } 87 | return $values; 88 | } 89 | 90 | /** 91 | * Return unkeyed values from this object as per the schema (no ID). 92 | * @return bool 93 | */ 94 | public function getAllUnkeyedValues() { 95 | return array_map(function ($key) { 96 | return $this->get($key); 97 | }, array_keys($this->getSchema())); 98 | } 99 | 100 | /** 101 | * Get the raw, underlying value of a property (don't perform a JOIN or lazy 102 | * loaded database query). 103 | * 104 | * @param $property 105 | * 106 | * @return mixed 107 | * @throws \Symlink\ORM\Exceptions\PropertyDoesNotExistException 108 | */ 109 | final public function getDBValue($property) { 110 | return $this->get($property); 111 | } 112 | 113 | /** 114 | * Generic getter. 115 | * 116 | * @param $property 117 | * 118 | * @return mixed 119 | * @throws \Symlink\ORM\Exceptions\PropertyDoesNotExistException 120 | */ 121 | final public function get($property) { 122 | // Check to see if the property exists on the model. 123 | if (!property_exists($this, $property)) { 124 | throw new \Symlink\ORM\Exceptions\PropertyDoesNotExistException(sprintf(__('The property %s does not exist on the model %s.'), $property, get_class($this))); 125 | } 126 | 127 | // If this property is a ManyToOne, check to see if it's an object and lazy 128 | // load it if not. 129 | $many_to_one_class = Mapping::getMapper()->getPropertyAnnotationValue(get_class($this), $property, 'ORM_ManyToOne'); 130 | /** @var string $many_to_one_property */ 131 | $many_to_one_property = Mapping::getMapper()->getPropertyAnnotationValue(get_class($this), $property, 'ORM_JoinProperty'); 132 | 133 | if ($many_to_one_class && $many_to_one_property && !is_object($this->$property)) { 134 | // Lazy load. 135 | $orm = Manager::getManager(); 136 | $object_repository = $orm->getRepository($many_to_one_class); 137 | 138 | $object = $object_repository->findBy([$many_to_one_property => $this->$property]); 139 | 140 | if ($object) { 141 | $this->$property = $object; 142 | } 143 | } 144 | 145 | // Return the value of the field. 146 | return $this->$property; 147 | } 148 | 149 | /** 150 | * Get multiple values from this object given an array of properties. 151 | * 152 | * @param $columns 153 | * @return array 154 | */ 155 | final public function getMultiple($columns) { 156 | $results = []; 157 | 158 | if (is_array($columns)) { 159 | foreach ($columns as $column) { 160 | $results[$column] = $this->get($column); 161 | } 162 | } 163 | 164 | return $results; 165 | } 166 | 167 | /** 168 | * Generic setter. 169 | * 170 | * @param $column 171 | * @param $value 172 | * 173 | * @return bool 174 | * @throws \Symlink\ORM\Exceptions\PropertyDoesNotExistException 175 | */ 176 | final public function set($column, $value) { 177 | // Check to see if the property exists on the model. 178 | if (!property_exists($this, $column)) { 179 | throw new \Symlink\ORM\Exceptions\PropertyDoesNotExistException(sprintf(__('The property %s does not exist on the model %s.'), $column, get_class($this))); 180 | } 181 | 182 | // Update the model with the value. 183 | $this->$column = $value; 184 | 185 | return TRUE; 186 | } 187 | 188 | } -------------------------------------------------------------------------------- /src/Models/IdModel.php: -------------------------------------------------------------------------------- 1 | ID; 23 | } 24 | 25 | } -------------------------------------------------------------------------------- /src/Models/PostMeta.php: -------------------------------------------------------------------------------- 1 | prepare(). 17 | * @var 18 | */ 19 | private $query; 20 | 21 | /** 22 | * Reference to the repository. 23 | * @var \Symlink\ORM\Repositories\BaseRepository 24 | */ 25 | private $repository; 26 | 27 | /** 28 | * QueryBuilder constructor. 29 | */ 30 | public function __construct(BaseRepository $repository) { 31 | // Set some default values. 32 | $this->where = []; 33 | $this->order_by; 34 | $this->limit; 35 | 36 | // And store the sent repository. 37 | $this->repository = $repository; 38 | } 39 | 40 | /** 41 | * Add a WHERE clause to the query. 42 | * 43 | * @param $property 44 | * @param $value 45 | * @param $operator 46 | * 47 | * @return $this 48 | * @throws \Symlink\ORM\Exceptions\InvalidOperatorException 49 | * @throws \Symlink\ORM\Exceptions\PropertyDoesNotExistException 50 | */ 51 | public function where($property, $value, $operator) { 52 | 53 | // Check the property exists. 54 | if (!in_array($property, $this->repository->getObjectProperties()) && $property != 'ID') { 55 | throw new \Symlink\ORM\Exceptions\PropertyDoesNotExistException(sprintf(__('Property %s does not exist in model %s.'), $property, $this->repository->getObjectClass())); 56 | } 57 | 58 | // Check the operator is valid. 59 | if (!in_array($operator, [ 60 | '<', 61 | '<=', 62 | '=', 63 | '!=', 64 | '>', 65 | '>=', 66 | 'IN', 67 | 'NOT IN' 68 | ]) 69 | ) { 70 | throw new \Symlink\ORM\Exceptions\InvalidOperatorException(sprintf(__('Operator %s is not valid.'), $operator)); 71 | } 72 | 73 | // Add the entry. 74 | $this->where[] = [ 75 | 'property' => $property, 76 | 'operator' => $operator, 77 | 'value' => $value, 78 | 'placeholder' => $this->repository->getObjectPropertyPlaceholders()[$property] 79 | ]; 80 | 81 | return $this; 82 | } 83 | 84 | /** 85 | * Set the ORDER BY clause. 86 | * 87 | * @param $property 88 | * @param $operator 89 | * 90 | * @return $this 91 | * @throws \Symlink\ORM\Exceptions\InvalidOperatorException 92 | * @throws \Symlink\ORM\Exceptions\PropertyDoesNotExistException 93 | */ 94 | public function orderBy($property, $operator) { 95 | 96 | // Check the property exists. 97 | if (!in_array($property, $this->repository->getObjectProperties()) && $property != 'ID') { 98 | throw new \Symlink\ORM\Exceptions\PropertyDoesNotExistException(sprintf(__('Property %s does not exist in model %s.'), $property, $this->repository->getObjectClass())); 99 | } 100 | 101 | // Check the operator is valid. 102 | if (!in_array($operator, [ 103 | 'ASC', 104 | 'DESC', 105 | ]) 106 | ) { 107 | throw new \Symlink\ORM\Exceptions\InvalidOperatorException(sprintf(__('Operator %s is not valid.'), $operator)); 108 | } 109 | 110 | // Save it 111 | $this->order_by = "ORDER BY " . $property . " " . $operator . " 112 | "; 113 | 114 | return $this; 115 | } 116 | 117 | /** 118 | * Set the limit clause. 119 | * 120 | * @param $count 121 | * @param int $offset 122 | * 123 | * @return $this 124 | */ 125 | public function limit($count, $offset = 0) { 126 | // Ignore if not valid. 127 | if (is_numeric($offset) && is_numeric($count) && $offset >= 0 && $count > 0) { 128 | $this->limit = "LIMIT " . $count . " OFFSET " . $offset . " 129 | "; 130 | } 131 | 132 | return $this; 133 | } 134 | 135 | /** 136 | * Build the query and process through $wpdb->prepare(). 137 | * @return $this 138 | */ 139 | public function buildQuery() { 140 | global $wpdb; 141 | 142 | $values = []; 143 | 144 | $sql = "SELECT * FROM " . $wpdb->prefix . $this->repository->getDBTable() . " 145 | "; 146 | 147 | // Combine the WHERE clauses and add to the SQL statement. 148 | if (count($this->where)) { 149 | $sql .= "WHERE 150 | "; 151 | 152 | $combined_where = []; 153 | foreach ($this->where as $where) { 154 | 155 | // Operators is not "IN" or "NOT IN" 156 | if ($where['operator'] != 'IN' && $where['operator'] != 'NOT IN') { 157 | $combined_where[] = $where['property'] . " " . $where['operator'] . " " . $where['placeholder'] . " 158 | "; 159 | $values[] = $where['value']; 160 | } 161 | // Operator is "IN" or "NOT IN" 162 | else { 163 | // Fail silently. 164 | if (is_array($where['value'])) { 165 | $combined_where[] = $where['property'] . " " . $where['operator'] . " (" . implode(", ", array_pad([], count($where['value']), $where['placeholder'])) . ") 166 | "; 167 | 168 | $values = array_merge($values, $where['value']); 169 | } 170 | } 171 | } 172 | 173 | $sql .= implode(' AND ', $combined_where); // @todo - should allow more than AND in future. 174 | } 175 | 176 | // Add the ORDER BY clause. 177 | if ($this->order_by) { 178 | $sql .= $this->order_by; 179 | } 180 | 181 | // Add the LIMIT clause. 182 | if ($this->limit) { 183 | $sql .= $this->limit; 184 | } 185 | 186 | // Save it. 187 | $this->query = $wpdb->prepare($sql, $values); 188 | return $this; 189 | } 190 | 191 | /** 192 | * Run the query returning either a single object or an array of objects. 193 | * 194 | * @param bool $always_array 195 | * 196 | * @return array|bool|mixed 197 | * @throws \Symlink\ORM\Exceptions\NoQueryException 198 | */ 199 | public function getResults($always_array = FALSE) { 200 | global $wpdb; 201 | 202 | if ($this->query) { 203 | 204 | // Classname for this repository. 205 | $object_classname = $this->repository->getObjectClass(); 206 | 207 | // Loop through the database results, building the objects. 208 | $objects = array_map(function ($result) use(&$object_classname) { 209 | 210 | // Create a new blank object. 211 | $object = new $object_classname(); 212 | 213 | // Fill in all the properties. 214 | array_walk($result, function ($value, $property) use (&$object) { 215 | $object->set($property, $value); 216 | }); 217 | 218 | // Track the object. 219 | $em = Manager::getManager(); 220 | $em->track($object); 221 | 222 | // Save it. 223 | return $object; 224 | }, $wpdb->get_results($this->query)); 225 | 226 | // There were no results. 227 | if (!count($objects)) { 228 | return FALSE; 229 | } 230 | 231 | // Return just an object if there was only one result. 232 | if (count($objects) == 1 && !$always_array) { 233 | return $objects[0]; 234 | } 235 | 236 | // Otherwise, the return an array of objects. 237 | return $objects; 238 | 239 | } 240 | else { 241 | throw new \Symlink\ORM\Exceptions\NoQueryException(__('No query was built. Run ->buildQuery() first.')); 242 | } 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /src/Repositories/BaseRepository.php: -------------------------------------------------------------------------------- 1 | classname = $classname; 21 | $this->annotations = $annotations; 22 | } 23 | 24 | /** 25 | * @param $classname 26 | * @param $annotations 27 | * 28 | * @return \Symlink\ORM\Repositories\BaseRepository 29 | */ 30 | public static function getInstance($classname, $annotations) { 31 | // Get the class (as this could be a child of BaseRepository) 32 | $this_repository_class = get_called_class(); 33 | 34 | // Return a new instance of the class. 35 | return new $this_repository_class($classname, $annotations); 36 | } 37 | 38 | /** 39 | * @return \Symlink\ORM\QueryBuilder 40 | */ 41 | public function createQueryBuilder() { 42 | return new QueryBuilder($this); 43 | } 44 | 45 | /** 46 | * Getter used in the query builder. 47 | * @return mixed 48 | */ 49 | public function getObjectClass() { 50 | return $this->classname; 51 | } 52 | 53 | /** 54 | * Getter used in the query builder 55 | * @return mixed 56 | */ 57 | public function getDBTable() { 58 | return $this->annotations['ORM_Table']; 59 | } 60 | 61 | /** 62 | * Getter used in the query builder. 63 | * @return array 64 | */ 65 | public function getObjectProperties() { 66 | return array_merge( 67 | ['ID'], 68 | array_keys($this->annotations['schema']) 69 | ); 70 | } 71 | 72 | /** 73 | * Getter used in the query builder. 74 | * @return array 75 | */ 76 | public function getObjectPropertyPlaceholders() { 77 | return array_merge( 78 | ['ID' => '%d'], 79 | $this->annotations['placeholder'] 80 | ); 81 | } 82 | 83 | /** 84 | * Find a single object by ID. 85 | * 86 | * @param $id 87 | * 88 | * @return array|bool|mixed 89 | */ 90 | public function find($id) { 91 | return $this->createQueryBuilder() 92 | ->where('ID', $id, '=') 93 | ->orderBy('ID', 'ASC') 94 | ->buildQuery() 95 | ->getResults(); 96 | } 97 | 98 | /** 99 | * Return all objects of this type. 100 | * 101 | * @return array|bool|mixed 102 | */ 103 | public function findAll() { 104 | return $this->createQueryBuilder() 105 | ->orderBy('ID', 'ASC') 106 | ->buildQuery() 107 | ->getResults(); 108 | } 109 | 110 | /** 111 | * Returns all objects with matching property value. 112 | * 113 | * @param array $criteria 114 | * 115 | * @return array 116 | */ 117 | public function findBy(array $criteria) { 118 | $qb = $this->createQueryBuilder(); 119 | foreach ($criteria as $property => $value) { 120 | $qb->where($property, $value, '='); 121 | } 122 | return $qb->orderBy('ID', 'ASC') 123 | ->buildQuery() 124 | ->getResults(); 125 | } 126 | 127 | } 128 | -------------------------------------------------------------------------------- /src/Repositories/PostsRepository.php: -------------------------------------------------------------------------------- 1 |