├── .gitignore ├── .idea ├── .gitignore ├── Orm.iml ├── codeStyles │ └── codeStyleConfig.xml ├── deployment.xml ├── encodings.xml ├── inspectionProfiles │ └── profiles_settings.xml ├── misc.xml ├── modules.xml ├── php-test-framework.xml ├── php.xml └── vcs.xml ├── .travis.yml ├── LICENSE ├── README.md ├── composer.json ├── composer.lock ├── docs ├── behaviours.md ├── cookbook_custom_entities.md ├── cookbook_polymorphic_associations.md ├── cookbook_table_inheritance.md ├── cookbook_testing.md ├── cookbook_validation.md ├── couscous.yml ├── direct_queries.md ├── entities.md ├── index.md ├── limits.md ├── mappers.md ├── queries.md ├── relation_aggregate.md ├── relation_eav.md ├── relation_many_to_many.md ├── relation_many_to_one.md ├── relation_one_to_many.md ├── relation_one_to_one.md ├── relations.md ├── the_actions.md ├── the_casting_manager.md ├── the_guards.md ├── the_query_scopes.md └── the_tracker.md ├── phpunit.mysql.xml ├── phpunit.xml ├── src ├── Action │ ├── ActionInterface.php │ ├── AttachEntities.php │ ├── BaseAction.php │ ├── Delete.php │ ├── DeletePivotRows.php │ ├── DetachEntities.php │ ├── Insert.php │ ├── SoftDelete.php │ └── Update.php ├── Behaviour │ ├── BehaviourInterface.php │ ├── SoftDelete.php │ └── Timestamps.php ├── CastingManager.php ├── Collection │ ├── Collection.php │ └── PaginatedCollection.php ├── Connection.php ├── ConnectionLocator.php ├── Entity │ ├── Behaviours.php │ ├── EntityInterface.php │ ├── GenericEntity.php │ ├── GenericEntityHydrator.php │ ├── HydratorInterface.php │ ├── LazyAggregate.php │ ├── LazyLoader.php │ ├── LazyRelation.php │ ├── StateEnum.php │ └── Tracker.php ├── Exception │ └── FailedActionException.php ├── Helpers │ ├── Arr.php │ ├── Inflector.php │ ├── QueryHelper.php │ └── Str.php ├── Mapper.php ├── MapperConfig.php ├── Orm.php ├── Query.php ├── QueryBuilder.php └── Relation │ ├── Aggregate.php │ ├── HasAggregates.php │ ├── ManyToMany.php │ ├── ManyToOne.php │ ├── OneToMany.php │ ├── OneToOne.php │ ├── Relation.php │ ├── RelationBuilder.php │ └── RelationConfig.php └── tests ├── Action ├── DeleteTest.php ├── FakeThrowsException.php ├── InsertTest.php └── UpdateTest.php ├── BaseTestCase.php ├── Behaviour ├── FakeThrowsException.php ├── SoftDeleteTest.php └── TimestampsTest.php ├── CastingManagerTest.php ├── Entity ├── FakeEntity.php └── ProductEntity.php ├── Helpers ├── ArrTest.php └── StrTest.php ├── MapperTest.php ├── OrmTest.php ├── QueryTest.php ├── Relation ├── ManyToManyTest.php ├── ManyToOneTest.php ├── OneToManyTest.php ├── OneToOneTest.php └── RelationTest.php ├── benchmark ├── AbstractTestSuite.php ├── SiriusOrmTestSuite.php ├── TestRunner.php └── sfTimer.php ├── bootstrap.php └── resources ├── mappers ├── categories.php ├── content_products.php ├── images.php ├── products.php └── tags.php └── tables ├── generic.php └── mysql.php /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | build/ 3 | php-cs-fixer.phar 4 | phpcbf.phar 5 | phpcs.phar 6 | .couscous/ 7 | couscous.phar 8 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /workspace.xml -------------------------------------------------------------------------------- /.idea/Orm.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/deployment.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/php-test-framework.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.idea/php.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 7.1 5 | - 7.2 6 | - 7.3 7 | - 7.4 8 | 9 | matrix: 10 | allow_failures: 11 | - php: 7.1 12 | 13 | before_script: 14 | - wget -c https://squizlabs.github.io/PHP_CodeSniffer/phpcs.phar 15 | - composer self-update 16 | - composer install --prefer-source 17 | 18 | script: 19 | - mkdir -p build/logs 20 | - composer run-script cs 21 | - composer run-script test 22 | 23 | after_script: 24 | - wget https://scrutinizer-ci.com/ocular.phar 25 | - php ocular.phar code-coverage:upload --format=php-clover build/logs/clover.xml 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Adrian Miu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [![Source Code](http://img.shields.io/badge/source-siriusphp/orm-blue.svg?style=flat-square)](https://github.com/siriusphp/orm) 3 | [![Latest Version](https://img.shields.io/packagist/v/siriusphp/orm.svg?style=flat-square)](https://github.com/siriusphp/orm/releases) 4 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](https://github.com/siriusphp/orm/blob/master/LICENSE) 5 | [![Build Status](https://img.shields.io/travis/siriusphp/orm/master.svg?style=flat-square)](https://travis-ci.org/siriusphp/orm) 6 | [![Coverage Status](https://img.shields.io/scrutinizer/coverage/g/siriusphp/orm.svg?style=flat-square)](https://scrutinizer-ci.com/g/siriusphp/orm/code-structure) 7 | [![Quality Score](https://img.shields.io/scrutinizer/g/siriusphp/orm.svg?style=flat-square)](https://scrutinizer-ci.com/g/siriusphp/orm) 8 | 9 | Sirius ORM is a [fast and lightweight](https://github.com/adrianmiu/forked-php-orm-benchmark) yet flexible data mapper solution developed with DX in mind. It offers: 10 | 11 | 1. Mapping rows to your own entities 12 | 2. Relations and relation aggregates (COUNT/AVERAGE) 13 | 3. Eager-loading & lazy-loading (without increasing the number of queries) 14 | 4. Queries that let you JOIN with relations (not tables) 15 | 5. Deep persistence 16 | 6. Dynamically defined mappers 17 | 7. Speed & low memory usage (no Entity Manager) 18 | 8. 90+% code coverage 19 | 20 | ### Installation 21 | 22 | ``` 23 | composer require siriusphp/orm 24 | ``` 25 | 26 | ### Initialization 27 | 28 | ```php 29 | use Sirius\Orm\Orm; 30 | use Sirius\Orm\ConnectionLocator; 31 | $connectionLocator = ConnectionLocator::new( 32 | 'mysql:host=localhost;dbname=testdb', 33 | 'username', 34 | 'password' 35 | ); 36 | $orm = new Orm($connectionLocator); 37 | ``` 38 | 39 | ### Configuration 40 | 41 | AKA, registering mappers and relations 42 | 43 | ```php 44 | $orm->register('pages', MapperConfig::fromArray([ 45 | /** 46 | * here goes the configuration 47 | */ 48 | ])); 49 | 50 | // continue with the rest of mappers 51 | ``` 52 | 53 | ### Usage 54 | 55 | ```php 56 | // find by ID 57 | $page = $orm->find('pages', 1); 58 | // or via the mapper 59 | $page = $orm->get('pages')->find(1); 60 | 61 | // query 62 | $pages = $orm->select('pages') 63 | ->where('status', 'published') 64 | ->orderBy('date desc') 65 | ->limit(10) 66 | ->get(); 67 | 68 | // manipulate 69 | $page->title = 'Best ORM evah!'; 70 | $page->featured_image->path = 'orm_schema.png'; 71 | 72 | // persist 73 | $orm->save($page); 74 | // or via the mapper 75 | $orm->get('pages')->save($page); 76 | ``` 77 | 78 | ### Links 79 | 80 | - [Documentation](https://www.sirius.ro/php/sirius/orm/) -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "siriusphp/orm", 3 | "description": "Powerfull and fast PDO-based data mapper", 4 | "type": "library", 5 | "license": "MIT", 6 | "keywords": [ 7 | "orm", 8 | "datamapper", 9 | "not-activerecord", 10 | "pdo", 11 | "modelling", 12 | "entity framework", 13 | "sql", 14 | "data", 15 | "mapping" 16 | ], 17 | "authors": [ 18 | { 19 | "name": "Adrian Miu", 20 | "email": "adrian@adrianmiu.ro" 21 | } 22 | ], 23 | "minimum-stability": "stable", 24 | "require": { 25 | "php": ">=7.1", 26 | "atlas/pdo": "^1.1", 27 | "siriusphp/sql": "^1.2", 28 | "doctrine/collections": "^1.6", 29 | "symfony/inflector": "^4.4" 30 | }, 31 | "require-dev": { 32 | "phpunit/phpunit": "^8.5", 33 | "mockery/mockery": "^1.3" 34 | }, 35 | "autoload": { 36 | "psr-4": { 37 | "Sirius\\Orm\\": "src/" 38 | } 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { 42 | "Sirius\\Orm\\Tests\\": "tests/" 43 | } 44 | }, 45 | "scripts": { 46 | "cs": [ 47 | "php phpcs.phar --standard=PSR2 ./src" 48 | ], 49 | "cbf": [ 50 | "php phpcbf.phar ./src --standard=PSR2 -w" 51 | ], 52 | "csfix": [ 53 | "php php-cs-fixer.phar fix ./src --rules=@PSR2" 54 | ], 55 | "build-docs": [ 56 | "php couscous.phar generate --target=build/docs/ ./docs" 57 | ], 58 | "docs": [ 59 | "cd docs && php ../couscous.phar preview" 60 | ], 61 | "test": [ 62 | "vendor/bin/phpunit -c phpunit.xml" 63 | ] 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /docs/behaviours.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Behaviours | Sirius ORM 3 | --- 4 | 5 | # Behaviours 6 | 7 | Behaviours are objects that alter the... behaviours of mappers. 8 | 9 | The are attached to the mapper's definition: 10 | 11 | ```php 12 | $orm->register('products', MapperConfig::fromArray([ 13 | // other mapper config goes here 14 | 'behaviours' => [new SoftDelete('deleted_at')] 15 | ])); 16 | ``` 17 | 18 | or on the fly: 19 | 20 | ```php 21 | $orm->get('products')->use(new SoftDelete('deleted_at')); 22 | ``` 23 | 24 | You can disable temporarily one behaviour like this: 25 | 26 | ```php 27 | $orm->get('products')->without('soft_delete') 28 | ->newQuery(); 29 | ``` 30 | 31 | This would clone the mapper and let you work with it under the new configuration. However the registered mapper instance will still be there on the next `$orm->get('products')` 32 | 33 | The Sirius ORM comes with 2 behaviours: 34 | 35 | > ##### Soft Delete 36 | 37 | > ```php 38 | > $orm->get('products') 39 | > ->use(new SoftDelete('name of the column with the date of delete')); 40 | > ``` 41 | 42 | > ##### Timestamps 43 | 44 | > ```php 45 | > $orm->get('products') 46 | > ->use(new Timestamps('column for create', 'column for update')); 47 | > ``` 48 | 49 | ## Temporarily disable behaviours 50 | 51 | Behaviours should be active all the times but sometimes you need to disable them for a short period of time. In the case of Soft Deletes you may want to query entities that were deleted. If you want o restore an entity you can do the following: 52 | 53 | ```php 54 | $allProductsMapper = $orm->get('products')->without('soft_deletes'); 55 | $deletedProduct = $allProductsMapper->find($id); 56 | $deletedProduct->deleted_at = null; 57 | $allProductMapper->save($deletedProduct); 58 | ``` 59 | 60 | ## Create your own behaviours 61 | 62 | Behaviours can intercept the result of various methods in the mapper and can alter that result or provide a new one. 63 | 64 | For example, the `SoftDelete` behaviour intercepts the `Delete` action that is generated when you call `$mapper->delete($entity)` and returns another action of class `SoftDelete` which performs an update. It also intercepts new queries and sets a 65 | guard `['deleted_at' => null]`. 66 | 67 | The mechanism by which this happens is this: when a mapper's method is called and it is allowed to be intercepted by behaviours the mappers will try to call the `on[method]` methods on the attached behaviours. 68 | 69 | The `SoftDelete` behaviours implements `onDelete()` (for the mapper's `delete` method) and `onNewQuery` (for the mapper's `newQuery` method). 70 | 71 | Below is the list of methods that can be intercepted by behaviours: 72 | 73 | - `newEntity` - used to create a new entity (from an array or a table rows) 74 | - `extractFromEntity` - used to extract the columns that have to be persisted 75 | - `save` 76 | - `delete` 77 | - `newQuery` 78 | 79 | Check out the `SoftDelete` and `Timestamps` classes for inspiration -------------------------------------------------------------------------------- /docs/cookbook_custom_entities.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Cookbook - Custom entities | Sirius ORM 3 | --- 4 | 5 | # Cookbook - Custom entities 6 | 7 | Out of the box the Sirius ORM provides a `GenericEntity` class to get you started as soon as possible. 8 | 9 | It allows you to work with entities like you would a plain object: 10 | 11 | ```php 12 | $product->title = 'New product'; 13 | echo $product->category->name; // where category could be eager or lazy loaded 14 | ``` 15 | 16 | If you prefer another style like using getters and setters or whatever here's what you have to do: 17 | 18 | #### 1. Create the entity class 19 | 20 | - it has to implement the `EntityInterface` interface. Sorry, another one of those nasty trade-offs. 21 | - implement a way to set/retrieve properties on the entity instance 22 | 23 | Here's an example for `Category` entity that uses getters and setters 24 | 25 | ```php 26 | use Sirius\Orm\Entity\EntityInterface; 27 | 28 | class Category implements EntityInterface 29 | { 30 | protected $id; 31 | protected $name; 32 | protected $parent_id; 33 | protected $parent; 34 | protected $products; 35 | 36 | protected $_state; 37 | 38 | protected $_changes; 39 | 40 | public function getId() 41 | { 42 | return $this->id; 43 | } 44 | 45 | public function setId($val){ 46 | $this->id = $val; 47 | } 48 | 49 | public function getPersistenceState() { 50 | return $this->_state; 51 | } 52 | 53 | public function setPersistenceState($state) { 54 | $this->_state = $state; 55 | } 56 | 57 | public function getArrayCopy() { 58 | return [ 59 | 'id' => $this->id, 60 | 'name' => $this->name, 61 | 'parent_id' => $this->parent_id, 62 | 'parent' => $this->parent ? $this->parent->getArrayCopy() : null, 63 | // ... you get the idea 64 | ]; 65 | } 66 | 67 | public function getChanges() { 68 | return $this->_changes; 69 | } 70 | 71 | // getters and setters 72 | public function getName() { 73 | return $this->name; 74 | } 75 | 76 | public function setName($name) { 77 | $current = $this->name; 78 | $this->name = $name; 79 | if ($name !== $current) { 80 | $this->_changes['name'] = true; 81 | } 82 | } 83 | } 84 | ``` 85 | 86 | #### 2. Create the entity hydrator class 87 | 88 | The hydrator for this entity should be able to transform an array (representing the row in the DB) and build a new entity 89 | 90 | ```php 91 | use Sirius\Orm\Entity\HydratorInterface; 92 | use Sirius\Orm\Entity\EntityInterface; 93 | 94 | class CategoryHydrator implements HydratorInterface { 95 | 96 | public function hydrate($attributes = []){ 97 | $category = new Category; 98 | $category->setId($attributes['id']); 99 | $category->setName($attributes['name']); 100 | $category->setParentId($attributes['parent_id']); 101 | } 102 | 103 | public function extract(EntityInterface $entity) { 104 | /** 105 | * Extract entity attributes that are to be persisted to the database 106 | */ 107 | } 108 | 109 | public function set($entity, $attribute, $value) { 110 | // set a single attribute 111 | } 112 | 113 | public function get($entity, $attribute) { 114 | // get a single attribute 115 | } 116 | } 117 | ``` 118 | 119 | Check out `GenericEntityHydrator` class for inspiration. 120 | 121 | #### 3. Create a mapper class 122 | 123 | For this you need to alter the behavior of `setEntityAttribute` and `getEntityAttribute` methods 124 | 125 | ```php 126 | use Sirius\Orm\Mapper; 127 | 128 | class CategoryMapper extends Mapper { 129 | protected $table = 'category'; 130 | protected $primaryKey = 'id'; 131 | // here goes the rest of the mapper's properties 132 | 133 | public function setEntityAttribute(EntityInterface $entity, $attribute, $value) 134 | { 135 | $setter = 'set_' . $attribute; 136 | return $entity->{$setter}($value); 137 | } 138 | 139 | public function getEntityAttribute(EntityInterface $entity, $attribute) 140 | { 141 | $getter = 'get_' . $attribute; 142 | return $entity->{$getter}(); 143 | } 144 | 145 | } 146 | ``` 147 | 148 | #### 4. Register the mapper in the ORM using a factory 149 | 150 | ```php 151 | $orm->register('categories', function($orm) { 152 | return new CategoryMapper($orm, new CategoryHydrator()); 153 | }); 154 | ``` 155 | 156 | If your mappers are complex and you are using a DiC you can do this: 157 | 158 | ```php 159 | $orm->register('categories', function($orm) use ($di) { 160 | return new CategoryMapper($orm, new CategoryHydrator(), $di->get('someService')); 161 | }); 162 | ``` 163 | 164 | **Warning!** The Sirius ORM internals makes some assumptions about the results of various values returned by entity methods: 165 | 166 | 1. One-to-many and many-to-many relations are attached to entities as Collections (which extend Doctrine's ArrayCollection class) 167 | 2. `getChanges()` is used to determine the changes to be persisted. Since there is no Entity Manager to track them, the entity is in responsible for tracking the changes. 168 | 169 | -------------------------------------------------------------------------------- /docs/cookbook_polymorphic_associations.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Cookbook - Polymorphic associations | Sirius ORM 3 | --- 4 | 5 | # Cookbook - Polymorphic associations 6 | 7 | There are situations where you want to use a single table to store one type of entities (eg: comments) that related to multiple other types of entities (products, blog posts etc) which reside in different tables. 8 | In this example the `comments` table would have to include, beside the ID of the attached entity (eg: product) an additional column (eg: `commentable_type`) that refers to the type of entity that is linked to. 9 | This column is called a "discriminator" column. 10 | 11 | While other ORMs use the concept of "morphs" for this type of relations we try to keep things as simple as possible so this can be achieved using regular relations and [guards](the_guards). 12 | 13 | 14 | ## Solution 1. Using guards on relations 15 | 16 | This is how you would define the "one-to-many" relation between a product and it's comments where the `comments` table is also used by other entities 17 | 18 | ```php 19 | use Sirius\Orm\MapperConfig; 20 | use Sirius\Orm\Relation\RelationConfig; 21 | 22 | $productsConfig = MapperConfig::fromArray([ 23 | MapperConfig::ENTITY_CLASS => 'App\Entity\Product', 24 | MapperConfig::TABLE => 'products', 25 | MapperConfig::RELATIONS => [ 26 | 'comments' => [ 27 | RelationConfig::TYPE => RelationConfig::TYPE_ONE_TO_MANY, 28 | RelationConfig::FOREIGN_MAPPER => 'comments', // name of the comments mapper as registered in the ORM 29 | RelationConfig::FOREIGN_KEY => 'commentable_id', 30 | RelationConfig::FOREIGN_GUARDS => ['commentable_type' => 'product'], // That's it! 31 | ] 32 | ] 33 | ]); 34 | $orm->register('products', $productsConfig); 35 | ``` 36 | 37 | After this set up all queries made on the products' related comments will have the guards added and any comment related to a product that is persisted to the database will have it's `commentable_type` column automatically set to 'product' 38 | 39 | ## Solution 2. Using guards on mappers 40 | 41 | For this solution you define a specific mapper for the "Product Comments" which contains the guards and make the products have many "product comments", like so: 42 | 43 | ```php 44 | use Sirius\Orm\MapperConfig; 45 | use Sirius\Orm\Relation\RelationConfig; 46 | 47 | $productCommentsConfig = MapperConfig::fromArray([ 48 | MapperConfig::ENTITY_CLASS => 'App\Entity\ProductComment', 49 | MapperConfig::TABLE => 'comments', 50 | MapperConfig::GUARDS => ['commentable_type' => 'product'] 51 | ]); 52 | 53 | $productsConfig = MapperConfig::fromArray([ 54 | MapperConfig::ENTITY_CLASS => 'App\Entity\Product', 55 | MapperConfig::TABLE => 'products', 56 | MapperConfig::RELATIONS => [ 57 | 'comments' => [ 58 | RelationConfig::TYPE => RelationConfig::TYPE_ONE_TO_MANY, 59 | RelationConfig::FOREIGN_MAPPER => 'product_comments', 60 | RelationConfig::FOREIGN_KEY => 'commentable_id' 61 | ] 62 | ] 63 | ]); 64 | $orm->register('product_comments', $productCommentsConfig); 65 | $orm->register('products', $productsConfig); 66 | ``` 67 | 68 | This solution relies on the mapper to ensure the proper value is set on the `commentable_type` column at the time it is persisted. -------------------------------------------------------------------------------- /docs/cookbook_table_inheritance.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Cookbook - table inheritance | Sirius ORM 3 | --- 4 | 5 | # Cookbook - Table inheritance 6 | 7 | Sometimes you need to use the same table to store multiple type of entities. If you are familiar with Wordpress, the `posts` table stores pages, posts and custom content types. 8 | 9 | ## Solution 1. One mapper per entity type 10 | 11 | If you don't want the mapper to query multiple entity types you can use [guards](the_guards.md) you configure the mapper like this 12 | 13 | ```php 14 | $pageConfig = MapperConfig::fromArray([ 15 | MapperConfig::ENTITY_CLASS => 'App\Entity\Content\Page', 16 | MapperConfig::TABLE => 'content', 17 | MapperConfig::GUARDS => ['content_type' => 'page'] 18 | ]); 19 | $orm->register('pages', $pageConfig); 20 | ``` 21 | 22 | ## Solution 2. Custom entity hydrator 23 | 24 | If you want to query the mapper for all types of content BUT you want the rows in the table to be converted into different entity types you need a custom Hydrator. 25 | 26 | ```php 27 | use Sirius\Orm\GenericEntityHydrator; 28 | 29 | class CustomHydrator extends GenericEntityHydrator { 30 | public function hydrate($arr) { 31 | if ($arr['content_type'] == 'Post') { 32 | return new Page($arr); 33 | } 34 | return new Content($arr); 35 | } 36 | } 37 | ``` 38 | 39 | ```php 40 | $customEntityHydrator = new CustomHydrator; 41 | 42 | $contentConfig = MapperConfig::fromArray([ 43 | MapperConfig::ENTITY_CLASS => 'App\Entity\Content', 44 | MapperConfig::TABLE => 'content', 45 | MapperConfig::ENTITY_FACTORY => $customEntityHydrator 46 | ]); 47 | $orm->register('content', $contentConfig); 48 | ``` 49 | 50 | In this case the `$customEntityFactoryInstance` will be in charge of converting a row into the proper type of content 51 | 52 | **Warning!** This solution poses some problems 53 | 54 | 1. Since relations are defined on a per-mapper basis, all the relations attached to the "content" mapper are considered to be applicable for all generated entities (pages, posts, products). 55 | Thus, the ORM will try to attach relations where you might not want to (eg: pages don't have many "price_rules" like products may have) 56 | 2. You are in charge of setting the `content_type` attribute on the entity so persistence works properly 57 | 3. Before persisting an entity the mapper checks if it can do it by invoking `assertCanPersistEntity()`. 58 | If you are not using a custom mapper to overwrite this method it will check if the entity that's about to be persisted has the same class as 59 | the `$mapper->entityClass` property. So you would need to make sure that your entity classes have the proper inheritance structure. 60 | 61 | For this reason we recommend using guards to create specific mappers and a general-purpose mapper that allows you to interact with the entities in a "unified" way. -------------------------------------------------------------------------------- /docs/cookbook_testing.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Cookbook - Testing | Sirius ORM 3 | --- 4 | 5 | # Cookbook - Testing 6 | 7 | TBD -------------------------------------------------------------------------------- /docs/cookbook_validation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Cookbook - Entity validation | Sirius ORM 3 | --- 4 | 5 | # Cookbook - Entity validation 6 | 7 | TBD -------------------------------------------------------------------------------- /docs/couscous.yml: -------------------------------------------------------------------------------- 1 | template: 2 | url: https://github.com/siriusphp/Template-ReadTheDocs 3 | 4 | # List of directories to exclude from the processing (default contains "vendor" and "website") 5 | # Paths are relative to the repository root 6 | exclude: 7 | - website 8 | - vendor 9 | - test 10 | - src 11 | - build 12 | 13 | # Base URL of the published website (no "/" at the end!) 14 | # You are advised to set and use this variable to write your links in the HTML layouts 15 | baseUrl: https://www.sirius.ro/php/sirius/orm 16 | paypal: https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=SGXDKNJCXFPJU 17 | gacode: UA-535999-18 18 | 19 | projectName: Sirius\Orm 20 | title: Sirius\Orm 21 | subTitle: Fast and flexible PHP ORM solution built with DX in mind 22 | 23 | # The left menu bar 24 | menu: 25 | sections: 26 | # versions: 27 | # name: Versions 28 | # items: 29 | # two: 30 | # text: "2.0" 31 | # relativeUrl: 32 | # one: 33 | # text: "1.0" 34 | # relativeUrl: 1_0/ 35 | guide: 36 | name: Getting started 37 | items: 38 | getting_started: 39 | text: Introduction 40 | relativeUrl: 41 | mappers: 42 | text: Mappers 43 | relativeUrl: mappers.html 44 | insert: 45 | text: Querying entities 46 | relativeUrl: queries.html 47 | update: 48 | text: Working with entities 49 | relativeUrl: entities.html 50 | delete: 51 | text: Behaviours 52 | relativeUrl: behaviours.html 53 | other: 54 | text: Direct queries 55 | relativeUrl: direct_queries.html 56 | query_scopes: 57 | text: Query scopes 58 | relativeUrl: the_query_scopes.html 59 | limits: 60 | text: Limitations 61 | relativeUrl: limits.html 62 | relations: 63 | name: Relations 64 | items: 65 | getting_started: 66 | text: Basics 67 | relativeUrl: relations.html 68 | one_to_one: 69 | text: One-to-one 70 | relativeUrl: relation_one_to_one.html 71 | one_to_many: 72 | text: One-to-many 73 | relativeUrl: relation_one_to_many.html 74 | many_to_one: 75 | text: Many-to-one 76 | relativeUrl: relation_many_to_one.html 77 | many_to_many: 78 | text: Many-to-many 79 | relativeUrl: relation_many_to_many.html 80 | eav: 81 | text: EAV 82 | relativeUrl: relation_eav.html 83 | aggregatges: 84 | text: Aggregates 85 | relativeUrl: relation_aggregate.html 86 | cookbook: 87 | name: Cookbook 88 | items: 89 | custom_entities: 90 | text: Custom entities 91 | relativeUrl: cookbook_custom_entities.html 92 | table-inheritance: 93 | text: Table inheritance 94 | relativeUrl: cookbook_table_inheritance.html 95 | polymorphic_associations: 96 | text: Polymorphic associations 97 | relativeUrl: cookbook_polymorphic_associations.html 98 | validation: 99 | text: Entity validation 100 | relativeUrl: cookbook_validation.html 101 | testing: 102 | text: Testing 103 | relativeUrl: cookbook_testing.html 104 | advanced: 105 | name: Advanced 106 | items: 107 | guards: 108 | text: The guards 109 | relativeUrl: the_guards.html 110 | actions: 111 | text: The actions 112 | relativeUrl: the_actions.html 113 | tracker: 114 | text: The tracker 115 | relativeUrl: the_tracker.html 116 | casting: 117 | text: The casting manager 118 | relativeUrl: the_casting_manager.html -------------------------------------------------------------------------------- /docs/direct_queries.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Direct queries | Sirius ORM 3 | --- 4 | 5 | # Direct queries 6 | 7 | Sometimes you want to execute queries on the mapper but you don't care for entities. For example you might want to count the number of comments per a list of articles, or get the average rating for some products 8 | 9 | Constructing queries work the same as normal queries except the last part where you ask for the results 10 | 11 | ```php 12 | $orm->get('reviews') 13 | ->join('INNER', 'products', 'products.id = reviews.product_id') 14 | ->where('products.name', 'Gold', 'contains') 15 | ->groupBy('product_id') 16 | ->select('product_id', 'AVERAGE(rating) as rating') 17 | ->fetchKeyPair(); 18 | ``` 19 | 20 | Since Sirius\ORM uses Sirius\Sql which in turn uses Atlas\Connection which are glorified PDO connections the methods available on PDO statements for retrieving results are also available on the ORM queries: 21 | 22 | - `fetchAll` 23 | - `fetchColumn`(int $column = 0) 24 | - `fetchGroup`(int $style = PDO::FETCH_COLUMN) 25 | - `fetchKeyPair`() 26 | - `fetchObject`(string $class = 'stdClass', array $args = []) 27 | - `fetchObjects`(string $class = 'stdClass', array $args = []) 28 | - `fetchOne`() 29 | - `fetchUnique`() 30 | - `fetchValue`(int $column = 0) 31 | 32 | Here's another example for counting some matching rows: 33 | 34 | ```php 35 | $orm->get('reviews') 36 | ->join('INNER', 'products', 'products.id = reviews.product_id') 37 | ->where('products.name', 'Gold', 'contains') 38 | ->select('COUNT(reviews.id) as total_gold_reviews') 39 | ->fetchValue(); 40 | ``` 41 | 42 | ## Reusing queries 43 | 44 | You can reuse queries to minimise the potential for errors: 45 | 46 | ```php 47 | $query = $orm->get('reviews') 48 | ->join('INNER', 'products', 'products.id = reviews.product_id') 49 | ->where('products.name', 'Gold', 'contains') 50 | ->groupBy('product_id'); 51 | 52 | $ratings = $query->select('product_id', 'AVERAGE(rating) as rating')->fetchKeyPair(); 53 | 54 | $count = $query->resetGroupBy() 55 | ->resetColumns() 56 | ->select('COUNT(reviews.id) as total_gold_reviews') 57 | ->fetchValue(); 58 | ``` -------------------------------------------------------------------------------- /docs/entities.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Working with entities | Sirius ORM 3 | --- 4 | 5 | # Working with entities 6 | 7 | ## Creating new entities 8 | 9 | You can ask the mapper to generate new entities for you using the hydrator. 10 | This will free you from having to build them yourself as it will also construct the proper relations 11 | 12 | ```php 13 | $product = $orm->get('products') 14 | ->newEntity([ 15 | 'name' => 'iPhone XXS', 16 | 'sku' => 'ixxs', 17 | 'price'=> 1000.50, 18 | 'category' => [ 19 | 'name' => 'Smart phones', 20 | ], 21 | // and on and on... 22 | ]); 23 | ``` 24 | 25 | The above code will construct a Category entity and associate it with the Product entity and so on and so forth. 26 | 27 | ## Manipulating the entities 28 | 29 | Once you have an entity from the mapper (from `newEntity` or from a querry) you can manipulate it as described by its interface. In the case of the `GenericEntity` class you can perform things like 30 | 31 | ```php 32 | $product = $orm->find('products', 1); 33 | $product->category->name = 'New category name'; // this works with lazy loading 34 | $product->images->get(0)->path = 'new_image.jpg'; // this too 35 | ``` 36 | 37 | One-to-many and Many-to-many relations return Collections, which extend the Doctrine's [ArrayCollection](https://www.doctrine-project.org/projects/doctrine-collections/en/1.6/index.html) so you can do things like 38 | 39 | ```php 40 | if (!$product->images->isEmpty()) { 41 | $product->images->first(); 42 | } 43 | 44 | $paths = $product->images->map(function($image) { 45 | return $image->path; 46 | }); 47 | ``` 48 | 49 | ## Persisting the entities 50 | 51 | ```php 52 | $orm->get('products')->save($product); 53 | ``` 54 | 55 | It's that simple! The actions required for persisting an entity are wrapped in a transaction and the ORM will search for all the changes in the "entity tree" and perform the necessary persisting action. You can learn more about the 56 | persistence actions [here](the_actions.md) 57 | 58 | If you don't want for the ORM to look up the "entity tree" and you want to persist only the "root entity" you can do this 59 | 60 | ```php 61 | $orm->get('products')->save($product, false); // it will only persist the product row 62 | ``` 63 | 64 | If you want to persist only specific parts of the "entity tree" you do this: 65 | 66 | ```php 67 | $orm->get('products')->save($product, ['category', 'category.parent', 'images']); 68 | ``` 69 | 70 | ## Deleting entities 71 | 72 | ```php 73 | $orm->get('products')->delete($product); 74 | ``` -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Sirius ORM | Fast yet flexibile ORM built with DX in mind 3 | --- 4 | 5 | # Sirius ORM 6 | 7 | [![Source Code](http://img.shields.io/badge/source-siriusphp/orm-blue.svg?style=flat-square)](https://github.com/siriusphp/orm) 8 | [![Latest Version](https://img.shields.io/packagist/v/siriusphp/orm.svg?style=flat-square)](https://github.com/siriusphp/orm/releases) 9 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](https://github.com/siriusphp/orm/blob/master/LICENSE) 10 | [![Build Status](https://img.shields.io/travis/siriusphp/orm/master.svg?style=flat-square)](https://travis-ci.org/siriusphp/orm) 11 | [![Coverage Status](https://img.shields.io/scrutinizer/coverage/g/siriusphp/orm.svg?style=flat-square)](https://scrutinizer-ci.com/g/siriusphp/orm/code-structure) 12 | [![Quality Score](https://img.shields.io/scrutinizer/g/siriusphp/orm.svg?style=flat-square)](https://scrutinizer-ci.com/g/siriusphp/orm) 13 | 14 | Sirius ORM is a [fast and lightweight](https://github.com/adrianmiu/forked-php-orm-benchmark) yet flexible data mapper solution developed with DX in mind. It offers: 15 | 16 | 1. Mapping rows to your own entities 17 | 2. Relations and relation aggregates (COUNT/AVERAGE) 18 | 3. Eager-loading & lazy-loading (without increasing the number of queries) 19 | 4. Queries that let you JOIN with relations (not tables 20 | 5. Deep persistence 21 | 6. Dynamically defined mappers 22 | 7. Speed & low memory usage (no Entity Manager) 23 | 8. 90+% code coverage 24 | 25 | ### Installation 26 | 27 | ``` 28 | composer require siriusphp/orm 29 | ``` 30 | 31 | ### Initialization 32 | 33 | ```php 34 | use Sirius\Orm\Orm; 35 | use Sirius\Orm\ConnectionLocator; 36 | $connectionLocator = ConnectionLocator::new( 37 | 'mysql:host=localhost;dbname=testdb', 38 | 'username', 39 | 'password' 40 | ); 41 | $orm = new Orm($connectionLocator); 42 | ``` 43 | 44 | ### Configuration 45 | 46 | AKA, registering mappers and relations 47 | 48 | ```php 49 | $orm->register('pages', MapperConfig::fromArray([ 50 | /** 51 | * here goes the configuration 52 | */ 53 | ])); 54 | 55 | // continue with the rest of mappers 56 | ``` 57 | 58 | ### Usage 59 | 60 | ```php 61 | // find by ID 62 | $page = $orm->find('pages', 1); 63 | // or via the mapper 64 | $page = $orm->get('pages')->find(1); 65 | 66 | // query 67 | $pages = $orm->select('pages') 68 | ->where('status', 'published') 69 | ->orderBy('date desc') 70 | ->limit(10) 71 | ->get(); 72 | 73 | // manipulate 74 | $page->title = 'Best ORM evah!'; 75 | $page->featured_image->path = 'orm_schema.png'; 76 | 77 | // persist 78 | $orm->save($page); 79 | // or via the mapper 80 | $orm->get('pages')->save($page); 81 | ``` 82 | 83 | Next: [mappers](mappers.md) -------------------------------------------------------------------------------- /docs/limits.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Limitations and known issues | Sirius ORM 3 | --- 4 | 5 | # Limits and caveats 6 | 7 | ## 1. No entity manager 8 | 9 | There is no central storage for all the rows extracted from the database to ensure all data is kept in sync. 10 | 11 | If you have circular references and you access the relation a new SQL will be triggered. For example: 12 | - products -> MANY_TO_ONE -> category 13 | - category -> ONE_TO_MANY -> products 14 | 15 | ```php 16 | $product = $orm->find('products', 1); 17 | $product->get('name'); // returns 'old name' 18 | $product->set('name', 'new name'); 19 | $productFromCategory = $product->get('category')->get('products')[0]; 20 | $productFromCategory->get('name'); // returns 'old name' NOT 'new name' 21 | ``` 22 | 23 | I believe this is a reasonable trade-off in the context of the request-response cycle. You usually either retrieve them for display OR modification. Plus, in most cases it is a bad practice do this in the first place. 24 | 25 | ## 2. No database schema utility 26 | 27 | At the moment the _Sirius ORM_ doesn't have a schema generation utility to help with creating migrations 28 | 29 | ## 3. Single database 30 | 31 | At the moment the _Sirius ORM_ doesn't know how to handle relations over multiple databases. The SELECT queries that contain JOINs have to be on the same database. 32 | 33 | -------------------------------------------------------------------------------- /docs/mappers.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Mappers | Sirius ORM 3 | --- 4 | 5 | # Mappers 6 | 7 | In _Sirius ORM_ Mappers are objects that do the following things: 8 | 9 | - construct [queries](queries.md) for retrieving entities 10 | - generates an executes actions for [persisting](persistence.md) entities 11 | 12 | For this they have to know about a lot of stuff so that the entities remain database-agnostic and as "dumb" as possible: table columns, relations between table columns and entity attributes, the type of entity they handle and the relations between 13 | entities. 14 | 15 | Mappers work together and delegate operations from one to another and they are "registered" within the ORM which acts as a "mapper locator". 16 | 17 | ### 1. Registering mappers via config 18 | 19 | Most of the times you don't need to construct a special `Mapper` class and you can use the one provided by the library. In this case you only need to register with the ORM instance the configuration options like in the example below. 20 | 21 | The exemple includes all config options available although you won't need them all the time (eg: table alias) and some of them have sensible defaults (eg: primary key) 22 | 23 | ```php 24 | use Sirius\Orm\Behaviour\SoftDelete; 25 | use Sirius\Orm\Behaviour\Timestamps; 26 | use Sirius\Orm\MapperConfig; 27 | use Sirius\Orm\Relation\RelationConfig; 28 | 29 | $orm->register('products', MapperConfig::fromArray([ 30 | MapperConfig::ENTITY_CLASS => 'App\Entity\Product', 31 | MapperConfig::TABLE => 'tbl_products', 32 | MapperConfig::TABLE_ALIAS => 'products', // if you have tables with prefixes 33 | MapperConfig::PRIMARY_KEY => 'product_id', // defaults to 'id' 34 | MapperConfig::COLUMNS => ['id', 'name', 'price', 'sku'], 35 | MapperConfig::CASTS => ['id' => 'integer', 'price' => 'decimal:2'], 36 | MapperConfig::COLUMN_ATTRIBUTE_MAP => ['sku' => 'code'], // the entity works with the 'code' attribute 37 | MapperConfig::GUARDS => ['published' => 1], // see "The guards" page 38 | MapperConfig::SCOPES => ['sortRandom' => $callback], // see "The query scopes" page 39 | MapperConfig::BEHAVIOURS => [ 40 | new SoftDelete('deleted_at'), 41 | new Timestamps(null, 'updated_at') 42 | ], 43 | MapperConfig::RELATIONS => [ 44 | 'images' => [ 45 | RelationConfig::FOREIGN_MAPPER => 'images' 46 | // see the Relation section for the rest 47 | ] 48 | // rest of the relations go here 49 | ] 50 | ])); 51 | ``` 52 | 53 | One advantage of this solution is that the mapper is constructed only when it is requested the first time. If your app doesn't need the `orders` mapper, it won't be constructed. 54 | 55 | ### 2. Registering mappers via factories 56 | 57 | You can use a function when registering a custom mapper with the ORM, like so: 58 | 59 | ```php 60 | $orm->register('products', function($orm) { 61 | // construct the mapper here and return it; 62 | }); 63 | ``` 64 | 65 | ### 3. Register mapper instances 66 | 67 | ```php 68 | $productsMapper = new ProductMapper($orm); 69 | // make adjustments here to the products mapper 70 | // inject services, add behaviours etc 71 | 72 | $orm->register('products', $productsMapper); 73 | ``` -------------------------------------------------------------------------------- /docs/relation_aggregate.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Relation aggregates | Sirius ORM 3 | --- 4 | 5 | # Relation aggregates 6 | 7 | Sometimes you want to query a relation and extract some aggregates. You may want to count the number of comments on a blog post, the average rating on a product etc. It would be faster if these aggregates are already available somewhere else (a 8 | stats table or in special columns) but sometimes your app doesn't need this type of optimizations. 9 | 10 | The relation aggregates are available on "one to many" and "many to many" relations 11 | 12 | Here's how to work with aggregates 13 | 14 | #### Define the available aggregates for a relation 15 | 16 | ```php 17 | use Sirius\Orm\Relation\RelationConfig; 18 | 19 | $orm->register('products', MapperConfig::make( 20 | // other mapper config goes here 21 | 'relations' => [ 22 | 'reviews' => [ 23 | 'type' => 'one_to_many', 24 | /** 25 | * other settings go here 26 | */ 27 | RelationConfig::AGGREGATES => [ 28 | 'reviews_count' => [ 29 | RelationConfig::AGG_FUNCTION => 'count(*)', 30 | RelationConfig::AGG_CALLBACK => $functionThatChangesTheQuery 31 | ], 32 | 'average_rating' => [ 33 | RelationConfig::AGG_FUNCTION => 'AVERAGE(rating)', 34 | RelationConfig::LOAD_STRATEGY => RelationConfig::LOAD_EAGER, // load the aggregates immediately 35 | ], 36 | ] 37 | ] 38 | ] 39 | )); 40 | ``` 41 | 42 | #### Accessing aggregates 43 | 44 | Using lazy loading 45 | 46 | ```php 47 | $products = $orm->select('products')->limit(10)->get(); 48 | 49 | foreach ($products as $product) { 50 | echo $product->reviews_count; 51 | echo $product->average_rating; 52 | } 53 | ``` 54 | 55 | or eager loading 56 | 57 | ```php 58 | $products = $orm->select('products') 59 | ->load('reviews_count', 'average_rating') 60 | ->limit(10) 61 | ->get(); 62 | 63 | foreach ($products as $product) { 64 | echo $product->reviews_count; 65 | echo $product->average_rating; 66 | } 67 | ``` 68 | 69 | ## Complex aggregates 70 | 71 | If you need more complex aggregates create queries using [joinWith] -------------------------------------------------------------------------------- /docs/relation_eav.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Entity-Attribute-Value | Sirius ORM 3 | --- 4 | 5 | # Entity-Attribute-Value relations 6 | 7 | This feature is WIP. 8 | 9 | EAV is a strategy of designing the database so that on one table (the native entity) you store known data about the entity and on another table (the "EAV table") you hold multiple rows each pointing to an attribute and value of the native entity 10 | . The EAV table has one column for the name of the attribute and another with the value. 11 | 12 | If you are not familiar with the EAV concept and you know Wordpress, the `post_meta` table is an EAV table with `meta_name` for the attribute name and `meta_value` as the value. 13 | 14 | It is similar to "one to many" relations with the exception of how the matching rows are attached to the native entity. In the case of the "one to many relations" you attach all matching rows as a collection on the native entity (eg: one product 15 | has one collection of images). In the case of EAV relations each row is identified by a name and it is attached to the native entity as an attribute under that name. 16 | 17 | This allows you to do things like 18 | 19 | ```php 20 | $product->meta_title = "SEO title"; 21 | ``` 22 | 23 | Given the fact that today's databases support JSON columns you can achieve the same result (ie: hold flexible data) using JSON columns. Still, it's a good feature for an ORM to provide. 24 | -------------------------------------------------------------------------------- /docs/relation_many_to_many.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Many to many relations | Sirius ORM 3 | --- 4 | 5 | # "Many to many" relations 6 | 7 | Also known as "__belongs to many__" relation in other ORMs. 8 | 9 | Example: many products belong to many tags. This involves a simple pivot table. 10 | 11 | Other ORMs allow you to use another mapper object instead of the pivot table but my experience is that, if the relation is a little more complex, you will end up working with the relations directly. 12 | 13 | For example, many products belong to many orders via the "order items" but, since the "order items" table is complex you will never want to attach/detach products to orders directly, which is something that you would do in the case of a 14 | "many products belong to many tags" kind of situation. 15 | 16 | This ORM is build with a focus on DX and having less options to shoot yourself in the foot is a reasonable trade-off. 17 | 18 | Besides the options explained in the [relations page](relations.html) below you will find those specific to this "many to many" relations: 19 | 20 | > ##### `through_table` / `RelationConfigs::THROUGH_TABLE` 21 | 22 | > - This is the table that links the entities together (products to tags). 23 | > - It defaults to `{plural_of_table_1}_{plural_of_table_2}` where the tables are sorted alphabetically. 24 | A pivot table between `products` and `tags` will be `products_tags` and a pivot between `products` and `categories` would be `categories_products` 25 | 26 | > ##### `through_table_alias` / `RelationConfigs::THROUGH_TABLE_ALIAS` 27 | 28 | > - Not required. 29 | > - The table alias is useful if you are dealing with tables that have prefix. If your DB has a `tbl_products_tags` the alias should be `products_tags` 30 | 31 | > ##### `through_native_column` / `RelationConfigs::THROUGH_NATIVE_COLUMN` 32 | 33 | > - This refers to the column(s) that should match with the native (ie: products) primary key column(s) 34 | 35 | > ##### `through_foreign_column` / `RelationConfigs::THROUGH_FOREIGN_COLUMN` 36 | 37 | > - This refers to the column(s) that should match with the foreign (ie: tags) primary key column(s) 38 | 39 | > ##### `through_guards` / `RelationConfigs::THROUGH_GUARDS` 40 | 41 | > - This refers to guards applied on the through table 42 | > - You can read more about guards [here](the_guards.md). 43 | > - If you are using the same table to link multiple type of entities you can use this option to further filter the queries. 44 | You might have a "content_links" table that holds references between any type of content in your DB (products to tags, users to preferred activities). 45 | > - You might have a column called `native_id` that hold the ID of the product and `foreign_id` that holds the ID of the tag. However, for this set up to work, you would need some guards: `["native_type" => "product", "foreign_type" => "tag"]` 46 | > - The guards are also used when creating/updating rows in the "through table" so you can safely link products and tags together via the global content links table. 47 | 48 | > ##### `through_columns` / `RelationConfigs::THROUGH_COLUMNS` 49 | 50 | > - This option holds the list of columns that are available for modification in the "through table". 51 | > - For example you may link products to tags but also specify a `position` column in the "through table" to let you sort the tags by their position. 52 | 53 | > ##### `through_columns_prefix` / `RelationConfigs::THROUGH_COLUMNS_PREFIX` 54 | 55 | > - This option is for attaching the "through columns" to the tag entity so you may change the. 56 | Defaults to `pivot_`. 57 | > - I don't know about other ORMs but Eloquent forces you to use the `->pivot` property of a foreign entity to do that. 58 | With Sirius Orm you can do just this: `$product->tags[0]->pivot_position = 10;` 59 | 60 | > ##### `aggregates` / `RelationConfig::AGGREGATES` 61 | 62 | > - here you have a list of aggregated values that can be eager/lazy loaded to an entity (count, average, sums) 63 | > - check the [relation aggregates](relation_aggregate.md) page for more details 64 | 65 | 66 | ## Defining a many-to-many relation 67 | 68 | ```php 69 | use Sirius\Orm\Relation\RelationConfig; 70 | 71 | $orm->register('products', MapperConfig::fromArray([ 72 | /** 73 | * other mapper config goes here 74 | */ 75 | 'relations' => [ 76 | 'tags' => [ 77 | RelationConfig::TYPE => RelationConfig::TYPE_MANY_TO_MANY, 78 | RelationConfig::FOREIGN_MAPPER => 'tags', 79 | RelationConfig::THROUGH_TABLE => 'tbl_products_to_tags', 80 | RelationConfig::THROUGH_TABLE_ALIAS => 'products_tags', 81 | RelationConfig::NATIVE_KEY => 'product_id', 82 | RelationConfig::FOREIGN_KEY => 'tag_id', 83 | RelationConfig::THROUGH_COLUMNS => ['position', 'created_at'], 84 | RelationConfig::THROUGH_COLUMNS_PREFIX => 'link_', 85 | RelationConfig::QUERY_CALLBACK => function($query) { 86 | $query->orderBy('position DESC'); 87 | return $query; 88 | } 89 | ] 90 | ] 91 | ])); 92 | ``` -------------------------------------------------------------------------------- /docs/relation_many_to_one.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Many to one relations | Sirius ORM 3 | --- 4 | 5 | # "Many to one" relations 6 | 7 | Also known as "__belongs to__" relation in other ORMs. 8 | 9 | Examples: 10 | - multiple images belong to one product, 11 | - multiple products belong to a category 12 | - multiple pages belong to one parent. 13 | 14 | There are no special options for this type of relation, besides those explained in the [relations page](relations.html). 15 | 16 | Most of the times (like in the examples above) you don't want to CASCADE delete so this defaults to FALSE. 17 | 18 | ## Definining a many-to-one relation 19 | 20 | ```php 21 | use Sirius\Orm\Relation\RelationConfig; 22 | 23 | $orm->register('products', MapperConfig::make( 24 | // other mapper config goes here 25 | 'relations' => [ 26 | 'category' => [ 27 | RelationConfig::TYPE => 'many_to_one', 28 | RelationConfig::FOREIGN_MAPPER => 'categories', 29 | RelationConfig::NATIVE_KEY => 'category_id', 30 | RelationConfig::FOREIGN_KEY => 'id', 31 | RelationConfig::QUERY_CALLBACK => function($query) { 32 | $query->orderBy('display_priority DESC'); 33 | return $query; 34 | } 35 | ] 36 | ] 37 | )); 38 | ``` -------------------------------------------------------------------------------- /docs/relation_one_to_many.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: One to many relations | Sirius ORM 3 | --- 4 | 5 | # "One to many" relations 6 | 7 | Also known as "__has many__" relation in other ORM. 8 | 9 | Examples: 10 | - one product has many images, 11 | - one category has many products. 12 | 13 | Besides the configuration options explained in the [relations page](relations.html) on "one to many" relation you can have 14 | 15 | > ##### `aggregates` / `RelationConfig::AGGREGATES` 16 | 17 | > - here you have a list of aggregated values that can be eager/lazy loaded to an entity (count, average, sums) 18 | > - check the [relation aggregates](relation_aggregate.md) page for more details 19 | 20 | Most of the times (like in the examples above) you don't want to CASCADE delete so this defaults to FALSE. 21 | One use-case where you want to enable this behaviours is on "one order has many order lines" where you don't need the order lines once the 22 | order is deleted. 23 | But then again, you should let the DB do this. 24 | 25 | ## Defining a one-to-many relation 26 | 27 | In this case the `media` table holds other type of files, not just images 28 | 29 | ```php 30 | use Sirius\Orm\Relation\RelationConfig; 31 | 32 | $orm->register('products', MapperConfig::make( 33 | // other mapper config goes here 34 | 'relations' => [ 35 | 'images' => [ 36 | RelationConfig::TYPE => 'many_to_one', 37 | RelationConfig::FOREIGN_MAPPER => 'media', 38 | RelationConfig::NATIVE_KEY => 'id', 39 | RelationConfig::FOREIGN_KEY => 'product_id', 40 | // the "media" mapper holds more than images 41 | RelationConfig::FOREIGN_GUARDS => [ 42 | 'media_type' => 'image' 43 | ], 44 | // order the images by a specific field 45 | RelationConfig::QUERY_CALLBACK => function($query) { 46 | $query->orderBy('display_priority DESC'); 47 | return $query; 48 | } 49 | ] 50 | ] 51 | )); 52 | ``` -------------------------------------------------------------------------------- /docs/relation_one_to_one.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: One to one relations | Sirius ORM 3 | --- 4 | 5 | # "One to one" relations 6 | 7 | Also known as "__has one__" relation in other ORMs. 8 | 9 | On the surface, this relation seems similar to the "many to one". For example you could think that one page has one parent but you would be wrong to classify it as a "one to one" relation. 10 | 11 | The difference is given by the order of operations when saving the table rows. In the "one page has one parent page" scenario you would save first the parent parent page (so you get the ID) and then the child page. This is why it is actually a "many pages have one parent page". 12 | 13 | In a "one to one" relation you first save the "main" entity and then the related entity. One example would be to have a general "content" table and other special tables for each type of content (eg: "content_products", "content_pages") that stores 14 | fields specific to each table. 15 | In this scenario you first save row in the "content" table. Sure, you could have many rows in the "content_products" table for each row in the "content" table (which would make it a "many content_products have one content") but the ORM will only 16 | return the first. 17 | 18 | There are no special options for this type of relation, besides those explained in the [relations page](relations.html). 19 | 20 | In this scenario you would probably want to do CASCADE delete but for this relation the default is still FALSE because usually this happens directly in the database. 21 | 22 | 23 | ## Definining a one-to-one relation 24 | 25 | Here's a typical example 26 | 27 | ```php 28 | use Sirius\Orm\Relation\RelationConfig; 29 | 30 | $orm->register('products', MapperConfig::make( 31 | // other mapper config goes here 32 | 'relations' => [ 33 | 'fields' => [ 34 | RelationConfig::TYPE => 'one_to_one', 35 | RelationConfig::FOREIGN_MAPPER => 'product_fields', 36 | RelationConfig::NATIVE_KEY => 'id', 37 | RelationConfig::FOREIGN_KEY => 'product_id', 38 | // most likely you want to cascade deletes if the DB doesn't 39 | RelationConfig::CASCADE => true, 40 | // most likely you would want the fields from the start 41 | RelationConfig::LOAD_STRATEGY => RelationConfig::LOAD_EAGER 42 | ] 43 | ] 44 | )); 45 | ``` -------------------------------------------------------------------------------- /docs/relations.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Relations | Sirius ORM 3 | --- 4 | 5 | 6 | # Relations 7 | 8 | Relations have a _name_ (the attribute of the entity that will get the relation), a _native mapper_ (the left-hand side of the relations), a _foreign mapper_ and some options. 9 | 10 | If you pass `MapperConfig` instances to the ORM, which is the easiest way to go, you have to populate the `relations` 11 | 12 | ```php 13 | $orm->register('products', MapperConfig::make( 14 | // other mapper config goes here 15 | 'relations' => [ 16 | 'category' => [ 17 | 'type' => 'many_to_one', 18 | 'foreign_mapper' => 'categories', 19 | // this is enough if you follow the conventions 20 | ] 21 | ] 22 | )); 23 | ``` 24 | 25 | Relations are constructed by the ORM on the first request using the configuration provided. 26 | This means, if a relation is not used, it won't be constructed. 27 | 28 | ## Configuration and conventions 29 | 30 | These are the general configuration options for relations. Check each relation dedicated page for specific additional options and details. 31 | 32 | You can use the `RelationConfig` class constants to make sure you're not making any mistakes when defining the relations 33 | 34 | > ##### `type` / `RelationConfig::TYPE` 35 | 36 | > - holds the type of relation since relations are created on demand 37 | > - supported types: `one_to_one`, `one_to_many`, `many_to_one`, `many_to_many`, `many_to_many_through`, `eav`, `aggregate` 38 | 39 | > ##### `foreign_mapper` / `RelationConfig::FOREIGN_MAPPER` 40 | 41 | > - the name of the mapper as registered in the ORM 42 | 43 | > ##### `native_key` / `RelationConfig::NATIVE_KEY` 44 | 45 | > - is the column(s) in the native mapper that holds the **values** to be searched in the foreign mapper 46 | > - the default value depends on the relation type. 47 | > - for **many to one**: `{foreign mapper table at singular}_{id column of the foreign mapper}` (eg: `category_id` for products -> many to one -> category) 48 | > - for the rest: `{id of the native mapper}` (eg: `id` for category -> one to many -> products) 49 | 50 | > ##### `foreign_key` / `RelationConfig::FOREIGN_KEY` 51 | 52 | > - is the column(s) in the foreign mapper that will be used to query and select the foreign entities 53 | > - the default value depends on the relation type. 54 | > - for **one to many**: `{native mapper table at singular}_{id column of the native mapper}` (eg: `category_id` for category -> one to many -> products) 55 | > - for the rest: `{id of the native mapper}` (eg: `id` for products -> many to one -> category) 56 | 57 | > ##### `foreign_guards` / `RelationConfig::FOREIGN_GUARDS` 58 | 59 | > - learn more about guards [here](the_guards.md) 60 | > - this is useful if the foreign mapper holds multiple types of "content" (eg: a "content" table holds both "pages" and "articles" and uses column "content_type" to determine which is which). The same end goal can be achieved by creating specific 61 | mappers (eg: a "articles" mapper + a "pages" mapper instead of a single "content" mapper) 62 | 63 | > ##### `load_strategy` / `RelationConfig::LOAD_STRATEGY` 64 | 65 | > - by default all relations are loaded lazy since 1) it doesn't affect the number of executed queries and 2) you can specify which relations to be eager loaded on each query 66 | > - however, if you find situations, where you need to have some relations always present it could save you some time 67 | > - you can also set it to `none` if you want for the relation to be loaded ONLY when you specify it (ie: ONLY EAGER) 68 | 69 | > ##### `cascade` / `RelationConfig::CASCADE` 70 | 71 | > - this specifies the behaviour for DELETE operations (ie: also delete related entities) 72 | > - by default the cascade option is `false` 73 | 74 | > ##### `query_callback` / `RelationConfig::QUERY_CALLBACK` 75 | 76 | > - this is for situations where the _foreign guards_ option is not enough you can use a function to executed on the query before retrieving the related entities 77 | > - unlike the _foreign guards_ which are applied on subsequent SAVE operations the _query callback_ is for retrieval only. 78 | You can use this for sorting or limiting the results. -------------------------------------------------------------------------------- /docs/the_actions.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: The persistence actions | Sirius ORM 3 | --- 4 | 5 | # Architecture - The persistence actions 6 | 7 | When persisting entity changes (insert/update/delete) Sirius ORM uses `Actions`. 8 | 9 | These are individual classes that responsible for: 10 | 11 | 1. executing the actual necessary queries 12 | 2. updating the entities after successful queries (eg: set the `id` from the `lastInsertId`) 13 | 3. revert changes made to the entities in case of exceptions 14 | 15 | There are actions for delete, insert, update and link entities together (for many-to-many relations). 16 | 17 | **Important!** The Sirius ORM actions are revertable. If there are exceptions while calling 18 | a persistence method (`save` or `delete`) the entities are reverted to their 19 | original state. This way you can use retries in your app if you face connectivity issues. 20 | 21 | One action can also execute other actions since entities are related and, usually, when you want to persist 22 | an entity change you want to also persist the changes to the related entities. 23 | 24 | If you were to save a new (as in "not already in DB") product entity that has a new category 25 | and a set of new tags the operations required would be 26 | 27 | 1. save category entity in the database 28 | 2. set the `category_id` attribute on the product entity 29 | 3. save the product entity in the database 30 | 4. set the `product_id` attribute on each image 31 | 5. save each tag entity in the database 32 | 6. create the links between the product and tags in the pivot table 33 | 34 | The problems Sirius ORM solves with `Action`s are: 35 | 36 | 1. All these operations are wrapped in a transaction and, in case of exceptions the 37 | `revert()` method is called on all the successfully executed actions. 38 | 2. These operations have to be performed in the proper order, dictated by the 39 | relations between the objects. 40 | 3. Behaviours can add their own actions inside the actions tree. 41 | 42 | To solve these problems the Sirius ORM `Action`s are organized in a tree-like 43 | structure that follow these rules: 44 | 45 | 1. Each action executes operations for **only one** entity 46 | (eg: insert the a row in the products table) 47 | 2. Each action may execute other actions before it's own operations 48 | (eg: call the "insert new category" action) 49 | 3. Each action may execute other actions after it's own operations 50 | (eg: call the "insert new image" action) 51 | 52 | The relation between entities determine what type of actions have to be appended 53 | or prepended inside each action and the mapper is does the "heavy lifting" of constructing the 54 | action tree. 55 | 56 | DELETE actions cascade for relations that have this option set to true, otherwise a DELETE action 57 | will also execute SAVE actions for the related entities. However, you should consider letting the DB 58 | handle this. -------------------------------------------------------------------------------- /docs/the_casting_manager.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: The Casting Manager | Sirius ORM 3 | --- 4 | 5 | # Architecture - The Casting Manager 6 | 7 | The Sirius ORM registers for internal usa a **Casting Manager** which is charge of converting and transforming values. It acts as a singleton and is passed around from the ORM instance to mappers and even entities (see `GenericEntity` class) 8 | 9 | The Casting Manager is a registry of functions that receive a value and return a "transformed" value: 10 | 11 | - '1' can be cast as `int` into 1 12 | - '1' can be cast as `bool` into TRUE 13 | - an array can be cast into an entity 14 | - a set of arrays can be cast into a collection of entities 15 | 16 | The Casting Manager is populated with functions by each mapper, functions that delegate to each of the mapper's entity factory to cast arrays as entities or collections of entities 17 | 18 | It is also used by the `GenericEntity` class to ensure the entity's attributes are properly casted. 19 | 20 | You can use for things like creating Carbon dates for some columns: 21 | 22 | ```php 23 | use Carbon\Carbon; 24 | 25 | $orm->getCastingManager() 26 | ->register('date', function($value) { 27 | return Carbon::parse($value); 28 | }); 29 | ``` 30 | 31 | Since an entity attribute must also be serialized back when persisting you need to define a "cast_for_db" function like so 32 | ```php 33 | use Carbon\Carbon; 34 | 35 | $orm->getCastingManager() 36 | ->register('date_for_db', function($value) { 37 | if ($value instanceof Carbon) { 38 | return $value->format('%Y-%m-%d'); 39 | } 40 | return (string) $value; 41 | }); 42 | ``` 43 | 44 | 45 | 46 | and if you use the `GenericEntity`-based entities 47 | 48 | ```php 49 | use Sirius\Orm\Entity\GenericEntity; 50 | 51 | class Page extends GenericEntity { 52 | protected $casts = ['published_at' => 'date']; 53 | } 54 | 55 | 56 | 57 | 58 | ``` -------------------------------------------------------------------------------- /docs/the_guards.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Entity guards | Sirius ORM 3 | --- 4 | 5 | # Architecture - Entity Guards 6 | 7 | There are many situations where you want to restrict the behaviour of the query used to select entities based on columns that have a specific value AND you also want to enforce those values at the moment the entities are persisted. 8 | 9 | Guards are key-value pairs that correspond to columns that are used while querying or persisting entities and they can be used on Mappers and Relations. 10 | 11 | Example: guard to force the `content_type` column to be equal to `page` 12 | 13 | ```php 14 | use Sirius\Orm\MapperConfig; 15 | 16 | $pageConfig = MapperConfig::fromArray([ 17 | MapperConfig::ENTITY_CLASS => 'App\Entity\Page', 18 | MapperConfig::TABLE => 'content', 19 | MapperConfig::GUARDS => ['content_type' => 'page'] //---- HERE 20 | ]); 21 | 22 | $orm->register('pages', $pageConfig); 23 | ``` 24 | 25 | This will make all sure that 26 | 27 | 1. the SELECT queries include a `AND content_type="page"` condition 28 | 2. the INSERT\UPDATE quries will include a `SET content_type="page"` instruction -------------------------------------------------------------------------------- /docs/the_query_scopes.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Query scopes | Sirius ORM 3 | --- 4 | 5 | # Query scopes 6 | 7 | Query scopes are functions that alter the queries and they are used to simplify the code (if they make lots of changes to the query) or express business rules. 8 | 9 | The query scopes are attached to the mapper like so: 10 | 11 | ```php 12 | use Sirius\Orm\MapperConfig; 13 | use Sirius\Orm\Query; 14 | 15 | $orm->register('articles', MapperConfig::fromArray([ 16 | /** 17 | * other mapper config goes here 18 | */ 19 | MapperConfig::SCOPES => [ 20 | 'pickRandomlyFromThosePublished' => function(Query $query, $count = 5) { 21 | $query->where('published', true) 22 | ->orderBy('RAND()') 23 | ->limit($count); 24 | return $query; 25 | }, 26 | 'ownedByUser' => function(Query $query, User $user) { 27 | $query->where('author_id', $user->id); 28 | return $query; 29 | } 30 | ] 31 | ])); 32 | ``` 33 | 34 | which later can be used like so: 35 | 36 | ```php 37 | $orm->select('articles') 38 | ->ownedByUser($someUser) 39 | ->pickRandomlyFromThosePublished(10); 40 | ``` -------------------------------------------------------------------------------- /docs/the_tracker.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: The tracker | Sirius ORM 3 | --- 4 | 5 | # Architecture - The Tracker object 6 | 7 | The Sirius ORM solves the **n+1 problem** that many ORM face using a `Tracker` object. 8 | 9 | Whenever a mapper is queried for entities, a `Tracker` object stores the rows, 10 | and the relations we might expect to be asked in the future relative to those rows/entities. 11 | 12 | For example, if you query the `Products` mapper for the first 10 products matching some 13 | conditions a `Tracker` object is created that stores those rows, not entities (!!!). 14 | 15 | When the entity is actually created from the row, the ORM builds the list of relations that 16 | the entity might ask in the future (lazy or eager). 17 | 18 | In the case of eager-loaded relations the proper query is executed and the matching entities 19 | are attached to each product. 20 | 21 | In the case of lazy-loaded relations a `LazyValueLoader` is attached to each product as a 22 | placeholder for the related entity. Later, when the consumer asks for the related entity the 23 | `LazyValueLoader` will perform the query and return the matching entities, the entity will 24 | add the result to it's attribute and dispose the `LazyValueLoader` 25 | -------------------------------------------------------------------------------- /phpunit.mysql.xml: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | ./ 15 | 16 | 17 | 18 | 19 | ../src 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ./tests 19 | 20 | 21 | 22 | 23 | ./src 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/Action/ActionInterface.php: -------------------------------------------------------------------------------- 1 | nativeMapper = $nativeMapper; 48 | $this->nativeEntity = $nativeEntity; 49 | $this->foreignMapper = $foreignMapper; 50 | $this->foreignEntity = $foreignEntity; 51 | $this->relation = $relation; 52 | $this->actionType = $actionType; 53 | } 54 | 55 | public function revert() 56 | { 57 | /** 58 | * @todo restore previous values 59 | */ 60 | } 61 | 62 | public function run() 63 | { 64 | /** 65 | * @todo store current attribute values 66 | */ 67 | $this->relation->attachEntities($this->nativeEntity, $this->foreignEntity); 68 | $this->maybeUpdatePivotRow(); 69 | } 70 | 71 | public function onSuccess() 72 | { 73 | } 74 | 75 | protected function maybeUpdatePivotRow() 76 | { 77 | if (!$this->relation instanceof ManyToMany) { 78 | return; 79 | } 80 | 81 | $conn = $this->relation->getNativeMapper()->getWriteConnection(); 82 | $throughTable = (string)$this->relation->getOption(RelationConfig::THROUGH_TABLE); 83 | 84 | $throughNativeColumns = (array) $this->relation->getOption(RelationConfig::THROUGH_NATIVE_COLUMN); 85 | $throughForeignColumns = (array) $this->relation->getOption(RelationConfig::THROUGH_FOREIGN_COLUMN); 86 | $nativeKey = (array) $this->nativeMapper->getEntityPk($this->nativeEntity); 87 | $foreignKey = (array) $this->foreignMapper->getEntityPk($this->foreignEntity); 88 | 89 | $delete = new \Sirius\Sql\Delete($conn); 90 | $delete->from($throughTable); 91 | foreach ($throughNativeColumns as $k => $col) { 92 | $delete->where($col, $nativeKey[$k]); 93 | $delete->where($throughForeignColumns[$k], $foreignKey[$k]); 94 | } 95 | foreach ((array)$this->relation->getOption(RelationConfig::THROUGH_GUARDS) as $col => $value) { 96 | if (!is_int($col)) { 97 | $delete->where($col, $value); 98 | } else { 99 | $delete->where($value); 100 | } 101 | } 102 | $delete->perform(); 103 | 104 | $insertColumns = []; 105 | foreach ($throughNativeColumns as $k => $col) { 106 | $insertColumns[$col] = $nativeKey[$k]; 107 | $insertColumns[$throughForeignColumns[$k]] = $foreignKey[$k]; 108 | } 109 | 110 | $throughColumnPrefix = $this->relation->getOption(RelationConfig::THROUGH_COLUMNS_PREFIX); 111 | foreach ((array)$this->relation->getOption(RelationConfig::THROUGH_COLUMNS) as $col) { 112 | $insertColumns[$col] = $this->relation 113 | ->getForeignMapper() 114 | ->getEntityAttribute($this->foreignEntity, "{$throughColumnPrefix}{$col}"); 115 | } 116 | 117 | foreach ((array)$this->relation->getOption(RelationConfig::THROUGH_GUARDS) as $col => $value) { 118 | if (!is_int($col)) { 119 | $insertColumns[$col] = $value; 120 | } 121 | } 122 | 123 | $insert = new \Sirius\Sql\Insert($conn); 124 | $insert->into($throughTable) 125 | ->columns($insertColumns) 126 | ->perform(); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Action/BaseAction.php: -------------------------------------------------------------------------------- 1 | mapper = $mapper; 64 | $this->entity = $entity; 65 | $this->options = $options; 66 | } 67 | 68 | public function prepend(ActionInterface $action) 69 | { 70 | $this->before[] = $action; 71 | } 72 | 73 | /** 74 | * @return Mapper 75 | */ 76 | public function getMapper(): Mapper 77 | { 78 | return $this->mapper; 79 | } 80 | 81 | /** 82 | * @return EntityInterface 83 | */ 84 | public function getEntity(): EntityInterface 85 | { 86 | return $this->entity; 87 | } 88 | 89 | public function getOption($name) 90 | { 91 | return $this->options[$name] ?? null; 92 | } 93 | 94 | public function append(ActionInterface $action) 95 | { 96 | $this->after[] = $action; 97 | } 98 | 99 | protected function addActionsForRelatedEntities() 100 | { 101 | if ($this->getOption('relations') === false || ! $this->mapper) { 102 | return; 103 | } 104 | 105 | foreach ($this->getMapper()->getRelations() as $name) { 106 | if (! $this->mapper->hasRelation($name)) { 107 | continue; 108 | } 109 | $this->mapper->getRelation($name)->addActions($this); 110 | } 111 | } 112 | 113 | protected function getConditions() 114 | { 115 | $entityPk = (array)$this->mapper->getPrimaryKey(); 116 | $conditions = []; 117 | foreach ($entityPk as $col) { 118 | $val = $this->mapper->getEntityAttribute($this->entity, $col); 119 | if ($val) { 120 | $conditions[$col] = $val; 121 | } 122 | } 123 | 124 | // not enough columns? reset 125 | if (count($conditions) != count($entityPk)) { 126 | return []; 127 | } 128 | 129 | return $conditions; 130 | } 131 | 132 | public function run($calledByAnotherAction = false) 133 | { 134 | $executed = []; 135 | 136 | try { 137 | $this->addActionsForRelatedEntities(); 138 | 139 | foreach ($this->before as $action) { 140 | $action->run(true); 141 | $executed[] = $action; 142 | } 143 | $this->execute(); 144 | $executed[] = $this; 145 | $this->hasRun = true; 146 | foreach ($this->after as $action) { 147 | $action->run(true); 148 | $executed[] = $action; 149 | } 150 | } catch (\Exception $e) { 151 | $this->undo($executed); 152 | throw new FailedActionException( 153 | sprintf("%s failed for mapper %s", get_class($this), $this->mapper->getTableAlias(true)), 154 | (int)$e->getCode(), 155 | $e 156 | ); 157 | } 158 | 159 | /** @var ActionInterface $action */ 160 | foreach ($executed as $action) { 161 | // if called by another action, that action will call `onSuccess` 162 | if (! $calledByAnotherAction || $action !== $this) { 163 | $action->onSuccess(); 164 | } 165 | } 166 | 167 | return true; 168 | } 169 | 170 | public function revert() 171 | { 172 | return; // each action implements it's own logic if necessary 173 | } 174 | 175 | protected function undo(array $executed) 176 | { 177 | foreach ($executed as $action) { 178 | $action->revert(); 179 | } 180 | } 181 | 182 | public function onSuccess() 183 | { 184 | return; 185 | } 186 | 187 | 188 | protected function execute() 189 | { 190 | throw new \BadMethodCallException(sprintf('%s must implement `execute()`', get_class($this))); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/Action/Delete.php: -------------------------------------------------------------------------------- 1 | getConditions(); 13 | 14 | if (empty($conditions)) { 15 | return; 16 | } 17 | 18 | $delete = new \Sirius\Sql\Delete($this->mapper->getWriteConnection()); 19 | $delete->from($this->mapper->getTable()); 20 | $delete->whereAll($conditions, false); 21 | 22 | $delete->perform(); 23 | } 24 | 25 | public function onSuccess() 26 | { 27 | if ($this->entity->getPersistenceState() !== StateEnum::DELETED) { 28 | $this->mapper->setEntityPk($this->entity, null); 29 | $this->entity->setPersistenceState(StateEnum::DELETED); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Action/DeletePivotRows.php: -------------------------------------------------------------------------------- 1 | relation = $relation; 27 | $this->nativeMapper = $relation->getNativeMapper(); 28 | $this->nativeEntity = $nativeEntity; 29 | $this->mapper = $relation->getForeignMapper(); 30 | $this->entity = $foreignEntity; 31 | } 32 | 33 | protected function execute() 34 | { 35 | $conditions = $this->getConditions(); 36 | 37 | if (empty($conditions)) { 38 | return; 39 | } 40 | 41 | $delete = new \Sirius\Sql\Delete($this->mapper->getWriteConnection()); 42 | $delete->from((string) $this->relation->getOption(RelationConfig::THROUGH_TABLE)); 43 | $delete->whereAll($conditions, false); 44 | 45 | $delete->perform(); 46 | } 47 | 48 | public function revert() 49 | { 50 | return; // no change to the entity has actually been performed 51 | } 52 | 53 | public function onSuccess() 54 | { 55 | return; 56 | } 57 | 58 | protected function getConditions() 59 | { 60 | $conditions = []; 61 | 62 | $nativeEntityPk = (array)$this->nativeMapper->getPrimaryKey(); 63 | $nativeThroughCols = (array)$this->relation->getOption(RelationConfig::THROUGH_NATIVE_COLUMN); 64 | foreach ($nativeEntityPk as $idx => $col) { 65 | $val = $this->nativeMapper->getEntityAttribute($this->nativeEntity, $col); 66 | if ($val) { 67 | $conditions[$nativeThroughCols[$idx]] = $val; 68 | } 69 | } 70 | 71 | $entityPk = (array)$this->mapper->getPrimaryKey(); 72 | $throughCols = (array)$this->relation->getOption(RelationConfig::THROUGH_FOREIGN_COLUMN); 73 | foreach ($entityPk as $idx => $col) { 74 | $val = $this->mapper->getEntityAttribute($this->entity, $col); 75 | if ($val) { 76 | $conditions[$throughCols[$idx]] = $val; 77 | } 78 | } 79 | 80 | // not enough columns? bail 81 | if (count($conditions) != count($entityPk) + count($nativeEntityPk)) { 82 | return []; 83 | } 84 | 85 | return $conditions; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Action/DetachEntities.php: -------------------------------------------------------------------------------- 1 | relation->detachEntities($this->nativeEntity, $this->foreignEntity); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Action/Insert.php: -------------------------------------------------------------------------------- 1 | entityId = $this->mapper->getEntityPk($this->entity); 18 | $this->entityState = $this->entity->getPersistenceState(); 19 | 20 | $connection = $this->mapper->getWriteConnection(); 21 | 22 | $columns = array_merge( 23 | $this->mapper->extractFromEntity($this->entity), 24 | $this->extraColumns, 25 | $this->mapper->getGuards() 26 | ); 27 | $columns = Arr::except($columns, $this->mapper->getPrimaryKey()); 28 | 29 | $insertSql = new \Sirius\Sql\Insert($connection); 30 | $insertSql->into($this->mapper->getTable()) 31 | ->columns($columns); 32 | $insertSql->perform(); 33 | $this->mapper->setEntityPk($this->entity, $connection->lastInsertId()); 34 | } 35 | 36 | public function revert() 37 | { 38 | if (! $this->hasRun) { 39 | return; 40 | } 41 | $this->mapper->setEntityPk($this->entity, $this->entityId); 42 | $this->entity->setPersistenceState($this->entityState); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Action/SoftDelete.php: -------------------------------------------------------------------------------- 1 | mapper->getEntityPk($this->entity); 18 | if (! $entityId) { 19 | return; 20 | } 21 | 22 | $this->now = time(); 23 | 24 | $update = new \Sirius\Sql\Update($this->mapper->getWriteConnection()); 25 | $update->table($this->mapper->getTable()) 26 | ->columns([ 27 | $this->getOption('deleted_at_column') => $this->now 28 | ]) 29 | ->where('id', $entityId); 30 | $update->perform(); 31 | } 32 | 33 | public function onSuccess() 34 | { 35 | $this->mapper->setEntityAttribute($this->entity, $this->getOption('deleted_at_column'), $this->now); 36 | if ($this->entity->getPersistenceState() !== StateEnum::DELETED) { 37 | $this->entity->setPersistenceState(StateEnum::DELETED); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Action/Update.php: -------------------------------------------------------------------------------- 1 | $value) { 26 | $this->extraColumns[$name] = $value; 27 | } 28 | 29 | return $this; 30 | } 31 | 32 | protected function execute() 33 | { 34 | $conditions = $this->getConditions(); 35 | 36 | if (empty($conditions)) { 37 | return; 38 | } 39 | 40 | $this->entityState = $this->entity->getPersistenceState(); 41 | 42 | $connection = $this->mapper->getWriteConnection(); 43 | 44 | $columns = $this->mapper->extractFromEntity($this->entity); 45 | $changes = Arr::renameKeys($this->entity->getChanges(), array_flip($this->mapper->getColumnAttributeMap())); 46 | $columns = Arr::only($columns, array_keys($changes)); 47 | $columns = array_merge( 48 | $columns, 49 | $this->extraColumns, 50 | $this->mapper->getGuards() 51 | ); 52 | $columns = Arr::except($columns, $this->mapper->getPrimaryKey()); 53 | 54 | if (count($columns) > 0) { 55 | $updateSql = new \Sirius\Sql\Update($connection); 56 | $updateSql->table($this->mapper->getTable()) 57 | ->columns($columns) 58 | ->whereAll($conditions, false); 59 | $updateSql->perform(); 60 | } 61 | } 62 | 63 | public function revert() 64 | { 65 | if (! $this->hasRun) { 66 | return; 67 | } 68 | $this->entity->setPersistenceState($this->entityState); 69 | } 70 | 71 | public function onSuccess() 72 | { 73 | foreach ($this->extraColumns as $col => $value) { 74 | $this->mapper->setEntityAttribute($this->entity, $col, $value); 75 | } 76 | $this->entity->setPersistenceState(StateEnum::SYNCHRONIZED); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Behaviour/BehaviourInterface.php: -------------------------------------------------------------------------------- 1 | column = $column; 23 | } 24 | 25 | public function getName() 26 | { 27 | return 'soft_delete'; 28 | } 29 | 30 | public function onDelete(Mapper $mapper, Delete $delete) 31 | { 32 | return new \Sirius\Orm\Action\SoftDelete($mapper, $delete->getEntity(), ['deleted_at_column' => $this->column]); 33 | } 34 | 35 | public function onNewQuery(/** @scrutinizer ignore-unused */Mapper $mapper, Query $query) 36 | { 37 | $query->setGuards([ 38 | $this->column => null 39 | ]); 40 | 41 | return $query; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Behaviour/Timestamps.php: -------------------------------------------------------------------------------- 1 | createColumn = $createColumn; 26 | $this->updateColumn = $updateColumn; 27 | } 28 | 29 | public function getName() 30 | { 31 | return 'timestamps'; 32 | } 33 | 34 | public function onSave(/** @scrutinizer ignore-unused */Mapper $mapper, ActionInterface $action) 35 | { 36 | if ($action instanceof Insert) { 37 | if ($this->createColumn) { 38 | $action->addColumns([$this->createColumn => time()]); 39 | } 40 | if ($this->updateColumn) { 41 | $action->addColumns([$this->updateColumn => time()]); 42 | } 43 | } 44 | if ($action instanceof Update && $this->updateColumn) { 45 | if (! empty($action->getEntity()->getChanges())) { 46 | $action->addColumns([$this->updateColumn => time()]); 47 | } 48 | } 49 | 50 | return $action; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/CastingManager.php: -------------------------------------------------------------------------------- 1 | casts[$name] = $func; 13 | } 14 | 15 | public function cast($type, $value, ...$args) 16 | { 17 | if (strpos($type, ':')) { 18 | list($cast, $args) = explode(':', $type); 19 | $args = explode(',', $args); 20 | } else { 21 | $cast = $type; 22 | } 23 | 24 | if (method_exists($this, $cast)) { 25 | return $this->$cast($value, ...$args); 26 | } 27 | 28 | if (isset($this->casts[$cast])) { 29 | $func = $this->casts[$cast]; 30 | 31 | return $func($value, ...$args); 32 | } 33 | 34 | return $value; 35 | } 36 | 37 | public function castArray($arr, $rules) 38 | { 39 | $result = []; 40 | 41 | foreach ($arr as $col => $val) { 42 | if (isset($rules[$col])) { 43 | $result[$col] = $this->cast($rules[$col], $val); 44 | } else { 45 | $result[$col] = $val; 46 | } 47 | } 48 | 49 | return $result; 50 | } 51 | 52 | public function castArrayForDb($arr, $rules) 53 | { 54 | $result = []; 55 | 56 | foreach ($arr as $col => $val) { 57 | if (isset($rules[$col])) { 58 | $result[$col] = $this->cast($rules[$col] . '_for_db', $val); 59 | } else { 60 | $result[$col] = $val; 61 | } 62 | } 63 | 64 | return $result; 65 | } 66 | 67 | public function bool($value) 68 | { 69 | return ! ! $value; 70 | } 71 | 72 | // phpcs:ignore 73 | public function bool_for_db($value) 74 | { 75 | return $value ? 1 : 0; 76 | } 77 | 78 | public function int($value) 79 | { 80 | return $value === null ? null : (int)$value; 81 | } 82 | 83 | public function float($value) 84 | { 85 | return $value === null ? null : (float) $value; 86 | } 87 | 88 | public function decimal($value, $digits) 89 | { 90 | return round((float)$value, (int)$digits); 91 | } 92 | 93 | public function json($value) 94 | { 95 | if (! $value) { 96 | return new \ArrayObject(); 97 | } 98 | if (is_array($value)) { 99 | return new \ArrayObject($value); 100 | } 101 | if (is_string($value)) { 102 | return new \ArrayObject(json_decode($value, true)); 103 | } 104 | if ($value instanceof \ArrayObject) { 105 | return $value; 106 | } 107 | throw new \InvalidArgumentException('Value has to be a string, an array or an ArrayObject'); 108 | } 109 | 110 | // phpcs:ignore 111 | public function json_for_db($value) 112 | { 113 | if (!$value) { 114 | return null; 115 | } 116 | if (is_array($value)) { 117 | return json_encode($value); 118 | } 119 | if ($value instanceof \ArrayObject) { 120 | return json_encode($value->getArrayCopy()); 121 | } 122 | return $value; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Collection/Collection.php: -------------------------------------------------------------------------------- 1 | [], 12 | 'added' => [] 13 | ]; 14 | /** 15 | * @var callable 16 | */ 17 | protected $castingFunction; 18 | 19 | public function __construct(array $elements = [], callable $castingFunction = null) 20 | { 21 | parent::__construct($elements); 22 | $this->changes['removed'] = new ArrayCollection(); 23 | $this->changes['added'] = new ArrayCollection(); 24 | $this->castingFunction = $castingFunction; 25 | } 26 | 27 | protected function castElement($data) 28 | { 29 | $castFunction = $this->castingFunction; 30 | return $castFunction ? call_user_func($castFunction, $data) : $data; 31 | } 32 | 33 | public function add($element) 34 | { 35 | $element = $this->castElement($element); 36 | $this->change('added', $element); 37 | 38 | return parent::add($element); 39 | } 40 | 41 | public function set($key, $value) 42 | { 43 | $value = $this->castElement($value); 44 | parent::set($key, $value); 45 | } 46 | 47 | public function remove($key) 48 | { 49 | $removed = parent::remove($key); 50 | if ($removed) { 51 | $this->change('removed', $removed); 52 | } 53 | 54 | return $removed; 55 | } 56 | 57 | public function removeElement($element) 58 | { 59 | $element = $this->castElement($element); 60 | $removed = parent::removeElement($element); 61 | if ($removed) { 62 | $this->change('removed', $element); 63 | } 64 | 65 | return $removed; 66 | } 67 | 68 | public function getChanges(): array 69 | { 70 | $changes = []; 71 | foreach (array_keys($this->changes) as $t) { 72 | /** @var ArrayCollection $changeCollection */ 73 | $changeCollection = $this->changes[$t]; 74 | $changes[$t] = $changeCollection->getValues(); 75 | } 76 | 77 | return $changes; 78 | } 79 | 80 | public function getArrayCopy() 81 | { 82 | $result = []; 83 | foreach ($this as $element) { 84 | if (is_object($element) && method_exists($element, 'getArrayCopy')) { 85 | $result[] = $element->getArrayCopy(); 86 | } else { 87 | $result[] = $element; 88 | } 89 | } 90 | return $result; 91 | } 92 | 93 | protected function change($type, $element) 94 | { 95 | foreach (array_keys($this->changes) as $t) { 96 | /** @var ArrayCollection $changeCollection */ 97 | $changeCollection = $this->changes[$t]; 98 | if ($t == $type) { 99 | if (! $changeCollection->contains($element)) { 100 | $changeCollection->add($element); 101 | } 102 | } else { 103 | $this->removeElement($element); 104 | } 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Collection/PaginatedCollection.php: -------------------------------------------------------------------------------- 1 | totalCount = $totalCount; 16 | $this->perPage = $perPage; 17 | $this->currentPage = $currentPage; 18 | } 19 | 20 | /** 21 | * @return mixed 22 | */ 23 | public function getTotalCount() 24 | { 25 | return $this->totalCount; 26 | } 27 | 28 | /** 29 | * @return mixed 30 | */ 31 | public function getPerPage() 32 | { 33 | return $this->perPage; 34 | } 35 | 36 | /** 37 | * @return mixed 38 | */ 39 | public function getCurrentPage() 40 | { 41 | return $this->currentPage; 42 | } 43 | 44 | public function getTotalPages() 45 | { 46 | if ($this->perPage < 1) { 47 | return 0; 48 | } 49 | 50 | return ceil($this->totalCount / $this->perPage); 51 | } 52 | 53 | public function getPageStart() 54 | { 55 | if ($this->totalCount < 1) { 56 | return 0; 57 | } 58 | 59 | return 1 + $this->perPage * ($this->currentPage - 1); 60 | } 61 | 62 | public function getPageEnd() 63 | { 64 | if ($this->totalCount < 1) { 65 | return 0; 66 | } 67 | 68 | return $this->getPageStart() + $this->count() - 1; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Connection.php: -------------------------------------------------------------------------------- 1 | list[$behaviour->getName()] = $behaviour; 17 | } 18 | 19 | public function remove($name) 20 | { 21 | unset($this->list[$name]); 22 | } 23 | 24 | public function without(...$names) 25 | { 26 | $clone = clone $this; 27 | foreach ($names as $name) { 28 | $clone->remove($name); 29 | } 30 | return $clone; 31 | } 32 | 33 | public function apply($mapper, $target, $result, ...$args) 34 | { 35 | foreach ($this->list as $behaviour) { 36 | $method = 'on' . Str::className($target); 37 | if (method_exists($behaviour, $method)) { 38 | $result = $behaviour->{$method}($mapper, $result, ...$args); 39 | } 40 | } 41 | 42 | return $result; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Entity/EntityInterface.php: -------------------------------------------------------------------------------- 1 | castingManager = $castingManager; 31 | foreach ($attributes as $attr => $value) { 32 | $this->set($attr, $value); 33 | } 34 | } 35 | 36 | public function __get($name) 37 | { 38 | return $this->get($name); 39 | } 40 | 41 | public function __set($name, $value) 42 | { 43 | return $this->set($name, $value); 44 | } 45 | 46 | public function __isset($name) 47 | { 48 | return isset($this->attributes[$name]); 49 | } 50 | 51 | public function __unset($name) 52 | { 53 | return $this->set($name, null); 54 | } 55 | 56 | protected function castAttribute($name, $value) 57 | { 58 | $method = Str::methodName($name . ' attribute', 'cast'); 59 | if (method_exists($this, $method)) { 60 | return $this->$method($value); 61 | } 62 | 63 | if (!$this->castingManager) { 64 | return $value; 65 | } 66 | 67 | /** 68 | * @todo implement additional attributes 69 | */ 70 | $type = $this->casts[$name] ?? $name; 71 | 72 | return $this->castingManager->cast($type, $value); 73 | } 74 | 75 | protected function set($attribute, $value = null) 76 | { 77 | $this->preventChangesIfDeleted(); 78 | 79 | if ($value instanceof LazyLoader) { 80 | $this->lazyLoaders[$attribute] = $value; 81 | return $this; 82 | } 83 | 84 | $value = $this->castAttribute($attribute, $value); 85 | if (! isset($this->attributes[$attribute]) || $value != $this->attributes[$attribute]) { 86 | $this->changed[$attribute] = true; 87 | $this->state = StateEnum::CHANGED; 88 | } 89 | $this->attributes[$attribute] = $value; 90 | 91 | return $this; 92 | } 93 | 94 | protected function get($attribute) 95 | { 96 | if (! $attribute) { 97 | return null; 98 | } 99 | 100 | $this->maybeLazyLoad($attribute); 101 | 102 | return $this->attributes[$attribute] ?? null; 103 | } 104 | 105 | public function getPersistenceState() 106 | { 107 | if (! empty($this->changed)) { 108 | } 109 | 110 | return $this->state; 111 | } 112 | 113 | public function setPersistenceState($state) 114 | { 115 | if ($state == StateEnum::SYNCHRONIZED) { 116 | $this->changed = []; 117 | } 118 | $this->state = $state; 119 | } 120 | 121 | public function getArrayCopy() 122 | { 123 | $copy = $this->attributes; 124 | foreach ($copy as $k => $v) { 125 | if (is_object($v) && method_exists($v, 'getArrayCopy')) { 126 | $copy[$k] = $v->getArrayCopy(); 127 | } 128 | } 129 | return $copy; 130 | } 131 | 132 | public function getChanges() 133 | { 134 | $changes = $this->changed; 135 | foreach ($this->attributes as $name => $value) { 136 | if (is_object($value) && method_exists($value, 'getChanges')) { 137 | if (! empty($value->getChanges())) { 138 | $changes[$name] = true; 139 | } 140 | } 141 | } 142 | 143 | return $changes; 144 | } 145 | 146 | protected function preventChangesIfDeleted() 147 | { 148 | if ($this->state == StateEnum::DELETED) { 149 | throw new \BadMethodCallException('Entity was deleted, no further changes are allowed'); 150 | } 151 | } 152 | 153 | /** 154 | * @param $attribute 155 | */ 156 | protected function maybeLazyLoad($attribute): void 157 | { 158 | if (isset($this->lazyLoaders[$attribute])) { 159 | // preserve state 160 | $state = $this->state; 161 | /** @var LazyLoader $lazyLoader */ 162 | $lazyLoader = $this->lazyLoaders[$attribute]; 163 | $lazyLoader->load(); 164 | unset($this->changed[$attribute]); 165 | unset($this->lazyLoaders[$attribute]); 166 | $this->state = $state; 167 | } 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/Entity/GenericEntityHydrator.php: -------------------------------------------------------------------------------- 1 | mapper = $mapper; 25 | } 26 | 27 | public function setCastingManager(CastingManager $castingManager) 28 | { 29 | $this->castingManager = $castingManager; 30 | } 31 | 32 | public function hydrate(array $attributes = []) 33 | { 34 | $attributes = $this->castingManager 35 | ->castArray($attributes, $this->mapper->getCasts()); 36 | $attributes = Arr::renameKeys($attributes, $this->mapper->getColumnAttributeMap()); 37 | $class = $this->mapper->getEntityClass() ?? GenericEntity::class; 38 | 39 | return new $class($attributes, $this->castingManager); 40 | } 41 | 42 | public function extract(EntityInterface $entity) 43 | { 44 | $data = Arr::renameKeys( 45 | $entity->getArrayCopy(), 46 | array_flip($this->mapper->getColumnAttributeMap()) 47 | ); 48 | $data = $this->castingManager 49 | ->castArrayForDb($data, $this->mapper->getCasts()); 50 | 51 | return Arr::only($data, $this->mapper->getColumns()); 52 | } 53 | 54 | public function get($entity, $attribute) 55 | { 56 | return $entity->{$attribute}; 57 | } 58 | 59 | public function set($entity, $attribute, $value) 60 | { 61 | return $entity->{$attribute} = $value; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Entity/HydratorInterface.php: -------------------------------------------------------------------------------- 1 | entity = $entity; 27 | $this->tracker = $tracker; 28 | $this->aggregate = $aggregate; 29 | } 30 | 31 | public function load() 32 | { 33 | $results = $this->tracker->getAggregateResults($this->aggregate); 34 | $this->aggregate->attachAggregateToEntity($this->entity, $results); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Entity/LazyLoader.php: -------------------------------------------------------------------------------- 1 | entity = $entity; 26 | $this->tracker = $tracker; 27 | $this->relation = $relation; 28 | } 29 | 30 | public function load() 31 | { 32 | $results = $this->tracker->getResultsForRelation($this->relation->getOption('name')); 33 | $this->relation->attachMatchesToEntity($this->entity, $results); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Entity/StateEnum.php: -------------------------------------------------------------------------------- 1 | rows = $rows; 44 | } 45 | 46 | public function setRelation($name, Relation $relation, $callback, array $nextLoad = [], $overwrite = false) 47 | { 48 | if ($overwrite || ! isset($this->relations[$name])) { 49 | $this->relations[$name] = $relation; 50 | $this->relationCallback[$name] = $callback; 51 | if (!empty($nextLoad)) { 52 | $this->relationNextLoad[$name] = $nextLoad; 53 | } 54 | } 55 | } 56 | 57 | public function hasRelation($name) 58 | { 59 | return isset($this->relations[$name]); 60 | } 61 | 62 | public function getResultsForRelation($name) 63 | { 64 | if (! isset($this->relations[$name])) { 65 | return []; 66 | } 67 | 68 | if (isset($this->relationResults[$name])) { 69 | return $this->relationResults[$name]; 70 | } 71 | 72 | $results = $this->queryRelation($name); 73 | 74 | $this->relationResults[$name] = $results; 75 | 76 | return $this->relationResults[$name]; 77 | } 78 | 79 | public function getAggregateResults(Aggregate $aggregate) 80 | { 81 | $name = $aggregate->getName(); 82 | 83 | if (isset($this->aggregateResults[$name])) { 84 | return $this->aggregateResults[$name]; 85 | } 86 | 87 | /** @var Query $query */ 88 | $query = $aggregate->getQuery($this); 89 | 90 | $results = $query->fetchAll(); 91 | $this->aggregateResults[$name] = $results instanceof Collection ? $results->getValues() : $results; 92 | 93 | return $this->aggregateResults[$name]; 94 | } 95 | 96 | public function pluck($columns) 97 | { 98 | $result = []; 99 | foreach ($this->rows as $row) { 100 | $value = $this->getColumnsFromRow($row, $columns); 101 | if ($value && !in_array($value, $result)) { 102 | $result[] = $value; 103 | } 104 | } 105 | 106 | return $result; 107 | } 108 | 109 | protected function getColumnsFromRow($row, $columns) 110 | { 111 | if (is_array($columns) && count($columns) > 1) { 112 | $result = []; 113 | foreach ($columns as $column) { 114 | if ($row instanceof GenericEntity) { 115 | $result[] = $row->{$column}; 116 | } else { 117 | $result[] = $row[$column] ?? null; 118 | } 119 | } 120 | 121 | return $result; 122 | } 123 | 124 | $column = is_array($columns) ? $columns[0] : $columns; 125 | 126 | return $row instanceof GenericEntity ? $row->{$column} : ($row[$column] ?? null); 127 | } 128 | 129 | /** 130 | * After the entities are created we use this method to swap the rows 131 | * with the actual entities to save some memory since rows can be quite big 132 | * 133 | * @param array $entities 134 | */ 135 | public function replaceRows(array $entities) 136 | { 137 | $this->rows = $entities; 138 | } 139 | 140 | /** 141 | * @param $name 142 | * 143 | * @return array 144 | */ 145 | protected function queryRelation($name) 146 | { 147 | /** @var Relation $relation */ 148 | $relation = $this->relations[$name]; 149 | /** @var Query $query */ 150 | $query = $relation->getQuery($this); 151 | 152 | $queryCallback = $this->relationCallback[$name] ?? null; 153 | if ($queryCallback && is_callable($queryCallback)) { 154 | $query = $queryCallback($query); 155 | } 156 | 157 | $queryNextLoad = $this->relationNextLoad[$name] ?? []; 158 | if ($queryNextLoad && ! empty($queryNextLoad)) { 159 | foreach ($queryNextLoad as $next => $callback) { 160 | $query = $query->load([$next => $callback]); 161 | } 162 | } 163 | 164 | $results = $query->get(); 165 | $results = $results instanceof Collection ? $results->getValues() : $results; 166 | $results = $relation->indexQueryResults($results); 167 | 168 | return $results; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/Exception/FailedActionException.php: -------------------------------------------------------------------------------- 1 | $value) { 14 | if (substr($key, 0, $prefixLen) != $prefix) { 15 | continue; 16 | } 17 | $children[substr($key, $prefixLen)] = $value; 18 | } 19 | 20 | return $children; 21 | } 22 | 23 | public static function ensureParents(array $arr, string $separator = '.'): array 24 | { 25 | foreach ($arr as $key => $value) { 26 | if (strpos($key, $separator)) { 27 | $parents = static::getParents($key, $separator); 28 | foreach ($parents as $parent) { 29 | if (! isset($arr[$parent])) { 30 | $arr[$parent] = null; 31 | } 32 | } 33 | } 34 | } 35 | 36 | return $arr; 37 | } 38 | 39 | protected static function getParents(string $path, string $separator): array 40 | { 41 | $parts = explode($separator, substr($path, 0, strrpos($path, $separator))); 42 | $current = ''; 43 | $parents = []; 44 | foreach ($parts as $part) { 45 | $current = $current . ($current ? $separator : '') . $part; 46 | $parents[] = $current; 47 | } 48 | 49 | return $parents; 50 | } 51 | 52 | public static function only(array $arr, array $keys) 53 | { 54 | return array_intersect_key($arr, array_flip((array)$keys)); 55 | } 56 | 57 | public static function except(array $arr, $keys) 58 | { 59 | foreach ((array)$keys as $key) { 60 | unset($arr[$key]); 61 | } 62 | 63 | return $arr; 64 | } 65 | 66 | public static function renameKeys(array $arr, array $newNames) 67 | { 68 | if (empty($newNames)) { 69 | return $arr; 70 | } 71 | 72 | foreach (array_keys($newNames) as $name) { 73 | if (isset($arr[$name])) { 74 | $arr[$newNames[$name]] = $arr[$name]; 75 | unset($arr[$name]); 76 | } 77 | } 78 | 79 | return $arr; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Helpers/Inflector.php: -------------------------------------------------------------------------------- 1 | $col) { 24 | $parts[] = "{$firsTable}.{$col} = {$secondTable}.{$secondColumns[$k]}"; 25 | } 26 | 27 | return implode(' AND ', $parts); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Helpers/Str.php: -------------------------------------------------------------------------------- 1 | [], 10 | 'methodName' => [], 11 | 'methodName' => [], 12 | 'variableName' => [], 13 | 'className' => [], 14 | ]; 15 | 16 | public static function underscore($str) 17 | { 18 | if (! isset(static::$cache['underscore'][$str])) { 19 | $str = strtolower($str); 20 | $str = preg_replace("/[^a-z0-9]+/", ' ', $str); 21 | 22 | static::$cache['underscore'][$str] = str_replace(' ', '_', $str); 23 | } 24 | 25 | return static::$cache['underscore'][$str]; 26 | } 27 | 28 | public static function methodName($str, $verb) 29 | { 30 | $key = $verb . $str; 31 | if (! isset(static::$cache['methodName'][$key])) { 32 | static::$cache['methodName'][$key] = strtolower($verb) . static::className($str); 33 | } 34 | 35 | return static::$cache['methodName'][$key]; 36 | } 37 | 38 | public static function variableName($str) 39 | { 40 | if (! isset(static::$cache['variableName'][$str])) { 41 | $class = static::className($str); 42 | 43 | static::$cache['variableName'][$str] = strtolower(substr($class, 0, 1)) . substr($class, 1); 44 | } 45 | 46 | return static::$cache['variableName'][$str]; 47 | } 48 | 49 | public static function className($str) 50 | { 51 | if (! isset(static::$cache['className'][$str])) { 52 | $str = strtolower($str); 53 | $str = preg_replace("/[^a-z0-9]+/", ' ', $str); 54 | $str = ucwords($str); 55 | 56 | static::$cache['className'][$str] = str_replace(' ', '', $str); 57 | } 58 | 59 | return static::$cache['className'][$str]; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/MapperConfig.php: -------------------------------------------------------------------------------- 1 | entity attribute) 60 | * @var array 61 | */ 62 | public $columnAttributeMap = []; 63 | 64 | /** 65 | * @var null|HydratorInterface 66 | */ 67 | public $entityHydrator = null; 68 | 69 | /** 70 | * Default attributes 71 | * @var array 72 | */ 73 | public $entityDefaultAttributes = []; 74 | 75 | /** 76 | * List of behaviours to be attached to the mapper 77 | * @var array[BehaviourInterface] 78 | */ 79 | public $behaviours = []; 80 | 81 | /** 82 | * List of relations of the configured mapper 83 | * (key = name of relation, value = relation instance) 84 | * @var array[BaseRelation] 85 | */ 86 | public $relations = []; 87 | 88 | /** 89 | * List of query callbacks that can be called directly from the query 90 | * @var array 91 | */ 92 | public $scopes = []; 93 | 94 | /** 95 | * List of column-value pairs that act as global filters 96 | * @var array 97 | */ 98 | public $guards = []; 99 | 100 | public static function fromArray(array $array) 101 | { 102 | $instance = new self; 103 | foreach ($array as $k => $v) { 104 | $instance->{$k} = $v; 105 | } 106 | 107 | return $instance; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/Orm.php: -------------------------------------------------------------------------------- 1 | connectionLocator = $connectionLocator; 46 | 47 | if (! $castingManager) { 48 | $castingManager = new CastingManager(); 49 | } 50 | $this->castingManager = $castingManager; 51 | 52 | $this->relationBuilder = new RelationBuilder($this); 53 | } 54 | 55 | public function register($name, $mapperOrConfigOrFactory): self 56 | { 57 | if ($mapperOrConfigOrFactory instanceof MapperConfig || is_callable($mapperOrConfigOrFactory)) { 58 | $this->lazyMappers[$name] = $mapperOrConfigOrFactory; 59 | } elseif ($mapperOrConfigOrFactory instanceof Mapper) { 60 | $this->mappers[$name] = $mapperOrConfigOrFactory; 61 | $mapperOrConfigOrFactory->registerCasts($this->castingManager); 62 | } else { 63 | throw new InvalidArgumentException('$mapperOrConfigOrFactory must be a Mapper instance, 64 | a MapperConfig instance or a callable that returns a Mapper instance'); 65 | } 66 | 67 | return $this; 68 | } 69 | 70 | public function has($name): bool 71 | { 72 | return isset($this->mappers[$name]) || isset($this->lazyMappers[$name]); 73 | } 74 | 75 | public function get($name): Mapper 76 | { 77 | if (isset($this->lazyMappers[$name])) { 78 | $this->mappers[$name] = $this->buildMapper($this->lazyMappers[$name]); 79 | $this->mappers[$name]->registerCasts($this->castingManager); 80 | unset($this->lazyMappers[$name]); 81 | } 82 | 83 | if (! isset($this->mappers[$name]) || ! $this->mappers[$name]) { 84 | throw new InvalidArgumentException(sprintf('Mapper named %s is not registered', $name)); 85 | } 86 | 87 | return $this->mappers[$name]; 88 | } 89 | 90 | public function save($mapperName, EntityInterface $entity, $withRelations = true) 91 | { 92 | return $this->get($mapperName)->save($entity, $withRelations); 93 | } 94 | 95 | public function delete($mapperName, EntityInterface $entity, $withRelations = true) 96 | { 97 | return $this->get($mapperName)->delete($entity, $withRelations); 98 | } 99 | 100 | public function find($mapperName, $id, array $load = []) 101 | { 102 | return $this->get($mapperName)->find($id, $load); 103 | } 104 | 105 | public function select($mapperName): Query 106 | { 107 | return $this->get($mapperName)->newQuery(); 108 | } 109 | 110 | public function createRelation(Mapper $nativeMapper, $name, $options): Relation 111 | { 112 | return $this->relationBuilder->newRelation($nativeMapper, $name, $options); 113 | } 114 | 115 | private function buildMapper($mapperConfigOrFactory): Mapper 116 | { 117 | if ($mapperConfigOrFactory instanceof MapperConfig) { 118 | return Mapper::make($this, $mapperConfigOrFactory); 119 | } 120 | 121 | $mapper = $mapperConfigOrFactory($this); 122 | if (! $mapper instanceof Mapper) { 123 | throw new InvalidArgumentException( 124 | 'The mapper generated from the factory is not a valid `Mapper` instance' 125 | ); 126 | } 127 | 128 | return $mapper; 129 | } 130 | 131 | public function getConnectionLocator(): ConnectionLocator 132 | { 133 | return $this->connectionLocator; 134 | } 135 | 136 | /** 137 | * @return CastingManager 138 | */ 139 | public function getCastingManager(): CastingManager 140 | { 141 | return $this->castingManager; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/Query.php: -------------------------------------------------------------------------------- 1 | getReadConnection(), $bindings, $indent); 36 | $this->mapper = $mapper; 37 | $this->from($this->mapper->getTableReference()); 38 | $this->resetColumns(); 39 | $this->columns($this->mapper->getTableAlias(true) . '.*'); 40 | } 41 | 42 | public function __call(string $method, array $params) 43 | { 44 | $scope = $this->mapper->getQueryScope($method); 45 | if ($scope && is_callable($scope)) { 46 | return $scope($this, ...$params); 47 | } 48 | 49 | return parent::__call($method, $params); 50 | } 51 | 52 | public function __clone() 53 | { 54 | $vars = get_object_vars($this); 55 | unset($vars['mapper']); 56 | foreach ($vars as $name => $prop) { 57 | if (is_object($prop)) { 58 | $this->$name = clone $prop; 59 | } 60 | } 61 | } 62 | 63 | 64 | public function load(...$relations): self 65 | { 66 | foreach ($relations as $relation) { 67 | if (is_array($relation)) { 68 | $name = key($relation); 69 | $callback = current($relation); 70 | } else { 71 | $name = $relation; 72 | $callback = null; 73 | } 74 | $this->load[$name] = $callback; 75 | } 76 | 77 | return $this; 78 | } 79 | 80 | public function joinWith($name): Query 81 | { 82 | if (! $this->mapper->hasRelation($name)) { 83 | throw new \InvalidArgumentException( 84 | sprintf("Relation %s, not defined for %s", $name, $this->mapper->getTable()) 85 | ); 86 | } 87 | $relation = $this->mapper->getRelation($name); 88 | 89 | return $relation->joinSubselect($this, $name); 90 | } 91 | 92 | public function subSelectForJoinWith(): Query 93 | { 94 | $subselect = new Query($this->mapper, $this->bindings, $this->indent . ' '); 95 | $subselect->resetFrom(); 96 | $subselect->resetColumns(); 97 | 98 | return $subselect; 99 | } 100 | 101 | public function first() 102 | { 103 | $row = $this->fetchOne(); 104 | 105 | return $this->mapper->newEntityFromRow($row, $this->load); 106 | } 107 | 108 | public function get(): Collection 109 | { 110 | return $this->mapper->newCollectionFromRows( 111 | $this->connection->fetchAll($this->getStatement(), $this->getBindValues()), 112 | $this->load 113 | ); 114 | } 115 | 116 | public function paginate($perPage, $page): PaginatedCollection 117 | { 118 | /** @var Query $countQuery */ 119 | $countQuery = clone $this; 120 | $total = $countQuery->count(); 121 | 122 | if ($total == 0) { 123 | $this->mapper->newPaginatedCollectionFromRows([], $total, $perPage, $page, $this->load); 124 | } 125 | 126 | $this->perPage($perPage); 127 | $this->page($page); 128 | 129 | return $this->mapper->newPaginatedCollectionFromRows($this->fetchAll(), $total, $perPage, $page, $this->load); 130 | } 131 | 132 | /** 133 | * Executes the query with a limit of $size and applies the callback on each entity 134 | * The callback can change the DB in such a way that you can end up in an infinite loop 135 | * (depending on the sorting) so we set a limit on the number of chunks that can be processed 136 | * 137 | * @param int $size 138 | * @param callable $callback 139 | * @param int $limit 140 | */ 141 | public function chunk(int $size, callable $callback, int $limit = 100000) 142 | { 143 | if (!$this->orderBy->build()) { 144 | $this->orderBy(...(array) $this->mapper->getPrimaryKey()); 145 | } 146 | 147 | $run = 0; 148 | while ($run < $limit) { 149 | $query = clone $this; 150 | $query->limit($size); 151 | $query->offset($run * $size); 152 | 153 | $results = $query->get(); 154 | 155 | if (count($results) === 0) { 156 | break; 157 | } 158 | 159 | foreach ($results as $entity) { 160 | $callback($entity); 161 | } 162 | 163 | $run++; 164 | } 165 | } 166 | 167 | public function count() 168 | { 169 | $this->resetOrderBy(); 170 | $this->resetColumns(); 171 | $this->columns('COUNT(*) AS total'); 172 | 173 | return (int)$this->fetchValue(); 174 | } 175 | 176 | public function setGuards(array $guards) 177 | { 178 | foreach ($guards as $column => $value) { 179 | if (is_int($column)) { 180 | $this->guards[] = $value; 181 | } else { 182 | $this->guards[$column] = $value; 183 | } 184 | } 185 | 186 | return $this; 187 | } 188 | 189 | public function resetGuards() 190 | { 191 | $this->guards = []; 192 | 193 | return; 194 | } 195 | 196 | protected function applyGuards() 197 | { 198 | if (empty($this->guards)) { 199 | return; 200 | } 201 | 202 | $this->groupCurrentWhere(); 203 | foreach ($this->guards as $column => $value) { 204 | if (is_int($column)) { 205 | $this->where($value); 206 | } else { 207 | $this->where($column, $value); 208 | } 209 | } 210 | } 211 | 212 | public function getStatement(): string 213 | { 214 | $this->applyGuards(); 215 | 216 | return parent::getStatement(); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/QueryBuilder.php: -------------------------------------------------------------------------------- 1 | getQueryClass($mapper); 29 | 30 | return new $queryClass($mapper); 31 | } 32 | 33 | protected function getQueryClass(Mapper $mapper) 34 | { 35 | $mapperClass = get_class($mapper); 36 | if (! isset($this->queryClasses[$mapperClass])) { 37 | $queryClass = $mapperClass . 'Query'; 38 | if (class_exists($queryClass)) { 39 | $this->queryClasses[$mapperClass] = $queryClass; 40 | } else { 41 | $this->queryClasses[$mapperClass] = Query::class; 42 | } 43 | } 44 | 45 | return $this->queryClasses[$mapperClass]; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Relation/Aggregate.php: -------------------------------------------------------------------------------- 1 | name = $name; 30 | $this->relation = $relation; 31 | $this->options = $options; 32 | } 33 | 34 | public function getQuery(Tracker $tracker) 35 | { 36 | $keys = $this->relation->getKeyPairs(); 37 | 38 | /** @var Query $query */ 39 | $query = $this->relation->getQuery($tracker); 40 | $query->resetColumns(); 41 | $query->columns(...array_values($keys)); 42 | $query->columns(sprintf( 43 | '%s as %s', 44 | $this->options[RelationConfig::AGG_FUNCTION], 45 | $this->name 46 | )); 47 | 48 | $callback = $this->options[RelationConfig::AGG_CALLBACK] ?? null; 49 | if (is_callable($callback)) { 50 | $query = $callback($query); 51 | } 52 | 53 | $query->groupBy(...array_values($keys)); 54 | 55 | return $query; 56 | } 57 | 58 | public function attachLazyAggregateToEntity(EntityInterface $entity, Tracker $tracker) 59 | { 60 | $valueLoader = new LazyAggregate($entity, $tracker, $this); 61 | $this->relation->getNativeMapper()->setEntityAttribute($entity, $this->name, $valueLoader); 62 | } 63 | 64 | public function attachAggregateToEntity(EntityInterface $entity, array $results) 65 | { 66 | $found = null; 67 | foreach ($results as $row) { 68 | if ($this->entityMatchesRow($entity, $row)) { 69 | $found = $row; 70 | break; 71 | } 72 | } 73 | $this->relation 74 | ->getNativeMapper() 75 | ->setEntityAttribute($entity, $this->name, $found ? $found[$this->name] : null); 76 | } 77 | 78 | public function isLazyLoad() 79 | { 80 | return !isset($this->options[RelationConfig::LOAD_STRATEGY]) || 81 | $this->options[RelationConfig::LOAD_STRATEGY] == RelationConfig::LOAD_LAZY; 82 | } 83 | 84 | public function isEagerLoad() 85 | { 86 | return isset($this->options[RelationConfig::LOAD_STRATEGY]) && 87 | $this->options[RelationConfig::LOAD_STRATEGY] == RelationConfig::LOAD_EAGER; 88 | } 89 | 90 | public function getName() 91 | { 92 | return $this->name; 93 | } 94 | 95 | private function entityMatchesRow(EntityInterface $entity, $row) 96 | { 97 | $keys = $this->relation->getKeyPairs(); 98 | foreach ($keys as $nativeCol => $foreignCol) { 99 | $entityValue = $this->relation->getNativeMapper()->getEntityAttribute($entity, $nativeCol); 100 | $rowValue = $row[$foreignCol]; 101 | // if both native and foreign key values are present (not unlinked entities) they must be the same 102 | // otherwise we assume that the entities can be linked together 103 | if ($entityValue && $rowValue && $entityValue != $rowValue) { 104 | return false; 105 | } 106 | } 107 | 108 | return true; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Relation/HasAggregates.php: -------------------------------------------------------------------------------- 1 | aggregates)) { 19 | return; 20 | } 21 | 22 | $aggregates = []; 23 | $aggregatesList = $this->getOption(RelationConfig::AGGREGATES); 24 | if (!is_array($aggregatesList) || empty($aggregatesList)) { 25 | $this->aggregates = $aggregates; 26 | return; 27 | } 28 | 29 | foreach ($aggregatesList as $name => $options) { 30 | $agg = new Aggregate($name, /** @scrutinizer ignore-type */ $this, $options); 31 | $aggregates[$name] = $agg; 32 | } 33 | 34 | $this->aggregates = $aggregates; 35 | } 36 | 37 | public function getAggregates() 38 | { 39 | $this->compileAggregates(); 40 | 41 | return $this->aggregates; 42 | } 43 | 44 | abstract public function getOption($name); 45 | } 46 | -------------------------------------------------------------------------------- /src/Relation/ManyToOne.php: -------------------------------------------------------------------------------- 1 | foreignMapper->getPrimaryKey(); 18 | if (! isset($this->options[RelationConfig::FOREIGN_KEY])) { 19 | $this->options[RelationConfig::FOREIGN_KEY] = $foreignKey; 20 | } 21 | 22 | if (! isset($this->options[RelationConfig::NATIVE_KEY])) { 23 | $nativeKey = $this->getKeyColumn($this->name, $foreignKey); 24 | $this->options[RelationConfig::NATIVE_KEY] = $nativeKey; 25 | } 26 | } 27 | 28 | public function joinSubselect(Query $query, string $reference) 29 | { 30 | $subselect = $query->subSelectForJoinWith() 31 | ->from($this->foreignMapper->getTable()) 32 | ->columns($this->foreignMapper->getTable() . '.*') 33 | ->as($reference); 34 | 35 | $subselect = $this->applyQueryCallback($subselect); 36 | 37 | $subselect = $this->applyForeignGuards($subselect); 38 | 39 | return $query->join('INNER', $subselect->getStatement(), $this->getJoinOnForSubselect()); 40 | } 41 | 42 | public function attachMatchesToEntity(EntityInterface $nativeEntity, array $result) 43 | { 44 | // no point in linking entities if the native one is deleted 45 | if ($nativeEntity->getPersistenceState() == StateEnum::DELETED) { 46 | return; 47 | } 48 | 49 | $nativeId = $this->getEntityId($this->nativeMapper, $nativeEntity, array_keys($this->keyPairs)); 50 | 51 | $found = $result[$nativeId] ?? []; 52 | 53 | $this->attachEntities($nativeEntity, $found[0] ?? null); 54 | } 55 | 56 | /** 57 | * @param EntityInterface $nativeEntity 58 | * @param EntityInterface $foreignEntity 59 | */ 60 | public function attachEntities(EntityInterface $nativeEntity, EntityInterface $foreignEntity = null): void 61 | { 62 | // no point in linking entities if the native one is deleted 63 | if ($nativeEntity->getPersistenceState() == StateEnum::DELETED) { 64 | return; 65 | } 66 | 67 | $nativeKey = (array)$this->getOption(RelationConfig::NATIVE_KEY); 68 | $foreignKey = (array)$this->getOption(RelationConfig::FOREIGN_KEY); 69 | 70 | foreach ($nativeKey as $k => $col) { 71 | $this->nativeMapper->setEntityAttribute( 72 | $nativeEntity, 73 | $col, 74 | $foreignEntity ? $this->foreignMapper->getEntityAttribute($foreignEntity, $foreignKey[$k]) : null 75 | ); 76 | } 77 | 78 | $this->nativeMapper->setEntityAttribute($nativeEntity, $this->name, $foreignEntity); 79 | } 80 | 81 | public function detachEntities(EntityInterface $nativeEntity, EntityInterface $foreignEntity) 82 | { 83 | if ($nativeEntity->getPersistenceState() == StateEnum::DELETED) { 84 | return; 85 | } 86 | 87 | // required for DELETED entities that throw errors if they are changed 88 | $state = $foreignEntity->getPersistenceState(); 89 | $foreignEntity->setPersistenceState(StateEnum::SYNCHRONIZED); 90 | 91 | $nativeKey = (array)$this->getOption(RelationConfig::NATIVE_KEY); 92 | 93 | foreach ($nativeKey as $k => $col) { 94 | $this->nativeMapper->setEntityAttribute( 95 | $nativeEntity, 96 | $col, 97 | null 98 | ); 99 | } 100 | 101 | $this->nativeMapper->setEntityAttribute($nativeEntity, $this->name, null); 102 | $foreignEntity->setPersistenceState($state); 103 | } 104 | 105 | protected function addActionOnDelete(BaseAction $action) 106 | { 107 | // no cascade delete? treat it as a save 108 | if (! $this->isCascade()) { 109 | $this->addActionOnSave($action); 110 | } else { 111 | $foreignEntity = $this->nativeMapper 112 | ->getEntityAttribute($action->getEntity(), $this->name); 113 | 114 | if ($foreignEntity) { 115 | $remainingRelations = $this->getRemainingRelations($action->getOption('relations')); 116 | $deleteAction = $this->foreignMapper 117 | ->newDeleteAction($foreignEntity, ['relations' => $remainingRelations]); 118 | $action->prepend($deleteAction); 119 | $action->prepend($this->newSyncAction($action->getEntity(), $foreignEntity, 'delete')); 120 | } 121 | } 122 | } 123 | 124 | protected function addActionOnSave(BaseAction $action) 125 | { 126 | if (!$this->relationWasChanged($action->getEntity())) { 127 | return; 128 | } 129 | $foreignEntity = $this->nativeMapper->getEntityAttribute($action->getEntity(), $this->name); 130 | if ($foreignEntity) { 131 | $remainingRelations = $this->getRemainingRelations($action->getOption('relations')); 132 | $saveAction = $this->foreignMapper 133 | ->newSaveAction($foreignEntity, ['relations' => $remainingRelations]); 134 | $saveAction->addColumns($this->getExtraColumnsForAction()); 135 | $action->prepend($saveAction); 136 | $action->prepend($this->newSyncAction($action->getEntity(), $foreignEntity, 'save')); 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/Relation/OneToMany.php: -------------------------------------------------------------------------------- 1 | nativeMapper->getPrimaryKey(); 20 | if (! isset($this->options[RelationConfig::NATIVE_KEY])) { 21 | $this->options[RelationConfig::NATIVE_KEY] = $nativeKey; 22 | } 23 | 24 | if (! isset($this->options[RelationConfig::FOREIGN_KEY])) { 25 | $prefix = Inflector::singularize($this->nativeMapper->getTable()); 26 | $this->options[RelationConfig::FOREIGN_KEY] = $this->getKeyColumn($prefix, $nativeKey); 27 | } 28 | 29 | parent::applyDefaults(); 30 | } 31 | 32 | public function getQuery(Tracker $tracker) 33 | { 34 | $nativeKey = $this->options[RelationConfig::NATIVE_KEY]; 35 | $nativePks = $tracker->pluck($nativeKey); 36 | 37 | $query = $this->foreignMapper 38 | ->newQuery() 39 | ->where($this->options[RelationConfig::FOREIGN_KEY], $nativePks); 40 | 41 | $query = $this->applyQueryCallback($query); 42 | 43 | $query = $this->applyForeignGuards($query); 44 | 45 | return $query; 46 | } 47 | 48 | public function joinSubselect(Query $query, string $reference) 49 | { 50 | $subselect = $query->subSelectForJoinWith() 51 | ->columns($this->foreignMapper->getTable() . '.*') 52 | ->from($this->foreignMapper->getTable()) 53 | ->as($reference); 54 | 55 | $subselect = $this->applyQueryCallback($subselect); 56 | 57 | $subselect = $this->applyForeignGuards($subselect); 58 | 59 | return $query->join('INNER', $subselect->getStatement(), $this->getJoinOnForSubselect()); 60 | } 61 | 62 | public function attachMatchesToEntity(EntityInterface $nativeEntity, array $result) 63 | { 64 | // no point in linking entities if the native one is deleted 65 | if ($nativeEntity->getPersistenceState() == StateEnum::DELETED) { 66 | return; 67 | } 68 | 69 | $nativeId = $this->getEntityId($this->nativeMapper, $nativeEntity, array_keys($this->keyPairs)); 70 | 71 | $found = $result[$nativeId] ?? []; 72 | 73 | $this->nativeMapper->setEntityAttribute($nativeEntity, $this->name, new Collection($found)); 74 | } 75 | 76 | public function attachEntities(EntityInterface $nativeEntity, EntityInterface $foreignEntity) 77 | { 78 | foreach ($this->keyPairs as $nativeCol => $foreignCol) { 79 | $nativeKeyValue = $this->nativeMapper->getEntityAttribute($nativeEntity, $nativeCol); 80 | $this->foreignMapper->setEntityAttribute($foreignEntity, $foreignCol, $nativeKeyValue); 81 | } 82 | } 83 | 84 | public function detachEntities(EntityInterface $nativeEntity, EntityInterface $foreignEntity) 85 | { 86 | $state = $foreignEntity->getPersistenceState(); 87 | $foreignEntity->setPersistenceState(StateEnum::SYNCHRONIZED); 88 | foreach ($this->keyPairs as $nativeCol => $foreignCol) { 89 | $this->foreignMapper->setEntityAttribute($foreignEntity, $foreignCol, null); 90 | } 91 | $this->foreignMapper->setEntityAttribute($foreignEntity, $this->name, null); 92 | $foreignEntity->setPersistenceState($state); 93 | } 94 | 95 | protected function addActionOnDelete(BaseAction $action) 96 | { 97 | $nativeEntity = $action->getEntity(); 98 | $remainingRelations = $this->getRemainingRelations($action->getOption('relations')); 99 | 100 | // no cascade delete? treat as save so we can process the changes 101 | if (! $this->isCascade()) { 102 | $this->addActionOnSave($action); 103 | } else { 104 | // retrieve them again from the DB since the related collection might not have everything 105 | // for example due to a relation query callback 106 | $foreignEntities = $this->getQuery(new Tracker([$nativeEntity->getArrayCopy()])) 107 | ->get(); 108 | 109 | foreach ($foreignEntities as $foreignEntity) { 110 | $deleteAction = $this->foreignMapper 111 | ->newDeleteAction($foreignEntity, ['relations' => $remainingRelations]); 112 | $action->append($this->newSyncAction($nativeEntity, $foreignEntity, 'delete')); 113 | $action->append($deleteAction); 114 | } 115 | } 116 | } 117 | 118 | protected function addActionOnSave(BaseAction $action) 119 | { 120 | if (!$this->relationWasChanged($action->getEntity())) { 121 | return; 122 | } 123 | 124 | $nativeEntity = $action->getEntity(); 125 | $remainingRelations = $this->getRemainingRelations($action->getOption('relations')); 126 | 127 | /** @var Collection $foreignEntities */ 128 | $foreignEntities = $this->nativeMapper->getEntityAttribute($nativeEntity, $this->name); 129 | $changes = $foreignEntities->getChanges(); 130 | 131 | // save the entities still in the collection 132 | foreach ($foreignEntities as $foreignEntity) { 133 | if (! empty($foreignEntity->getChanges())) { 134 | $saveAction = $this->foreignMapper 135 | ->newSaveAction($foreignEntity, ['relations' => $remainingRelations]); 136 | $saveAction->addColumns($this->getExtraColumnsForAction()); 137 | $action->append($this->newSyncAction($nativeEntity, $foreignEntity, 'save')); 138 | $action->append($saveAction); 139 | } 140 | } 141 | 142 | // save entities that were removed but NOT deleted 143 | foreach ($changes['removed'] as $foreignEntity) { 144 | $saveAction = $this->foreignMapper 145 | ->newSaveAction($foreignEntity, ['relations' => $remainingRelations]); 146 | $saveAction->addColumns($this->getExtraColumnsForAction()); 147 | $action->append($this->newSyncAction($nativeEntity, $foreignEntity, 'delete')); 148 | $action->append($saveAction); 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/Relation/OneToOne.php: -------------------------------------------------------------------------------- 1 | getPersistenceState() == StateEnum::DELETED) { 15 | return; 16 | } 17 | 18 | $nativeId = $this->getEntityId($this->nativeMapper, $nativeEntity, array_keys($this->keyPairs)); 19 | 20 | $found = $result[$nativeId] ?? []; 21 | 22 | $this->nativeMapper->setEntityAttribute($nativeEntity, $this->name, $found[0] ?? null); 23 | } 24 | 25 | protected function addActionOnDelete(BaseAction $action) 26 | { 27 | // no cascade delete? treat it as a save 28 | if (! $this->isCascade()) { 29 | $this->addActionOnSave($action); 30 | } else { 31 | $foreignEntity = $this->nativeMapper 32 | ->getEntityAttribute($action->getEntity(), $this->name); 33 | 34 | if ($foreignEntity) { 35 | $remainingRelations = $this->getRemainingRelations($action->getOption('relations')); 36 | $deleteAction = $this->foreignMapper 37 | ->newDeleteAction($foreignEntity, ['relations' => $remainingRelations]); 38 | $action->prepend($deleteAction); 39 | $action->append($this->newSyncAction($action->getEntity(), $foreignEntity, 'delete')); 40 | } 41 | } 42 | } 43 | 44 | protected function addActionOnSave(BaseAction $action) 45 | { 46 | if (! $this->relationWasChanged($action->getEntity())) { 47 | return; 48 | } 49 | $foreignEntity = $this->nativeMapper->getEntityAttribute($action->getEntity(), $this->name); 50 | if ($foreignEntity) { 51 | $remainingRelations = $this->getRemainingRelations($action->getOption('relations')); 52 | $saveAction = $this->foreignMapper 53 | ->newSaveAction($foreignEntity, ['relations' => $remainingRelations]); 54 | $saveAction->addColumns($this->getExtraColumnsForAction()); 55 | $action->prepend($saveAction); 56 | $action->append($this->newSyncAction($action->getEntity(), $foreignEntity, 'save')); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Relation/RelationBuilder.php: -------------------------------------------------------------------------------- 1 | orm = $orm; 20 | } 21 | 22 | public function newRelation(Mapper $nativeMapper, $name, $options): Relation 23 | { 24 | $foreignMapper = $options[RelationConfig::FOREIGN_MAPPER]; 25 | if ($this->orm->has($foreignMapper)) { 26 | if (! $foreignMapper instanceof Mapper) { 27 | $foreignMapper = $this->orm->get($foreignMapper); 28 | } 29 | } 30 | $type = $options[RelationConfig::TYPE]; 31 | $relationClass = __NAMESPACE__ . '\\' . Str::className($type); 32 | 33 | if (! class_exists($relationClass)) { 34 | throw new \InvalidArgumentException("{$relationClass} does not exist"); 35 | } 36 | 37 | return new $relationClass($name, $nativeMapper, $foreignMapper, $options); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Relation/RelationConfig.php: -------------------------------------------------------------------------------- 1 | orm->get('products'); 17 | 18 | $this->insertRow('content', ['content_type' => 'product', 'title' => 'Product 1']); 19 | 20 | $product = $mapper->find(1); 21 | $this->assertNotNull($product); 22 | 23 | $mapper->delete($product); 24 | $this->assertNull($mapper->find(1)); 25 | $this->assertNull($product->id); 26 | $this->assertEquals(StateEnum::DELETED, $product->getPersistenceState()); 27 | } 28 | 29 | public function test_entity_is_reverted() 30 | { 31 | 32 | $mapper = Mapper::make($this->orm, MapperConfig::fromArray([ 33 | MapperConfig::TABLE => 'content', 34 | MapperConfig::COLUMNS => ['id', 'content_type', 'title', 'description', 'summary'], 35 | MapperConfig::GUARDS => ['content_type' => 'product'], 36 | MapperConfig::BEHAVIOURS => [new \Sirius\Orm\Tests\Behaviour\FakeThrowsException()] 37 | ])); 38 | 39 | 40 | $this->insertRow('content', ['content_type' => 'product', 'title' => 'Product 1']); 41 | 42 | $product = $mapper->find(1); 43 | $this->assertNotNull($product); 44 | 45 | $this->expectException(FailedActionException::class); 46 | $mapper->delete($product); 47 | $this->assertEquals(1, $product->id); 48 | $this->assertEquals(StateEnum::SYNCHRONIZED, $product->getPersistenceState()); 49 | } 50 | } -------------------------------------------------------------------------------- /tests/Action/FakeThrowsException.php: -------------------------------------------------------------------------------- 1 | orm->get('products'); 16 | 17 | $product = $mapper->newEntity(['title' => 'Product 1']); 18 | 19 | $this->assertNull($product->id); 20 | 21 | $mapper->save($product); 22 | 23 | $this->assertNotNull($product->id); 24 | 25 | $product = $mapper->find($product->id); 26 | $this->assertEquals('Product 1', $product->title); 27 | } 28 | 29 | public function test_entity_is_reverted() 30 | { 31 | 32 | $mapper = Mapper::make($this->orm, MapperConfig::fromArray([ 33 | MapperConfig::TABLE => 'content', 34 | MapperConfig::COLUMNS => ['id', 'content_type', 'title', 'description', 'summary'], 35 | MapperConfig::GUARDS => ['content_type' => 'product'], 36 | MapperConfig::BEHAVIOURS => [new \Sirius\Orm\Tests\Behaviour\FakeThrowsException()] 37 | ])); 38 | 39 | $this->expectException(\Exception::class); 40 | 41 | $product = $mapper->newEntity(['title' => 'Product 1']); 42 | 43 | $this->assertNull($product->id); 44 | 45 | $mapper->save($product); 46 | 47 | $this->assertNull($product->id); 48 | } 49 | } -------------------------------------------------------------------------------- /tests/Action/UpdateTest.php: -------------------------------------------------------------------------------- 1 | orm->get('products'); 16 | 17 | $product = $mapper->newEntity(['title' => 'Product 1']); 18 | $mapper->save($product); 19 | 20 | // reload after insert 21 | $product = $mapper->find($product->id); 22 | $product->description = 'Description product 1'; 23 | $mapper->save($product); 24 | 25 | // reload after save 26 | $product = $mapper->find($product->id); 27 | $this->assertEquals('Description product 1', $product->description); 28 | $this->assertEquals(StateEnum::SYNCHRONIZED, $product->getPersistenceState()); 29 | } 30 | 31 | public function test_entity_is_reverted() 32 | { 33 | 34 | $mapper = Mapper::make($this->orm, MapperConfig::fromArray([ 35 | MapperConfig::TABLE => 'content', 36 | MapperConfig::COLUMNS => ['id', 'content_type', 'title', 'description', 'summary'], 37 | MapperConfig::GUARDS => ['content_type' => 'product'], 38 | MapperConfig::BEHAVIOURS => [new \Sirius\Orm\Tests\Behaviour\FakeThrowsException()] 39 | ])); 40 | 41 | $this->insertRow('content', ['content_type' => 'product', 'title' => 'Product 1']); 42 | 43 | $product = $mapper->find(1); 44 | $product->title = 'Product 2'; 45 | 46 | $this->expectException(\Exception::class); 47 | $mapper->save($product); 48 | $this->assertEquals(StateEnum::CHANGED, $product->getPersistenceState()); 49 | } 50 | 51 | public function test_column_attribute_map() 52 | { 53 | 54 | $mapper = Mapper::make($this->orm, MapperConfig::fromArray([ 55 | MapperConfig::TABLE => 'content', 56 | MapperConfig::COLUMNS => ['id', 'content_type', 'title', 'description', 'summary'], 57 | MapperConfig::COLUMN_ATTRIBUTE_MAP => ['summary' => 'excerpt'], 58 | MapperConfig::GUARDS => ['content_type' => 'product'], 59 | ])); 60 | 61 | $this->insertRow('content', ['content_type' => 'product', 'title' => 'Product 1', 'summary' => 'Excerpt']); 62 | 63 | $product = $mapper->find(1); 64 | $this->assertEquals('Excerpt', $product->excerpt); 65 | 66 | $product->excerpt = 'New excerpt'; 67 | 68 | $mapper->save($product); 69 | $product = $mapper->find(1); 70 | $this->assertEquals('New excerpt', $product->excerpt); 71 | } 72 | } -------------------------------------------------------------------------------- /tests/BaseTestCase.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 39 | $connectionLocator = ConnectionLocator::new($this->connection); 40 | $this->connectionLocator = $connectionLocator; 41 | $this->orm = new Orm($connectionLocator); 42 | $this->createTables(getenv('DB_ENGINE') ? getenv('DB_ENGINE') : 'generic'); 43 | $this->loadMappers(); 44 | $connectionLocator->logQueries(); 45 | } 46 | 47 | public function createTables($fileName = 'generic') 48 | { 49 | foreach (include(__DIR__ . "/resources/tables/{$fileName}.php") as $sql) { 50 | $this->connection->perform($sql); 51 | } 52 | } 53 | 54 | public function loadMappers() 55 | { 56 | $this->orm->register('images', $this->getMapperConfig('images')); 57 | $this->orm->register('tags', $this->getMapperConfig('tags')); 58 | $this->orm->register('categories', $this->getMapperConfig('categories')); 59 | $this->orm->register('products', $this->getMapperConfig('products')); 60 | $this->orm->register('content_products', $this->getMapperConfig('content_products')); 61 | 62 | } 63 | 64 | public function getMapperConfig($name) 65 | { 66 | return include(__DIR__ . '/resources/mappers/' . $name . '.php'); 67 | } 68 | 69 | protected function insertRow($table, $values) 70 | { 71 | $insert = new Insert($this->connection); 72 | $insert->into($table)->columns($values); 73 | $this->connection->perform($insert->getStatement(), $insert->getBindValues()); 74 | } 75 | 76 | public function assertExpectedQueries($expected) 77 | { 78 | $this->assertEquals($expected, count($this->connectionLocator->getQueries())); 79 | } 80 | 81 | public function assertRowDeleted($table, ...$conditions) 82 | { 83 | $select = new Select($this->connection); 84 | $row = $select->from($table) 85 | ->where(...$conditions) 86 | ->fetchOne(); 87 | $this->assertNull($row); 88 | } 89 | 90 | public function assertRowPresent($table, ...$conditions) 91 | { 92 | $select = new Select($this->connection); 93 | $row = $select->from($table) 94 | ->where(...$conditions) 95 | ->fetchOne(); 96 | $this->assertNotNull($row); 97 | } 98 | 99 | protected function insertRows($table, $columns, $rows) 100 | { 101 | foreach ($rows as $row) { 102 | $this->insertRow($table, array_combine($columns, $row)); 103 | } 104 | } 105 | 106 | protected function assertSameStatement($expect, $actual) 107 | { 108 | $this->assertSame($this->removeWhiteSpace($expect), $this->removeWhiteSpace($actual)); 109 | } 110 | 111 | protected function removeWhiteSpace($str) 112 | { 113 | $str = trim($str); 114 | $str = preg_replace('/^[ \t]*/m', '', $str); 115 | $str = preg_replace('/[ \t]*$/m', '', $str); 116 | $str = preg_replace('/[ ]{2,}/m', ' ', $str); 117 | $str = preg_replace('/[\r\n|\n|\r]+/', ' ', $str); 118 | $str = str_replace('( ', '(', $str); 119 | $str = str_replace(' )', ')', $str); 120 | 121 | return $str; 122 | } 123 | } -------------------------------------------------------------------------------- /tests/Behaviour/FakeThrowsException.php: -------------------------------------------------------------------------------- 1 | prepend(new \Sirius\Orm\Tests\Action\FakeThrowsException()); 23 | return $delete; 24 | } 25 | 26 | public function onSave(Mapper $mapper, ActionInterface $delete) 27 | { 28 | $delete->append(new \Sirius\Orm\Tests\Action\FakeThrowsException()); 29 | return $delete; 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /tests/Behaviour/SoftDeleteTest.php: -------------------------------------------------------------------------------- 1 | orm, MapperConfig::fromArray([ 16 | MapperConfig::TABLE => 'content', 17 | MapperConfig::COLUMNS => ['id', 'content_type', 'title', 'description', 'summary'], 18 | MapperConfig::GUARDS => ['content_type' => 'product'], 19 | MapperConfig::BEHAVIOURS => [new SoftDelete()] 20 | ])); 21 | 22 | $this->insertRow('content', ['content_type' => 'product', 'title' => 'Product 1']); 23 | 24 | $this->assertTrue($mapper->delete($mapper->find(1))); 25 | $this->assertRowPresent('content', 'id = 1'); 26 | 27 | // check the mapper doesn't find the row 28 | $this->assertNull($mapper->find(1)); 29 | 30 | // mapper without the behaviour will find the row 31 | $this->assertNotNull($mapper->without('soft_delete')->find(1)); 32 | } 33 | } -------------------------------------------------------------------------------- /tests/Behaviour/TimestampsTest.php: -------------------------------------------------------------------------------- 1 | mapper = Mapper::make($this->orm, MapperConfig::fromArray([ 22 | MapperConfig::TABLE => 'content', 23 | MapperConfig::COLUMNS => ['id', 'content_type', 'title', 'description', 'summary'], 24 | MapperConfig::GUARDS => ['content_type' => 'product'], 25 | MapperConfig::BEHAVIOURS => [new Timestamps()] 26 | ])); 27 | 28 | $product = $this->mapper->newEntity(['title' => 'Product 1']); 29 | 30 | $this->assertNull($product->created_at); 31 | $this->assertNull($product->updated_at); 32 | 33 | $this->assertTrue($this->mapper->save($product)); 34 | 35 | $this->assertNotNull($product->created_at); 36 | $this->assertNotNull($product->updated_at); 37 | } 38 | } -------------------------------------------------------------------------------- /tests/CastingManagerTest.php: -------------------------------------------------------------------------------- 1 | cm = new CastingManager(); 16 | } 17 | 18 | public function test_json() 19 | { 20 | $this->assertSame([], $this->cm->cast('json', '')->getArrayCopy()); 21 | $this->assertSame(['ab' => 2], $this->cm->cast('json', '{"ab":2}')->getArrayCopy()); 22 | $this->assertSame(['ab' => 2], $this->cm->cast('json', ['ab' => 2])->getArrayCopy()); 23 | $this->assertSame(['ab' => 2], $this->cm->cast('json', new \ArrayObject(['ab' => 2]))->getArrayCopy()); 24 | } 25 | 26 | public function test_json_for_db() 27 | { 28 | $this->assertSame(null, $this->cm->cast('json_for_db', [])); 29 | $this->assertSame('{"ab":2}', $this->cm->cast('json_for_db', '{"ab":2}')); 30 | $this->assertSame('{"ab":2}', $this->cm->cast('json_for_db', ['ab' => 2])); 31 | $this->assertSame('{"ab":2}', $this->cm->cast('json_for_db', new \ArrayObject(['ab' => 2]))); 32 | } 33 | 34 | public function test_cast_array() 35 | { 36 | 37 | $result = $this->cm->castArray([ 38 | 'price' => '10', 39 | 'active' => '1', 40 | ], [ 41 | 'price' => 'float', 42 | 'active' => 'bool', 43 | ]); 44 | 45 | $this->assertSame([ 46 | 'price' => 10.0, 47 | 'active' => true 48 | ], $result); 49 | } 50 | public function test_cast_array_for_db() 51 | { 52 | 53 | $result = $this->cm->castArrayForDb([ 54 | 'price' => 10.0, 55 | 'active' => true 56 | ], [ 57 | 'price' => 'float', 58 | 'active' => 'bool', 59 | ]); 60 | 61 | $this->assertSame([ 62 | 'price' => 10.0, 63 | 'active' => 1 64 | ], $result); 65 | } 66 | } -------------------------------------------------------------------------------- /tests/Entity/FakeEntity.php: -------------------------------------------------------------------------------- 1 | 'int', 14 | 'value' => 'decimal:2' 15 | ]; 16 | 17 | protected function castFeaturedImageIdAttribute($value) 18 | { 19 | return (int)$value; 20 | } 21 | 22 | } -------------------------------------------------------------------------------- /tests/Helpers/ArrTest.php: -------------------------------------------------------------------------------- 1 | null, 17 | 'key_1.key_3' => null, 18 | 'key_2.key_3' => null, 19 | 'key_1.key_2.key_3' => null 20 | ]; 21 | 22 | $this->assertSame([ 23 | 'key_2' => null, 24 | 'key_3' => null, 25 | 'key_2.key_3' => null, 26 | ], Arr::getChildren($arr, 'key_1')); 27 | 28 | $this->assertSame([ 29 | 'key_3' => null, 30 | ], Arr::getChildren($arr, 'key_1.key_2')); 31 | } 32 | 33 | public function test_ensure_parents() 34 | { 35 | $arr = [ 36 | 'key_1.key_2.key_3' => null 37 | ]; 38 | 39 | $this->assertSame([ 40 | 'key_1.key_2.key_3' => null, 41 | 'key_1' => null, 42 | 'key_1.key_2' => null 43 | ], Arr::ensureParents($arr)); 44 | } 45 | } -------------------------------------------------------------------------------- /tests/Helpers/StrTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('abc_def', Str::underscore('abc Def')); 16 | $this->assertEquals('abc_def', Str::underscore('abc-def')); 17 | $this->assertEquals('abcdef', Str::underscore('abcDef')); 18 | } 19 | 20 | public function test_method_name() 21 | { 22 | $this->assertEquals('getAttributeName', Str::methodName('attribute_name', 'get')); 23 | } 24 | 25 | public function test_variable_name() 26 | { 27 | $this->assertEquals('primaryKey', Str::variableName('primary_key')); 28 | } 29 | } -------------------------------------------------------------------------------- /tests/MapperTest.php: -------------------------------------------------------------------------------- 1 | mapper = Mapper::make($this->orm, MapperConfig::fromArray([ 22 | MapperConfig::TABLE => 'products', 23 | MapperConfig::ENTITY_CLASS => ProductEntity::class, 24 | MapperConfig::TABLE_ALIAS => 'p', 25 | MapperConfig::COLUMNS => ['id', 'category_id', 'featured_image_id', 'sku', 'price'], 26 | MapperConfig::COLUMN_ATTRIBUTE_MAP => ['price' => 'value'] 27 | ])); 28 | } 29 | 30 | public function test_new_entity() 31 | { 32 | $product = $this->mapper->newEntity([ 33 | 'category_id' => '10', 34 | 'featured_image_id' => '20', 35 | 'sku' => 'sku 1', 36 | 'price' => '100.343' 37 | ]); 38 | 39 | $this->assertEquals(100.34, $product->value); 40 | $this->assertEquals(10, $product->category_id); 41 | $this->assertEquals(20, $product->featured_image_id); 42 | } 43 | } -------------------------------------------------------------------------------- /tests/OrmTest.php: -------------------------------------------------------------------------------- 1 | 'products', 17 | MapperConfig::TABLE_ALIAS => 'p', 18 | MapperConfig::COLUMNS => ['id', 'category_id', 'featured_image_id', 'sku', 'price'] 19 | ]); 20 | $this->orm->register('products', $mapperConfig); 21 | 22 | $this->assertTrue($this->orm->has('products')); 23 | $this->assertInstanceOf(Mapper::class, $this->orm->get('products')); 24 | } 25 | 26 | public function test_lazy_mapper_factory() 27 | { 28 | $mapperConfig = MapperConfig::fromArray([ 29 | MapperConfig::TABLE => 'products', 30 | MapperConfig::TABLE_ALIAS => 'p', 31 | MapperConfig::COLUMNS => ['id', 'category_id', 'featured_image_id', 'sku', 'price'] 32 | ]); 33 | $this->orm->register('products', function ($orm) use ($mapperConfig) { 34 | return Mapper::make($orm, $mapperConfig); 35 | }); 36 | 37 | $this->assertTrue($this->orm->has('products')); 38 | $this->assertInstanceOf(Mapper::class, $this->orm->get('products')); 39 | } 40 | 41 | public function test_mapper_instance() 42 | { 43 | $mapperConfig = MapperConfig::fromArray([ 44 | MapperConfig::TABLE => 'products', 45 | MapperConfig::TABLE_ALIAS => 'p', 46 | MapperConfig::COLUMNS => ['id', 'category_id', 'featured_image_id', 'sku', 'price'] 47 | ]); 48 | $mapper = Mapper::make($this->orm, $mapperConfig); 49 | $this->orm->register('products', $mapper); 50 | 51 | $this->assertInstanceOf(Mapper::class, $this->orm->get('products')); 52 | } 53 | 54 | public function test_exception_thrown_on_invalid_mapper_instance() 55 | { 56 | $this->expectException(\InvalidArgumentException::class); 57 | $this->orm->register('products', new \stdClass()); 58 | } 59 | 60 | public function test_exception_thrown_on_unknown_mapper() 61 | { 62 | $this->expectException(\InvalidArgumentException::class); 63 | $this->orm->get('pages'); 64 | } 65 | 66 | public function test_exception_thrown_on_invalid_mapper_factory() 67 | { 68 | $this->expectException(\InvalidArgumentException::class); 69 | $this->orm->register('products', function () { 70 | return new \stdClass(); 71 | }); 72 | $this->orm->get('products'); 73 | } 74 | } -------------------------------------------------------------------------------- /tests/QueryTest.php: -------------------------------------------------------------------------------- 1 | mapper = $this->orm->get('products'); 22 | } 23 | 24 | public function test_find() 25 | { 26 | $this->insertRow('content', [ 27 | 'content_type' => 'product', 28 | 'title' => 'Product 1' 29 | ]); 30 | $entity = $this->mapper->find(1); 31 | $this->assertSame('Product 1', $entity->title); 32 | 33 | $this->assertNull($this->mapper->find(2)); 34 | } 35 | 36 | public function test_query_get() 37 | { 38 | $this->insertRows('content', ['content_type', 'title'], [ 39 | ['product', 'Product 1'], 40 | ['product', 'Product 2'], 41 | ['product', 'Product 3'], 42 | ['product', 'Product 4'], 43 | ]); 44 | 45 | $result = $this->mapper->newQuery() 46 | ->where('title', 'Product 2', '>=') 47 | ->get(); 48 | 49 | $this->assertEquals(3, count($result)); 50 | } 51 | 52 | public function test_chunk() 53 | { 54 | $this->insertRows('content', ['content_type', 'title'], [ 55 | ['product', 'Product 1'], 56 | ['product', 'Product 2'], 57 | ['product', 'Product 3'], 58 | ['product', 'Product 4'], 59 | ['product', 'Product 5'], 60 | ['product', 'Product 6'], 61 | ]); 62 | 63 | $found = 0; 64 | $result = $this->mapper->newQuery() 65 | ->chunk(2, function($entity) use (&$found) { 66 | $found += 1; 67 | }, 2); 68 | 69 | $this->assertEquals(4, $found); 70 | } 71 | 72 | public function test_chunk_no_limit() 73 | { 74 | $this->insertRows('content', ['content_type', 'title'], [ 75 | ['product', 'Product 1'], 76 | ['product', 'Product 2'], 77 | ['product', 'Product 3'], 78 | ['product', 'Product 4'], 79 | ['product', 'Product 5'], 80 | ['product', 'Product 6'], 81 | ]); 82 | 83 | $found = 0; 84 | $result = $this->mapper->newQuery() 85 | ->chunk(2, function($entity) use (&$found) { 86 | $found += 1; 87 | }); 88 | 89 | $this->assertEquals(6, $found); 90 | } 91 | 92 | public function test_query_paginate() 93 | { 94 | $this->insertRows('content', ['content_type', 'title'], [ 95 | ['product', 'Product 1'], 96 | ['product', 'Product 2'], 97 | ['product', 'Product 3'], 98 | ['product', 'Product 4'], 99 | ]); 100 | 101 | $result = $this->mapper->newQuery() 102 | ->paginate(3, 2); 103 | 104 | $this->assertEquals(1, count($result)); 105 | $this->assertEquals(2, $result->getCurrentPage()); 106 | $this->assertEquals(4, $result->getPageStart()); 107 | $this->assertEquals(4, $result->getPageStart()); 108 | $this->assertEquals(4, $result->getPageEnd()); 109 | $this->assertEquals(2, $result->getTotalPages()); 110 | $this->assertEquals(3, $result->getPerPage()); 111 | $this->assertEquals(4, $result->getTotalCount()); 112 | 113 | 114 | $result = $this->mapper->newQuery() 115 | ->where('title', 'Product 5', '>') 116 | ->paginate(3, 2); 117 | 118 | $this->assertEquals(0, $result->getTotalCount()); 119 | $this->assertEquals(0, $result->getPageStart()); 120 | $this->assertEquals(0, $result->getPageEnd()); 121 | } 122 | } -------------------------------------------------------------------------------- /tests/Relation/OneToOneTest.php: -------------------------------------------------------------------------------- 1 | loadMappers(); 26 | 27 | $this->nativeMapper = $this->orm->get('products'); 28 | $this->foreignMapper = $this->orm->get('content_products'); 29 | } 30 | 31 | public function test_delete_with_cascade_true() 32 | { 33 | // reconfigure products-featured_image to use CASCADE 34 | $config = $this->getMapperConfig('products'); 35 | $config->relations['fields'][RelationConfig::CASCADE] = true; 36 | $this->nativeMapper = $this->orm->register('products', $config)->get('products'); 37 | 38 | $this->insertRow('content', ['id' => 1, 'content_type' => 'product', 'title' => 'Product 1']); 39 | $this->insertRow('content_products', ['content_id' => 1, 'featured_image_id' => 2]); 40 | 41 | $product = $this->nativeMapper->find(1); 42 | $this->assertNotNull($product->fields); 43 | $this->assertTrue($this->nativeMapper->delete($product, true)); 44 | $this->assertRowDeleted('content', 'id', 1); 45 | $this->assertRowDeleted('content_products', 'content_id', 1); 46 | } 47 | 48 | public function test_delete_with_cascade_false() 49 | { 50 | $this->insertRow('content', ['id' => 1, 'content_type' => 'product', 'title' => 'Product 1']); 51 | $this->insertRow('content_products', ['content_id' => 1, 'featured_image_id' => 2]); 52 | 53 | $product = $this->nativeMapper->find(1); 54 | $product->fields->featured_image_id = 3; 55 | 56 | $this->assertTrue($this->nativeMapper->delete($product, false)); 57 | $this->assertRowDeleted('content', 'id', 1); 58 | $fields = $this->foreignMapper->find(1); 59 | $this->assertEquals('2', $fields->featured_image_id); 60 | } 61 | 62 | public function test_save_with_relations() 63 | { 64 | $this->insertRow('content', ['id' => 1, 'content_type' => 'product', 'title' => 'Product 1']); 65 | $this->insertRow('content_products', ['content_id' => 1, 'featured_image_id' => 2]); 66 | 67 | $product = $this->nativeMapper->find(1); 68 | $product->title = 'Product 2'; 69 | $product->fields->featured_image_id = 3; 70 | 71 | $this->assertTrue($this->nativeMapper->save($product)); 72 | 73 | $product = $this->nativeMapper->find(1); 74 | $this->assertEquals('Product 2', $product->title); 75 | $this->assertEquals(3, $product->fields->featured_image_id); 76 | } 77 | 78 | public function test_save_without_relations() 79 | { 80 | $this->insertRow('content', ['id' => 1, 'content_type' => 'product', 'title' => 'Product 1']); 81 | $this->insertRow('content_products', ['content_id' => 1, 'featured_image_id' => 2]); 82 | 83 | $product = $this->nativeMapper->find(1); 84 | $product->title = 'Product 2'; 85 | $product->fields->featured_image_id = 3; 86 | 87 | $this->assertTrue($this->nativeMapper->save($product, false)); 88 | 89 | $product = $this->nativeMapper->find(1); 90 | $this->assertEquals('Product 2', $product->title); 91 | $this->assertEquals(2, $product->fields->featured_image_id); 92 | } 93 | 94 | public function test_join_with() { 95 | $query = $this->nativeMapper->newQuery() 96 | ->joinWith('fields'); 97 | 98 | // the featured_image is not a real one-to-one relation 99 | $expectedStatement = <<assertSameStatement($expectedStatement, $query->getStatement()); 113 | } 114 | } -------------------------------------------------------------------------------- /tests/Relation/RelationTest.php: -------------------------------------------------------------------------------- 1 | nativeMapper = Mapper::make($this->orm, MapperConfig::fromArray([ 19 | MapperConfig::TABLE => 'products', 20 | MapperConfig::COLUMNS => ['id', 'related_col_1', 'related_col_2'] 21 | ])); 22 | 23 | $this->foreignMapper = Mapper::make($this->orm, MapperConfig::fromArray([ 24 | MapperConfig::TABLE => 'categories', 25 | MapperConfig::PRIMARY_KEY => ['col_1', 'col_2'], 26 | MapperConfig::COLUMNS => ['col_1', 'col_2', 'name'] 27 | ])); 28 | 29 | $relation = new ManyToOne('related', $this->nativeMapper, $this->foreignMapper); 30 | 31 | $this->assertSame(['related_col_1', 'related_col_2'], $relation->getOption(RelationConfig::NATIVE_KEY)); 32 | 33 | $native1 = new GenericEntity(['related_col_1' => 10, 'related_col_2' => 10]); 34 | $native2 = new GenericEntity(['related_col_1' => 10, 'related_col_2' => 20]); 35 | 36 | $foreign1 = new GenericEntity(['col_1' => 10, 'col_2' => 10]); 37 | $foreign2 = new GenericEntity(['col_1' => 10, 'col_2' => 20]); 38 | 39 | $tracker = new Tracker([ 40 | $native1->getArrayCopy(), 41 | $native2->getArrayCopy(), 42 | ]); 43 | 44 | $expectedStatement = <<assertSameStatement($expectedStatement, $relation->getQuery($tracker)->getStatement()); 63 | $this->assertSame([ 64 | '__1__' => [10, \PDO::PARAM_INT], 65 | '__2__' => [10, \PDO::PARAM_INT], 66 | '__3__' => [10, \PDO::PARAM_INT], 67 | '__4__' => [20, \PDO::PARAM_INT], 68 | ], $relation->getQuery($tracker)->getBindValues()); 69 | } 70 | } -------------------------------------------------------------------------------- /tests/benchmark/AbstractTestSuite.php: -------------------------------------------------------------------------------- 1 | con->exec('DROP TABLE [products]'); 46 | $this->con->exec('DROP TABLE [products_tags]'); 47 | $this->con->exec('DROP TABLE [tags]'); 48 | $this->con->exec('DROP TABLE [categories]'); 49 | $this->con->exec('DROP TABLE [images]'); 50 | } catch (PDOException $e) { 51 | // do nothing - the tables probably don't exist yet 52 | } 53 | $this->con->exec('CREATE TABLE [products] 54 | ( 55 | [id] INTEGER NOT NULL PRIMARY KEY, 56 | [name] VARCHAR(255) NOT NULL, 57 | [sku] VARCHAR(24) NOT NULL, 58 | [price] FLOAT, 59 | [category_id] INTEGER, 60 | FOREIGN KEY (category_id) REFERENCES categories(id) 61 | )'); 62 | $this->con->exec('CREATE TABLE [categories] 63 | ( 64 | [id] INTEGER NOT NULL PRIMARY KEY, 65 | [name] VARCHAR(128) NOT NULL 66 | )'); 67 | $this->con->exec('CREATE TABLE [images] 68 | ( 69 | [id] INTEGER NOT NULL PRIMARY KEY, 70 | [imageable_id] INTEGER, 71 | [imageable_type] VARCHAR(128), 72 | [path] VARCHAR(128) NOT NULL 73 | )'); 74 | $this->con->exec('CREATE TABLE [tags] 75 | ( 76 | [id] INTEGER NOT NULL PRIMARY KEY, 77 | [name] VARCHAR(128) NOT NULL 78 | )'); 79 | $this->con->exec('CREATE TABLE [products_tags] 80 | ( 81 | [id] INTEGER NOT NULL PRIMARY KEY, 82 | [product_id] INTEGER, 83 | [tag_id] INTEGER, 84 | [position] INTEGER 85 | )'); 86 | } 87 | 88 | public function run() 89 | { 90 | $t1 = $this->runMethod('insert'); 91 | $t2 = $this->runMethod('update'); 92 | $t3 = $this->runMethod('find'); 93 | $t4 = $this->runMethod('complexQuery'); 94 | $t5 = $this->runMethod('relations'); 95 | echo sprintf("| %32s | %6d | %6d | %6d | %6d | %6d |", str_replace('TestSuite', '', get_class($this)), $t1, $t2, $t3, $t4, $t5); 96 | } 97 | 98 | public function runMethod($methodName, $nbTest = self::NB_TEST) 99 | { 100 | // prepare method are used to isolate some operations outside the tests 101 | // for the getters test we isolate the retrieval of the object from the db 102 | $prepareMethod = 'prepare_' . $methodName; 103 | if (method_exists($this, $prepareMethod)) { 104 | $this->$prepareMethod(); 105 | } 106 | 107 | $testMethod = 'test_' . $methodName; 108 | $this->$testMethod(); 109 | 110 | $timer = new sfTimer(); 111 | 112 | $this->clearCache(); 113 | 114 | for ($i = 0; $i < $nbTest; $i++) { 115 | $this->$methodName($i); 116 | } 117 | $t = $timer->getElapsedTime(); 118 | 119 | return $t * 1000; 120 | } 121 | 122 | public function assertEquals($expected, $actual, $message = null) { 123 | if ($expected != $actual) { 124 | throw new Exception($message ?? sprintf('%s is not the same %s', $expected, $actual)); 125 | } 126 | } 127 | 128 | public function assertNotNull($actual, $message = null) { 129 | if (null == $actual) { 130 | throw new Exception($message ?? sprintf('%s is null', $actual)); 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /tests/benchmark/TestRunner.php: -------------------------------------------------------------------------------- 1 | initialize(); 9 | $test->run(); 10 | echo sprintf(" %11s | %6.2f |\n", number_format(memory_get_usage(true) - $memory), (microtime(true) - $time)); 11 | -------------------------------------------------------------------------------- /tests/benchmark/sfTimer.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | /** 12 | * sfTimer class allows to time some PHP code. 13 | * 14 | * @package symfony 15 | * @subpackage util 16 | * @author Fabien Potencier 17 | * @version SVN: $Id: sfTimer.class.php 9079 2008-05-20 00:38:07Z Carl.Vondrick $ 18 | */ 19 | class sfTimer 20 | { 21 | protected 22 | $startTime = null, 23 | $totalTime = null, 24 | $name = '', 25 | $calls = 0; 26 | 27 | /** 28 | * Creates a new sfTimer instance. 29 | * 30 | * @param string $name The name of the timer 31 | */ 32 | public function __construct($name = '') 33 | { 34 | $this->name = $name; 35 | $this->startTimer(); 36 | } 37 | 38 | /** 39 | * Starts the timer. 40 | */ 41 | public function startTimer() 42 | { 43 | $this->startTime = microtime(true); 44 | } 45 | 46 | /** 47 | * Stops the timer and add the amount of time since the start to the total time. 48 | * 49 | * @return float Time spend for the last call 50 | */ 51 | public function addTime() 52 | { 53 | $spend = microtime(true) - $this->startTime; 54 | $this->totalTime += $spend; 55 | ++$this->calls; 56 | 57 | return $spend; 58 | } 59 | 60 | /** 61 | * Gets the number of calls this timer has been called to time code. 62 | * 63 | * @return integer Number of calls 64 | */ 65 | public function getCalls() 66 | { 67 | return $this->calls; 68 | } 69 | 70 | /** 71 | * Gets the total time elapsed for all calls of this timer. 72 | * 73 | * @return float Time in seconds 74 | */ 75 | public function getElapsedTime() 76 | { 77 | if (null === $this->totalTime) 78 | { 79 | $this->addTime(); 80 | } 81 | 82 | return $this->totalTime; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | 'categories', 8 | MapperConfig::COLUMNS => ['id', 'parent_id', 'name'], 9 | MapperConfig::CASTS => [ 10 | 'id' => 'int', 11 | 'details' => 'json' 12 | ], 13 | MapperConfig::RELATIONS => [ 14 | 'products' => [ 15 | RelationConfig::TYPE => RelationConfig::TYPE_ONE_TO_MANY, 16 | RelationConfig::FOREIGN_MAPPER => 'content_products', 17 | RelationConfig::AGGREGATES => [ 18 | 'products_count' => [ 19 | RelationConfig::AGG_FUNCTION => 'count(content_id)', 20 | ] 21 | ] 22 | ], 23 | 'parent' => [ 24 | RelationConfig::TYPE => RelationConfig::TYPE_MANY_TO_ONE, 25 | RelationConfig::FOREIGN_MAPPER => 'categories', 26 | ] 27 | ] 28 | ]); -------------------------------------------------------------------------------- /tests/resources/mappers/content_products.php: -------------------------------------------------------------------------------- 1 | 'content_products', 9 | MapperConfig::PRIMARY_KEY=> 'content_id', 10 | MapperConfig::COLUMNS => ['content_id', 'sku', 'price', 'category_id', 'featured_image_id'], 11 | MapperConfig::CASTS => [ 12 | 'content_id' => 'int', 13 | 'category_id' => 'int', 14 | 'featured_image_id' => 'int', 15 | 'price' => 'decimal:2', 16 | ], 17 | MapperConfig::RELATIONS => [ 18 | 'category' => [ 19 | RelationConfig::FOREIGN_MAPPER => 'categories', 20 | RelationConfig::TYPE => RelationConfig::TYPE_MANY_TO_ONE 21 | ], 22 | 'featured_image' => [ 23 | RelationConfig::FOREIGN_MAPPER => 'images', 24 | RelationConfig::TYPE => RelationConfig::TYPE_MANY_TO_ONE 25 | ], 26 | ] 27 | ]); -------------------------------------------------------------------------------- /tests/resources/mappers/images.php: -------------------------------------------------------------------------------- 1 | 'images', 8 | MapperConfig::COLUMNS => ['id', 'name', 'folder'], 9 | MapperConfig::CASTS => [ 10 | 'id' => 'int', 11 | 'content_id' => 'int' 12 | ], 13 | MapperConfig::RELATIONS => [ 14 | 'products_where_featured' => [ 15 | RelationConfig::TYPE => RelationConfig::TYPE_ONE_TO_MANY, 16 | RelationConfig::FOREIGN_MAPPER => 'products', 17 | RelationConfig::FOREIGN_KEY => 'featured_image_id' 18 | ] 19 | ] 20 | ]); -------------------------------------------------------------------------------- /tests/resources/mappers/products.php: -------------------------------------------------------------------------------- 1 | 'content', 9 | MapperConfig::COLUMNS => ['id', 'content_type', 'title', 'description', 'summary'], 10 | MapperConfig::GUARDS => ['content_type' => 'product'], 11 | MapperConfig::RELATIONS => [ 12 | 'category' => [ 13 | RelationConfig::FOREIGN_MAPPER => 'categories', 14 | RelationConfig::TYPE => RelationConfig::TYPE_MANY_TO_ONE 15 | ], 16 | 'fields' => [ 17 | RelationConfig::FOREIGN_MAPPER => 'content_products', 18 | RelationConfig::TYPE => RelationConfig::TYPE_ONE_TO_ONE, 19 | RelationConfig::FOREIGN_KEY => 'content_id', 20 | RelationConfig::LOAD_STRATEGY => RelationConfig::LOAD_EAGER 21 | ], 22 | 'images' => [ 23 | RelationConfig::FOREIGN_MAPPER => 'images', 24 | RelationConfig::TYPE => RelationConfig::TYPE_ONE_TO_MANY, 25 | RelationConfig::FOREIGN_GUARDS => ['type' => 'product'], 26 | RelationConfig::AGGREGATES => [ 27 | 'images_count' => [ 28 | RelationConfig::AGG_FUNCTION => 'count(id)', 29 | ] 30 | ] 31 | ], 32 | 'tags' => [ 33 | RelationConfig::FOREIGN_MAPPER => 'tags', 34 | RelationConfig::TYPE => RelationConfig::TYPE_MANY_TO_MANY, 35 | RelationConfig::THROUGH_TABLE => 'products_tags', 36 | RelationConfig::THROUGH_NATIVE_COLUMN => 'product_id', 37 | RelationConfig::THROUGH_COLUMNS => ['position'], 38 | RelationConfig::QUERY_CALLBACK => function (Query $query) { 39 | $query->orderBy('position ASC'); 40 | return $query; 41 | }, 42 | RelationConfig::AGGREGATES => [ 43 | 'tags_count' => [ 44 | RelationConfig::AGG_FUNCTION => 'count(tags.id)', 45 | ] 46 | ] 47 | ] 48 | ] 49 | ]); -------------------------------------------------------------------------------- /tests/resources/mappers/tags.php: -------------------------------------------------------------------------------- 1 | 'tags', 8 | MapperConfig::COLUMNS => ['id', 'name'], 9 | // MapperConfig::RELATIONS => [ 10 | // 'products' => [ 11 | // RelationConfig::FOREIGN_MAPPER => 'products', 12 | // RelationConfig::TYPE => RelationConfig::TYPE_MANY_TO_MANY, 13 | // RelationConfig::THROUGH_COLUMNS => ['position'], 14 | // ] 15 | // ] 16 | ]); -------------------------------------------------------------------------------- /tests/resources/tables/generic.php: -------------------------------------------------------------------------------- 1 |