├── .gitignore ├── tests ├── Fakes │ ├── PremiumMember.php │ ├── RegularMember.php │ ├── Plan.php │ ├── Subscription.php │ └── Member.php ├── factories │ ├── PlanFactory.php │ ├── SubscriptionFactory.php │ └── MemberFactory.php ├── TestCase.php ├── Unit │ ├── TypeMapTest.php │ ├── RelationshipsTest.php │ ├── STIParentTest.php │ └── STISubtypeTest.php └── Concerns │ └── ManagesDatabase.php ├── .travis.yml ├── bootstrap └── blueprint_macro.php ├── phpunit.xml ├── src ├── TypeMap.php └── STI.php ├── composer.json ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.lock 3 | -------------------------------------------------------------------------------- /tests/Fakes/PremiumMember.php: -------------------------------------------------------------------------------- 1 | hasMany(Member::class); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/Fakes/Subscription.php: -------------------------------------------------------------------------------- 1 | belongsTo(Member::class); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/factories/PlanFactory.php: -------------------------------------------------------------------------------- 1 | define(Plan::class, function (Faker $faker) { 9 | return ['name' => $faker->name]; 10 | }); 11 | -------------------------------------------------------------------------------- /tests/Fakes/Member.php: -------------------------------------------------------------------------------- 1 | hasOne(Subscription::class); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/factories/SubscriptionFactory.php: -------------------------------------------------------------------------------- 1 | define(Subscription::class, function (Faker $faker) { 10 | return ['name' => $faker->name]; 11 | }); 12 | 13 | $factory->state(Subscription::class, 'with-member', function () use ($factory) { 14 | return [ 15 | 'member_id' => $factory->create(Member::class) 16 | ]; 17 | }); 18 | -------------------------------------------------------------------------------- /bootstrap/blueprint_macro.php: -------------------------------------------------------------------------------- 1 | string($columnName); 17 | }); 18 | -------------------------------------------------------------------------------- /tests/factories/MemberFactory.php: -------------------------------------------------------------------------------- 1 | define(Member::class, function (Faker $faker) use ($types) { 16 | return ['type' => $faker->randomElement($types), 'name' => $faker->name]; 17 | }); 18 | 19 | $factory->state(Member::class, RegularMember::class, [ 20 | 'type' => 'regular_member' 21 | ]); 22 | 23 | $factory->state(Member::class, PremiumMember::class, [ 24 | 'type' => PremiumMember::class 25 | ]); 26 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | ./tests/Unit 14 | 15 | 16 | 17 | 18 | ./src 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/TypeMap.php: -------------------------------------------------------------------------------- 1 | $className) { 27 | if ($className != $searchedClassName) { 28 | continue; 29 | } 30 | 31 | return $key; 32 | } 33 | 34 | return $searchedClassName; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phl/laravel-sti", 3 | "description": "Single table inheritance with eloquent", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "paulhenri-l", 9 | "email": "25308170+paulhenri-l@users.noreply.github.com" 10 | } 11 | ], 12 | "require": { 13 | "php": "^7.3", 14 | "illuminate/database": "^8.0", 15 | "illuminate/events": "^8.0" 16 | }, 17 | "require-dev": { 18 | "phpunit/phpunit": "^9.3", 19 | "fzaninotto/faker": "^1.8", 20 | "symfony/finder": "^4.1", 21 | "symfony/var-dumper": "^4.1", 22 | "illuminate/pagination": "^8.0", 23 | "laravel/legacy-factories": "^1.0" 24 | }, 25 | "autoload": { 26 | "psr-4": { 27 | "PHL\\LaravelSTI\\": "src/" 28 | }, 29 | "files": [ 30 | "bootstrap/blueprint_macro.php" 31 | ] 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "Tests\\": "tests/" 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | prepareDbIfNecessary(); 21 | $this->freshSchema(); 22 | 23 | Relation::morphMap([ 24 | 'regular_member' => RegularMember::class, 25 | ]); 26 | } 27 | 28 | /** 29 | * Helper to count the number of time an object of a type has been seen. 30 | */ 31 | protected function updateMemberCount(Member $member, array &$results) 32 | { 33 | if ($member instanceof PremiumMember) { 34 | $results['premium_count']++; 35 | } elseif ($member instanceof RegularMember) { 36 | $results['regular_count']++; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2018] [Paul-Henri Leobon] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/Unit/TypeMapTest.php: -------------------------------------------------------------------------------- 1 | Foo::class, 17 | 'bar' => Bar::class 18 | ]); 19 | } 20 | 21 | /** 22 | * You can get the class name from an alias. 23 | */ 24 | public function testGetClassNameFromAlias() 25 | { 26 | $this->assertEquals( 27 | Foo::class, 28 | TypeMap::getClassName('foo') 29 | ); 30 | 31 | $this->assertEquals( 32 | Bar::class, 33 | TypeMap::getClassName('bar') 34 | ); 35 | } 36 | 37 | /** 38 | * You can get the alias from a class name. 39 | */ 40 | public function testGetAliasFromClassName() 41 | { 42 | $this->assertEquals( 43 | 'foo', 44 | TypeMap::getAlias(Foo::class) 45 | ); 46 | 47 | $this->assertEquals( 48 | 'bar', 49 | TypeMap::getAlias(Bar::class) 50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/Unit/RelationshipsTest.php: -------------------------------------------------------------------------------- 1 | factory(Member::class)->state(PremiumMember::class)->create(); 20 | $subscription = $this->factory(Subscription::class)->create(['member_id' => $member->id]); 21 | 22 | $this->assertInstanceOf(PremiumMember::class, $subscription->member); 23 | } 24 | 25 | /** 26 | * Test that you can still retrieve relationships from a subtype. 27 | */ 28 | public function testHasOneFromSubtypes() 29 | { 30 | $member = $this->factory(Member::class)->state(PremiumMember::class)->create(); 31 | $this->factory(Subscription::class)->create(['member_id' => $member->id]); 32 | 33 | // Calling fresh will downcast it from Member to PremiumMember 34 | $member = $member->fresh(); 35 | 36 | $this->assertInstanceOf(Subscription::class, $member->subscription); 37 | } 38 | 39 | /** 40 | * Test that has many return the correct sub types. 41 | * 42 | * If this test and the above pass, relationships should work just fine :) 43 | */ 44 | public function testHasMany() 45 | { 46 | $plan = $this->factory(Plan::class)->create(); 47 | 48 | $this->factory(Member::class, 3) 49 | ->state(PremiumMember::class) 50 | ->create(['plan_id' => $plan->id]); 51 | 52 | $this->factory(Member::class) 53 | ->state(RegularMember::class) 54 | ->create(['plan_id' => $plan->id]); 55 | 56 | $this->assertCount(3, $plan->members->filter(function ($member) { 57 | return $member instanceof PremiumMember; 58 | })); 59 | 60 | $this->assertCount(1, $plan->members->filter(function ($member) { 61 | return $member instanceof RegularMember; 62 | })); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Travis](https://api.travis-ci.org/paulhenri-l/laravel-sti.svg?branch=master) 2 | 3 | # Laravel STI 4 | 5 | > This is my take at bringing Single Table Inheritance (STI) to the Eloquent ORM. 6 | 7 | *This package can be used outside of a laravel app* 8 | 9 | ## Installation 10 | 11 | ``` 12 | composer require phl/laravel-sti 13 | ``` 14 | 15 | ## Usage 16 | 17 | Besides using the trait and adding the type column there is nothing much to do. 18 | 19 | ```php 20 | class Member extends Illuminate\Database\Eloquent\Model 21 | { 22 | use PHL\LaravelSTI\STI; 23 | } 24 | ``` 25 | 26 | ```php 27 | Schema::create('members', function ($table) { 28 | // ... 29 | $table->type(); 30 | // ... 31 | }); 32 | ``` 33 | 34 | You can now extend the Member model. 35 | 36 | ```php 37 | class PremiumMember extends Member 38 | { 39 | // 40 | } 41 | 42 | class RegularMember extends Member 43 | { 44 | // 45 | } 46 | ``` 47 | 48 | And enjoy single table inheritance! 49 | 50 | ## Configuration 51 | 52 | Out of the box there is absolutely nothing to configure. You may want to change 53 | the defaults though. 54 | 55 | ### Type column 56 | 57 | By default the type column is named `type` if you want to use another name you 58 | can specify it in the migration and in the model. 59 | 60 | ```php 61 | class Member extends Illuminate\Database\Eloquent\Model 62 | { 63 | use PHL\LaravelSTI\STI; 64 | 65 | protected static $stiTypeKey = 'custom_type_column' 66 | } 67 | ``` 68 | 69 | ```php 70 | Capsule::schema()->create('members', function ($table) { 71 | // ... 72 | $table->type('custom_type_column'); 73 | // ... 74 | }); 75 | ``` 76 | 77 | ### Type value 78 | 79 | If you do not want your type column to contain the class name you can use 80 | Eloquent's `Relation::morphMap()` function to add mapping between a name 81 | and a class. 82 | 83 | ```php 84 | Relation::morphMap([ 85 | 'regular_member' => RegularMember::class, 86 | ]); 87 | ``` 88 | 89 | Now the type column will be filled with `regular_member` instead of `Member`. 90 | This helps avoid leaking code details into the DB. 91 | 92 | ## Read the source Luke! 93 | 94 | If you are currious about the implementation details, the code and tests have 95 | been heavily documented :) 96 | -------------------------------------------------------------------------------- /tests/Concerns/ManagesDatabase.php: -------------------------------------------------------------------------------- 1 | bootEloquent(); 33 | $this->loadFactories(); 34 | static::$dbPrepared = true; 35 | } 36 | } 37 | 38 | /** 39 | * Boot eloquent by using an in memory sqlite db. 40 | */ 41 | protected function bootEloquent() 42 | { 43 | $capsule = new Capsule; 44 | 45 | $capsule->addConnection([ 46 | 'driver' => 'sqlite', 47 | 'database' => ':memory:', 48 | ]); 49 | 50 | $capsule->setEventDispatcher(new Dispatcher(new Container)); 51 | $capsule->setAsGlobal(); 52 | $capsule->bootEloquent(); 53 | } 54 | 55 | /** 56 | * Equivalent of migarte:fresh 57 | */ 58 | protected function freshSchema() 59 | { 60 | Capsule::schema()->dropIfExists('members'); 61 | Capsule::schema()->create('members', function ($table) { 62 | $table->increments('id'); 63 | $table->type(); 64 | $table->unsignedInteger('plan_id')->nullable(); 65 | $table->string('name'); 66 | $table->string('bio')->nullable(); 67 | $table->timestamps(); 68 | }); 69 | 70 | Capsule::schema()->dropIfExists('subscriptions'); 71 | Capsule::schema()->create('subscriptions', function ($table) { 72 | $table->increments('id'); 73 | $table->unsignedInteger('member_id'); 74 | $table->string('name'); 75 | $table->timestamps(); 76 | }); 77 | 78 | Capsule::schema()->dropIfExists('plans'); 79 | Capsule::schema()->create('plans', function ($table) { 80 | $table->increments('id'); 81 | $table->string('name'); 82 | $table->timestamps(); 83 | }); 84 | } 85 | 86 | /** 87 | * Load the database factories. 88 | */ 89 | protected function loadFactories() 90 | { 91 | static::$factory = Factory::construct(\Faker\Factory::create(), __DIR__ . '/../factories'); 92 | } 93 | 94 | /** 95 | * You can use this method exactly as you would use the default laravel 96 | * factory helper. 97 | */ 98 | public function factory(...$arguments) 99 | { 100 | if (isset($arguments[1]) && is_string($arguments[1])) { 101 | return static::$factory->of($arguments[0], $arguments[1])->times($arguments[2] ?? null); 102 | } elseif (isset($arguments[1])) { 103 | return static::$factory->of($arguments[0])->times($arguments[1]); 104 | } 105 | 106 | return static::$factory->of($arguments[0]); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/STI.php: -------------------------------------------------------------------------------- 1 | setAttribute(static::typeKey(), TypeMap::getAlias(static::class)); 24 | } 25 | 26 | /** 27 | * Scope all queries to the current subtype (if we are in one). 28 | */ 29 | public static function bootSTI() 30 | { 31 | if (static::inSTIParent()) { 32 | return; 33 | } 34 | 35 | static::addGlobalScope(function ($query) { 36 | return $query->whereSTIType(static::class); 37 | }); 38 | } 39 | 40 | /** 41 | * Allways use the STI parent model's table. 42 | */ 43 | public function getTable() 44 | { 45 | if (static::inSTIParent()) { 46 | return parent::getTable(); 47 | } 48 | 49 | return $this->newSTIParent()->getTable(); 50 | } 51 | 52 | /** 53 | * Use the STI parent class to infer the foreign key name. 54 | */ 55 | public function getForeignKey() 56 | { 57 | $table = Str::snake( 58 | class_basename(static::getSTIParentClassName()) 59 | ); 60 | 61 | return "{$table}_{$this->getKeyName()}"; 62 | } 63 | 64 | /** 65 | * Pass the given type through the type map before searching for it. 66 | */ 67 | public function scopeWhereSTIType(Builder $query, $type) 68 | { 69 | return $query->where(static::typeKey(), TypeMap::getAlias($type)); 70 | } 71 | 72 | /** 73 | * Return a new instance of the STI model or the subtype it represents. 74 | */ 75 | public function newInstance($attributes = [], $exists = false) 76 | { 77 | $type = $this->findClassNameThanksToAttributes($attributes); 78 | 79 | $model = new $type($attributes); 80 | 81 | $model->exists = $exists; 82 | 83 | $model->setConnection( 84 | $this->getConnectionName() 85 | ); 86 | 87 | return $model; 88 | } 89 | 90 | /** 91 | * Return a new instance of the STI model or the subtype it represents. 92 | */ 93 | public function newFromBuilder($attributes = [], $connection = null) 94 | { 95 | $attributes = (array)$attributes; 96 | $type = $this->findClassNameThanksToAttributes($attributes); 97 | 98 | $model = (new $type)->newInstance([], true); 99 | 100 | $model->setRawAttributes($attributes, true); 101 | 102 | $model->setConnection($connection ?: $this->getConnectionName()); 103 | 104 | $model->fireModelEvent('retrieved', false); 105 | 106 | return $model; 107 | } 108 | 109 | /** 110 | * Here we'll let laravel do the heavy lifting in its original updateOrCrate 111 | * function. Once that's done we'll check if the returned model has been 112 | * recently created. When that's the case we simply have to call fresh on 113 | * it to get it "downcasted" into the correct type. 114 | * 115 | * As this issue only occurs when calling updateOrCreate on the parent model 116 | * (the one that uses the STI trait) and not the subtype ones we'll exit 117 | * early if we can see that we are in the context of a subtype. 118 | */ 119 | public function overloadedUpdateOrCreate(...$args) 120 | { 121 | $model = $this->forwardCallTo($this->newQuery(), 'updateOrCreate', $args); 122 | 123 | if (!static::inSTIParent()) { 124 | return $model; 125 | } 126 | 127 | if ($model->wasRecentlyCreated) { 128 | $model = $model->fresh(); 129 | $model->wasRecentlyCreated = true; 130 | } 131 | 132 | return $model; 133 | } 134 | 135 | /** 136 | * Update or create is not able to return models in the correct subtype when 137 | * it creates them. So we'll have to overload the original method in order 138 | * to add support for this feature. 139 | * 140 | * As updateOrCreate lives in the builder it is not directly available on 141 | * the model. It also means that this function can be called both on an 142 | * instance or statically thanks to the model's __call() and __callStatic() 143 | * magic methods. 144 | * 145 | * Our new code for the updateOrCreate method can only work in an instance 146 | * context. So we'll have to be creative in order to keep the abbility to 147 | * call it both statically and from an instance. That is the case because 148 | * adding it as an instance method will make it unavailable for static calls. 149 | * 150 | * The workaround for this issue is to add our implementation in a new 151 | * instance method named overloadedUpdateOrCreate() that will get called 152 | * from this updateOrCreate method. 153 | * 154 | * This updateOrCreate method being static means that it can be called 155 | * from both a static and an instance context. 156 | * 157 | * @param array $attributes 158 | * @param array $values 159 | * @return \Illuminate\Database\Eloquent\Model|static 160 | */ 161 | public static function updateOrCreate(...$args) 162 | { 163 | return (new static())->overloadedUpdateOrCreate(...$args); 164 | } 165 | 166 | /** 167 | * Are we currently in a subtype or the parent model. 168 | * 169 | * Even though this method might look extremely weird fear not! 170 | * It works like a charm and is covered by the testsuite :) 171 | * http://php.net/manual/en/language.oop5.late-static-bindings.php 172 | */ 173 | public static function inSTIParent() 174 | { 175 | return static::class === static::getSTIParentClassName(); 176 | } 177 | 178 | /** 179 | * Return the className of the parent STI model. 180 | */ 181 | public static function getSTIParentClassName() 182 | { 183 | return self::class; 184 | } 185 | 186 | /** 187 | * Return a new instance of the STI parent model. 188 | */ 189 | public function newSTIParent() 190 | { 191 | $class = $this->getSTIParentClassName(); 192 | return new $class; 193 | } 194 | 195 | /** 196 | * Given an array of attributes that may or may not contain a type what 197 | * type of object should we create? 198 | */ 199 | protected function findClassNameThanksToAttributes(array $attributes) 200 | { 201 | return TypeMap::getClassName($attributes[$this->typeKey()] ?? static::class); 202 | } 203 | 204 | /** 205 | * Return the column in which we should look for the model's type. 206 | */ 207 | protected static function typeKey() 208 | { 209 | return static::$stiTypeKey ?? 'type'; 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /tests/Unit/STIParentTest.php: -------------------------------------------------------------------------------- 1 | 'name', 'type' => PremiumMember::class]); 18 | 19 | $this->assertInstanceOf(PremiumMember::class, $member); 20 | $this->assertEquals(PremiumMember::class, $member->type); 21 | } 22 | 23 | /** 24 | * Test that save saves objects of the correct subtype. 25 | * 26 | * Note that it is not possible to "downcast" on save. 27 | */ 28 | public function testSave() 29 | { 30 | $member = tap(new Member(['name' => 'name', 'type' => 'regular_member']))->save(); 31 | 32 | $this->assertInstanceOf(Member::class, $member); 33 | $this->assertEquals('regular_member', $member->type); 34 | } 35 | 36 | /** 37 | * Test that first returns objects of the correct type. 38 | */ 39 | public function testFirst() 40 | { 41 | $this->factory(Member::class)->state(RegularMember::class)->create(); 42 | 43 | $member = Member::first(); 44 | 45 | $this->assertInstanceOf(RegularMember::class, $member); 46 | } 47 | 48 | /** 49 | * Test that find returns objects of the correct type. 50 | */ 51 | public function testFind() 52 | { 53 | $createdMemeber = $this->factory(Member::class) 54 | ->state(RegularMember::class) 55 | ->create(); 56 | 57 | $member = Member::find($createdMemeber->id); 58 | 59 | $this->assertInstanceOf(RegularMember::class, $member); 60 | } 61 | 62 | /** 63 | * Test that find or fail returns objects of the correct type. 64 | */ 65 | public function testFindOrFail() 66 | { 67 | $createdMemeber = $this->factory(Member::class) 68 | ->state(RegularMember::class) 69 | ->create(); 70 | 71 | $member = Member::findOrFail($createdMemeber->id); 72 | 73 | $this->assertInstanceOf(RegularMember::class, $member); 74 | } 75 | 76 | /** 77 | * Test that firstOrNew returns objects of the correct type when it 78 | * finds or makes one. 79 | */ 80 | public function testFirstOrNew() 81 | { 82 | // First 83 | $this->factory(Member::class) 84 | ->state(RegularMember::class) 85 | ->create(['name' => 'find-me']); 86 | 87 | $member = Member::firstOrNew(['name' => 'find-me'], [ 88 | 'bio' => 'new-bio' 89 | ]); 90 | 91 | $this->assertInstanceOf(RegularMember::class, $member); 92 | $this->assertNotEquals('new-bio', $member->bio); 93 | $this->assertTrue($member->exists); 94 | 95 | // New 96 | $member = Member::firstOrNew(['name' => 'i-do-not-exists'], [ 97 | 'type' => RegularMember::class, 98 | ]); 99 | 100 | $this->assertInstanceOf(RegularMember::class, $member); 101 | $this->assertEquals('i-do-not-exists', $member->name); 102 | $this->assertFalse($member->exists); 103 | } 104 | 105 | /** 106 | * Test that firstOrCreate returns objects of the correct type when it 107 | * finds or creates one. 108 | */ 109 | public function testFirstOrCreate() 110 | { 111 | // First 112 | $this->factory(Member::class) 113 | ->state(RegularMember::class) 114 | ->create(['name' => 'find-me']); 115 | 116 | $member = Member::firstOrCreate(['name' => 'find-me'], [ 117 | 'type' => RegularMember::class, 118 | 'bio' => 'new-bio' 119 | ]); 120 | 121 | $this->assertInstanceOf(RegularMember::class, $member); 122 | $this->assertNotEquals('new-bio', $member->bio); 123 | $this->assertTrue($member->exists); 124 | 125 | // Create 126 | $member = Member::firstOrCreate(['name' => 'i-do-not-exists'], [ 127 | 'type' => RegularMember::class, 128 | 'bio' => 'not-found', 129 | ]); 130 | 131 | $this->assertInstanceOf(RegularMember::class, $member); 132 | $this->assertEquals('not-found', $member->bio); 133 | $this->assertTrue($member->wasRecentlyCreated); 134 | } 135 | 136 | /** 137 | * Test that firstOrNew returns objects of the correct type when it 138 | * updates or creates one. 139 | */ 140 | public function testUpdateOrCreate() 141 | { 142 | // Update 143 | $this->factory(Member::class) 144 | ->state(RegularMember::class) 145 | ->create(['name' => 'find-me']); 146 | 147 | $member = Member::updateOrCreate(['name' => 'find-me'], [ 148 | 'bio' => 'updated' 149 | ]); 150 | 151 | $this->assertInstanceOf(RegularMember::class, $member); 152 | $this->assertEquals('updated', $member->bio); 153 | $this->assertFalse($member->wasRecentlyCreated); 154 | 155 | // Create 156 | $member = Member::updateOrCreate(['name' => 'i-do-not-exists'], [ 157 | 'type' => RegularMember::class, 158 | 'bio' => 'created', 159 | ]); 160 | 161 | $this->assertInstanceOf(RegularMember::class, $member); 162 | $this->assertEquals('created', $member->bio); 163 | $this->assertTrue($member->wasRecentlyCreated); 164 | } 165 | 166 | /** 167 | * Test that take returns objects of the correct type. 168 | */ 169 | public function testTake() 170 | { 171 | $this->factory(Member::class, 2)->state(PremiumMember::class)->create(); 172 | $this->factory(Member::class, 1)->state(RegularMember::class)->create(); 173 | 174 | $this->assertInstanceOf( 175 | PremiumMember::class, 176 | Member::whereSTIType(PremiumMember::class)->take(1)->get()->first() 177 | ); 178 | 179 | $this->assertInstanceOf( 180 | RegularMember::class, 181 | Member::whereSTIType(RegularMember::class)->take(1)->get()->first() 182 | ); 183 | 184 | $this->assertInstanceOf( 185 | RegularMember::class, 186 | Member::whereSTIType('regular_member')->take(1)->get()->first() 187 | ); 188 | } 189 | 190 | /** 191 | * Test that all returns objects of the correct type. 192 | */ 193 | public function testAll() 194 | { 195 | $this->factory(Member::class, 2)->state(PremiumMember::class)->create(); 196 | $this->factory(Member::class, 1)->state(RegularMember::class)->create(); 197 | 198 | $members = Member::all(); 199 | 200 | $this->assertCount(2, $members->filter(function ($member) { 201 | return $member instanceof PremiumMember; 202 | })); 203 | 204 | $this->assertCount(1, $members->filter(function ($member) { 205 | return $member instanceof RegularMember; 206 | })); 207 | } 208 | 209 | /** 210 | * Test that paginate returns objects of the correct type. 211 | */ 212 | public function testPaginate() 213 | { 214 | $this->factory(Member::class, 2)->state(PremiumMember::class)->create(); 215 | $this->factory(Member::class, 1)->state(RegularMember::class)->create(); 216 | 217 | $members = Member::paginate(); 218 | 219 | $this->assertCount(2, $members->filter(function ($member) { 220 | return $member instanceof PremiumMember; 221 | })); 222 | 223 | $this->assertCount(1, $members->filter(function ($member) { 224 | return $member instanceof RegularMember; 225 | })); 226 | } 227 | 228 | /** 229 | * Test that each iterates over objects of the correct type. 230 | */ 231 | public function testEach() 232 | { 233 | $this->factory(Member::class, 2)->state(PremiumMember::class)->create(); 234 | $this->factory(Member::class)->state(RegularMember::class)->create(); 235 | 236 | $results = ['regular_count' => 0, 'premium_count' => 0]; 237 | 238 | Member::each(function ($member) use (&$results) { 239 | $this->updateMemberCount($member, $results); 240 | }); 241 | 242 | $this->assertEquals(2, $results['premium_count']); 243 | $this->assertEquals(1, $results['regular_count']); 244 | } 245 | 246 | /** 247 | * Test that chunk iterates over objects of the correct type. 248 | */ 249 | public function testChunk() 250 | { 251 | $this->factory(Member::class, 2)->state(PremiumMember::class)->create(); 252 | $this->factory(Member::class)->state(RegularMember::class)->create(); 253 | 254 | $results = ['regular_count' => 0, 'premium_count' => 0]; 255 | 256 | Member::chunkById(10, function ($members) use (&$results) { 257 | foreach ($members as $member) { 258 | $this->updateMemberCount($member, $results); 259 | } 260 | }); 261 | 262 | $this->assertEquals(2, $results['premium_count']); 263 | $this->assertEquals(1, $results['regular_count']); 264 | } 265 | 266 | /** 267 | * Test that chunk iterates over objects of the correct type. 268 | */ 269 | public function testCursor() 270 | { 271 | $this->factory(Member::class, 2)->state(PremiumMember::class)->create(); 272 | $this->factory(Member::class)->state(RegularMember::class)->create(); 273 | 274 | $results = ['regular_count' => 0, 'premium_count' => 0]; 275 | 276 | foreach (Member::cursor() as $member) { 277 | $this->updateMemberCount($member, $results); 278 | } 279 | 280 | $this->assertEquals(2, $results['premium_count']); 281 | $this->assertEquals(1, $results['regular_count']); 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /tests/Unit/STISubtypeTest.php: -------------------------------------------------------------------------------- 1 | 'name']); 20 | 21 | $this->assertInstanceOf(PremiumMember::class, $member); 22 | $this->assertEquals(PremiumMember::class, $member->type); 23 | } 24 | 25 | /** 26 | * Test that save saves objects of the correct subtype. 27 | */ 28 | public function testSave() 29 | { 30 | $member = tap(new RegularMember(['name' => 'name']))->save(); 31 | 32 | $this->assertInstanceOf(RegularMember::class, $member); 33 | $this->assertEquals('regular_member', $member->type); 34 | } 35 | 36 | /** 37 | * Test that count is scoped to the subtype it is called on. 38 | */ 39 | public function testCount() 40 | { 41 | $this->factory(Member::class, 2)->state(PremiumMember::class)->create(); 42 | $this->factory(Member::class, 3)->state(RegularMember::class)->create(); 43 | 44 | $this->assertEquals(2, PremiumMember::count()); 45 | $this->assertEquals(3, RegularMember::count()); 46 | } 47 | 48 | /** 49 | * Test that first returns only objects of the subtype that it is called on. 50 | */ 51 | public function testFirst() 52 | { 53 | $this->factory(Member::class)->state(PremiumMember::class)->create(); 54 | $this->factory(Member::class)->state(RegularMember::class)->create(); 55 | 56 | $member = RegularMember::first(); 57 | 58 | $this->assertInstanceOf(RegularMember::class, $member); 59 | } 60 | 61 | /** 62 | * Test that find returns only objects of the subtype that it is called on. 63 | */ 64 | public function testFind() 65 | { 66 | $createdRegularMemeber = $this->factory(Member::class) 67 | ->state(RegularMember::class) 68 | ->create(); 69 | 70 | $createdPremiumMemeber = $this->factory(Member::class) 71 | ->state(PremiumMember::class) 72 | ->create(); 73 | 74 | $regularMember = RegularMember::find($createdRegularMemeber->id); 75 | $premiumMember = RegularMember::find($createdPremiumMemeber->id); 76 | 77 | $this->assertInstanceOf(RegularMember::class, $regularMember); 78 | $this->assertNull($premiumMember); 79 | } 80 | 81 | /** 82 | * Test that find returns only objects of the subtype that it is called on. 83 | */ 84 | public function testFindOrFail() 85 | { 86 | $createdRegularMemeber = $this->factory(Member::class) 87 | ->state(RegularMember::class) 88 | ->create(); 89 | 90 | $createdPremiumMemeber = $this->factory(Member::class) 91 | ->state(PremiumMember::class) 92 | ->create(); 93 | 94 | $premiumMember = PremiumMember::findOrFail($createdPremiumMemeber->id); 95 | $this->assertInstanceOf(PremiumMember::class, $premiumMember); 96 | 97 | $this->expectException(ModelNotFoundException::class); 98 | PremiumMember::findOrFail($createdRegularMemeber->id); 99 | } 100 | 101 | /** 102 | * Test that firstOrNew returns only objects of the subtype that it is 103 | * called on. As a side effect this also tests that it makes them when not 104 | * found. 105 | */ 106 | public function testFirstOrNew() 107 | { 108 | $this->factory(Member::class) 109 | ->state(PremiumMember::class) 110 | ->create(['name' => 'premium-find-me']); 111 | 112 | $this->factory(Member::class) 113 | ->state(RegularMember::class) 114 | ->create(['name' => 'not-premium-find-me']); 115 | 116 | // First 117 | $premiumMember = PremiumMember::firstOrNew( 118 | ['name' => 'premium-find-me'], ['bio' => 'not-found'] 119 | ); 120 | $this->assertInstanceOf(PremiumMember::class, $premiumMember); 121 | $this->assertNotEquals('not-found', $premiumMember->bio); 122 | $this->assertTrue($premiumMember->exists); 123 | 124 | // New 125 | $notRegularMember = PremiumMember::firstOrNew( 126 | ['name' => 'not-premium-find-me'], ['bio' => 'not-found'] 127 | ); 128 | $this->assertInstanceOf(PremiumMember::class, $notRegularMember); 129 | $this->assertEquals('not-found', $notRegularMember->bio); 130 | $this->assertFalse($notRegularMember->exists); 131 | } 132 | 133 | /** 134 | * Test that firstOrCreate returns only objects of the subtype that it is 135 | * called on. As a side effect this also tests that it creates them when not 136 | * found. 137 | */ 138 | public function testFirstOrCreate() 139 | { 140 | $this->factory(Member::class) 141 | ->state(RegularMember::class) 142 | ->create(['name' => 'regular-find-me']); 143 | 144 | $this->factory(Member::class) 145 | ->state(PremiumMember::class) 146 | ->create(['name' => 'not-regular-find-me']); 147 | 148 | // First 149 | $regularMember = RegularMember::firstOrCreate( 150 | ['name' => 'regular-find-me'], ['bio' => 'not-found'] 151 | ); 152 | $this->assertInstanceOf(RegularMember::class, $regularMember); 153 | $this->assertNotEquals('not-found', $regularMember->bio); 154 | $this->assertTrue($regularMember->exists); 155 | 156 | // Create 157 | $notRegularMember = RegularMember::firstOrCreate( 158 | ['name' => 'not-regular-find-me'], ['bio' => 'not-found'] 159 | ); 160 | $this->assertInstanceOf(RegularMember::class, $notRegularMember); 161 | $this->assertEquals('not-found', $notRegularMember->bio); 162 | $this->assertTrue($notRegularMember->exists); 163 | $this->assertTrue($notRegularMember->wasRecentlyCreated); 164 | } 165 | 166 | /** 167 | * Tests that when you call *OrCreate and use the id of an object of another 168 | * subtype in the search attributes that you will get an exception. 169 | * 170 | * This is due to the fact that a subtype cannot see other subtypes, it will 171 | * therefore attempt to create a new model if the search attributes contains 172 | * the id of another subtype. As ids should be unique the db will fail. 173 | * 174 | * IMO this is more of a limitation of Single table inheritance than a bug 175 | * that's why I will not attempt to handle this situation. 176 | */ 177 | public function testfirstOrCreateAndUpdateOrCreateFailsWithIds() 178 | { 179 | $createdPremiumMember = $this->factory(Member::class) 180 | ->state(PremiumMember::class) 181 | ->create(); 182 | 183 | $this->expectException(QueryException::class); 184 | RegularMember::firstOrCreate(['id' => $createdPremiumMember->id], [ 185 | 'name' => 'test', 186 | ]); 187 | 188 | $this->expectException(QueryException::class); 189 | RegularMember::updateOrCreate(['id' => $createdPremiumMember->id], [ 190 | 'name' => 'test', 191 | ]); 192 | } 193 | 194 | /** 195 | * Test that firstOrNew returns objects of the correct type when it 196 | * updates or creates one. 197 | */ 198 | public function testUpdateOrCreate() 199 | { 200 | $this->factory(Member::class) 201 | ->state(RegularMember::class) 202 | ->create(['name' => 'regular-find-me']); 203 | 204 | $this->factory(Member::class) 205 | ->state(PremiumMember::class) 206 | ->create(['name' => 'not-regular-find-me']); 207 | 208 | // First 209 | $regularMember = RegularMember::updateOrCreate( 210 | ['name' => 'regular-find-me'], ['bio' => 'updated'] 211 | ); 212 | $this->assertInstanceOf(RegularMember::class, $regularMember); 213 | $this->assertEquals('updated', $regularMember->bio); 214 | $this->assertFalse($regularMember->wasRecentlyCreated); 215 | 216 | // Create 217 | $notRegularMember = RegularMember::updateOrCreate( 218 | ['name' => 'not-regular-find-me'], ['bio' => 'created'] 219 | ); 220 | $this->assertInstanceOf(RegularMember::class, $notRegularMember); 221 | $this->assertEquals('created', $notRegularMember->bio); 222 | $this->assertTrue($notRegularMember->wasRecentlyCreated); 223 | } 224 | 225 | /** 226 | * Test that take returns only objects of the subtype it is called on. 227 | */ 228 | public function testTake() 229 | { 230 | $this->factory(Member::class, 2)->state(PremiumMember::class)->create(); 231 | $this->factory(Member::class, 1)->state(RegularMember::class)->create(); 232 | 233 | $members = RegularMember::take(3)->get(); 234 | 235 | $this->assertCount(0, $members->filter(function ($member) { 236 | return $member instanceof PremiumMember; 237 | })); 238 | 239 | $this->assertCount(1, $members->filter(function ($member) { 240 | return $member instanceof RegularMember; 241 | })); 242 | } 243 | 244 | /** 245 | * Test that all returns only objects of the subtype it is called on. 246 | */ 247 | public function testAll() 248 | { 249 | $this->factory(Member::class, 2)->state(PremiumMember::class)->create(); 250 | $this->factory(Member::class, 1)->state(RegularMember::class)->create(); 251 | 252 | $members = RegularMember::all(); 253 | 254 | $this->assertCount(0, $members->filter(function ($member) { 255 | return $member instanceof PremiumMember; 256 | })); 257 | 258 | $this->assertCount(1, $members->filter(function ($member) { 259 | return $member instanceof RegularMember; 260 | })); 261 | } 262 | 263 | /** 264 | * Test that paginate returns only objects of the subtype it is called on. 265 | */ 266 | public function testPaginate() 267 | { 268 | $this->factory(Member::class, 2)->state(PremiumMember::class)->create(); 269 | $this->factory(Member::class, 1)->state(RegularMember::class)->create(); 270 | 271 | $members = RegularMember::paginate(); 272 | 273 | $this->assertCount(0, $members->filter(function ($member) { 274 | return $member instanceof PremiumMember; 275 | })); 276 | 277 | $this->assertCount(1, $members->filter(function ($member) { 278 | return $member instanceof RegularMember; 279 | })); 280 | } 281 | 282 | /** 283 | * Test that each iterates only over objects of the subtype it is called on. 284 | */ 285 | public function testEach() 286 | { 287 | $this->factory(Member::class, 2)->state(PremiumMember::class)->create(); 288 | $this->factory(Member::class)->state(RegularMember::class)->create(); 289 | 290 | $results = ['regular_count' => 0, 'premium_count' => 0]; 291 | 292 | RegularMember::each(function ($member) use (&$results) { 293 | $this->updateMemberCount($member, $results); 294 | }); 295 | 296 | $this->assertEquals(0, $results['premium_count']); 297 | $this->assertEquals(1, $results['regular_count']); 298 | } 299 | 300 | /** 301 | * Test that chunk iterates only over objects of the subtype it is 302 | * called on. 303 | */ 304 | public function testChunk() 305 | { 306 | $this->factory(Member::class, 2)->state(PremiumMember::class)->create(); 307 | $this->factory(Member::class)->state(RegularMember::class)->create(); 308 | 309 | $results = ['regular_count' => 0, 'premium_count' => 0]; 310 | 311 | RegularMember::chunkById(10, function ($members) use (&$results) { 312 | foreach ($members as $member) { 313 | $this->updateMemberCount($member, $results); 314 | } 315 | }); 316 | 317 | $this->assertEquals(0, $results['premium_count']); 318 | $this->assertEquals(1, $results['regular_count']); 319 | } 320 | 321 | /** 322 | * Test that cursor iterates only over objects of the subtype it is 323 | * called on. 324 | */ 325 | public function testCursor() 326 | { 327 | $this->factory(Member::class, 2)->state(PremiumMember::class)->create(); 328 | $this->factory(Member::class)->state(RegularMember::class)->create(); 329 | 330 | $results = ['regular_count' => 0, 'premium_count' => 0]; 331 | 332 | foreach (RegularMember::cursor() as $member) { 333 | $this->updateMemberCount($member, $results); 334 | } 335 | 336 | $this->assertEquals(0, $results['premium_count']); 337 | $this->assertEquals(1, $results['regular_count']); 338 | } 339 | } 340 | --------------------------------------------------------------------------------