diff --git a/composer.json b/composer.json index 205da3e4..4754cda0 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,8 @@ "psr/log": "^3.0" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^10.0", + "orchestra/testbench": "^8.0" }, "extra": { "laravel": { diff --git a/phpunit.xml b/phpunit.xml index cd8dcefb..89ec3f3c 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -5,6 +5,9 @@ ./tests/ReportParser + + ./tests/InjectedRelations + ./tests/CleanCCPHtml diff --git a/src/Exceptions/InjectedRelationConflictException.php b/src/Exceptions/InjectedRelationConflictException.php new file mode 100644 index 00000000..3d6dedf2 --- /dev/null +++ b/src/Exceptions/InjectedRelationConflictException.php @@ -0,0 +1,30 @@ +make(InjectedRelationRegistry::class); + $extension_class = $extension_registry->getExtensionClassFor($this::class, $key); + + // check if we have an injected relation + if($extension_class){ + // we have an injected relation + // since what we are doing is not intended, we have to roughly reimplement laravel's code from here on + + //check if relation data is cached + // the relation cache continues to work even for 'fake' relations, but we have to manually call it + if($this->relationLoaded($key)){ + return $this->getRelationValue($key); + } + + // it is NOT cached, we have to load it and put it into the cache + // get relation from extension + $extension_class_instance = new $extension_class; + $relation = $extension_class_instance->$key($this); + + // the following code is taken from laravel's \Illuminate\Database\Eloquent\Concerns\HasAttributes::getRelationshipFromMethod + // check if we actually got a relation returned + if (! $relation instanceof Relation) { + if (is_null($relation)) { + throw new LogicException(sprintf( + '%s::%s must return a relationship instance, but "null" was returned. Was the "return" keyword used?', static::class, $key + )); + } + + throw new LogicException(sprintf( + '%s::%s must return a relationship instance.', static::class, $key + )); + } + + return tap($relation->getResults(), function ($results) use ($key) { + $this->setRelation($key, $results); + }); + } + + // use the default behaviour if no relation is injected + return parent::__get($key); + } + + /** + * Redirects calls to injected relations or behaves like a normal class. + * + * @param string $method + * @param $parameters + * @return mixed + * + * @throws BindingResolutionException + */ + public function __call($method, $parameters) + { + // fetch injected relations + $extension_registry = app()->make(InjectedRelationRegistry::class); + $extension_class = $extension_registry->getExtensionClassFor($this::class, $method); + + // check if we have an injected relation + if($extension_class) { + // return the injected relation + $extension_class_instance = new $extension_class; + + return $extension_class_instance->$method($this); + } + + // use the default behaviour if no relation is injected + return parent::__call($method, $parameters); + } + + /** + * Injects relations into this model. + * + * @param string $extension_class the class that provides the injected relations + * @return void + * + * @throws BindingResolutionException + * @throws InjectedRelationConflictException A conflict arises when trying to inject two relations with the same name into a target. + */ + public static function injectRelationsFrom(string $extension_class): void { + $registry = app()->make(InjectedRelationRegistry::class); + $registry->injectRelations(static::class, $extension_class); + } +} diff --git a/src/Models/GlobalSetting.php b/src/Models/GlobalSetting.php index 98a22efb..4f97e59b 100644 --- a/src/Models/GlobalSetting.php +++ b/src/Models/GlobalSetting.php @@ -3,7 +3,7 @@ /* * This file is part of SeAT * - * Copyright (C) 2015 to 2022 Leon Jacobs + * Copyright (C) 2015 to present Leon Jacobs * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -22,14 +22,12 @@ namespace Seat\Services\Models; -use Illuminate\Database\Eloquent\Model; - /** * Class GlobalSetting. * * @package Seat\Services\Models */ -class GlobalSetting extends Model +class GlobalSetting extends ExtensibleModel { /** * @var array diff --git a/src/Models/Note.php b/src/Models/Note.php index 5493ba6d..8086aac8 100644 --- a/src/Models/Note.php +++ b/src/Models/Note.php @@ -3,7 +3,7 @@ /* * This file is part of SeAT * - * Copyright (C) 2015 to 2022 Leon Jacobs + * Copyright (C) 2015 to present Leon Jacobs * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -23,14 +23,13 @@ namespace Seat\Services\Models; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Model; /** * Class Note. * * @package Seat\Services\Models */ -class Note extends Model +class Note extends ExtensibleModel { /** * @var array diff --git a/src/Models/Schedule.php b/src/Models/Schedule.php index 9efcd5eb..c76d3ca7 100644 --- a/src/Models/Schedule.php +++ b/src/Models/Schedule.php @@ -3,7 +3,7 @@ /* * This file is part of SeAT * - * Copyright (C) 2015 to 2022 Leon Jacobs + * Copyright (C) 2015 to present Leon Jacobs * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -22,14 +22,12 @@ namespace Seat\Services\Models; -use Illuminate\Database\Eloquent\Model; - /** * Class Schedule. * * @package Seat\Services\Models */ -class Schedule extends Model +class Schedule extends ExtensibleModel { /** * @var array diff --git a/src/Models/UserSetting.php b/src/Models/UserSetting.php index cdc4ddbf..d4c0c579 100644 --- a/src/Models/UserSetting.php +++ b/src/Models/UserSetting.php @@ -3,7 +3,7 @@ /* * This file is part of SeAT * - * Copyright (C) 2015 to 2022 Leon Jacobs + * Copyright (C) 2015 to present Leon Jacobs * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -22,14 +22,12 @@ namespace Seat\Services\Models; -use Illuminate\Database\Eloquent\Model; - /** * Class UserSetting. * * @package Seat\Services\Models */ -class UserSetting extends Model +class UserSetting extends ExtensibleModel { /** * @var array diff --git a/src/Services/InjectedRelationRegistry.php b/src/Services/InjectedRelationRegistry.php new file mode 100644 index 00000000..2a5c6d9a --- /dev/null +++ b/src/Services/InjectedRelationRegistry.php @@ -0,0 +1,100 @@ + + */ + private array $relations = []; + + /** + * Injects all relations from a class into a model. + * + * @param string $target_model the model to inject relations into + * @param string $extension_class the class to take the relations from + * @return void + * + * @throws InjectedRelationConflictException A conflict arises when trying to inject two relations with the same name into a target. + */ + public function injectRelations(string $target_model, string $extension_class): void + { + $methods = get_class_methods($extension_class); + + foreach ($methods as $relation_name){ + $this->injectSingleRelation($target_model, $extension_class, $relation_name); + } + } + + /** + * Injects a single relation into a model. + * + * @param string $model the model to inject the relation into + * @param string $extension_class the class holding the relation function + * @param string $relation the name of the relation to be injected. The method providing the relation in $extension_class must have the same name, and the relation will be accessible under this name. + * @return void + * + * @throws InjectedRelationConflictException A conflict arises when trying to inject two relations with the same name into a target. + */ + public function injectSingleRelation(string $model, string $extension_class, string $relation): void + { + $key = $this->getInjectionTargetKey($model, $relation); + + // check for conflicts, as there can't be two relations with the same name + if(array_key_exists($key, $this->relations)) { + $conflict = $this->relations[$key]; + throw new InjectedRelationConflictException(sprintf('Relation \'%s\' from \'%s\' is name-conflicting with \'%s\'', $relation, $model, $conflict)); + } + + $this->relations[$key] = $extension_class; + } + + /** + * Searches for injected relations for a model. + * + * @param string $model the model to search for + * @param string $relation the relation name to search for + * @return string|null the class providing the injected relation, or null if there is no injected relation + */ + public function getExtensionClassFor(string $model, string $relation): ?string + { + return $this->relations[$this->getInjectionTargetKey($model, $relation)] ?? null; + } + + /** + * Generates a key for $this->$relations. + * + * @param string $model the injection target class + * @param string $relation_name the relation name + * @return string a key to use with $this->$relations + */ + private function getInjectionTargetKey(string $model, string $relation_name): string { + return sprintf('%s.%s', $model, $relation_name); + } +} diff --git a/src/ServicesServiceProvider.php b/src/ServicesServiceProvider.php index 4e9746f8..5b63afb4 100644 --- a/src/ServicesServiceProvider.php +++ b/src/ServicesServiceProvider.php @@ -3,7 +3,7 @@ /* * This file is part of SeAT * - * Copyright (C) 2015 to 2022 Leon Jacobs + * Copyright (C) 2015 to present Leon Jacobs * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -25,6 +25,7 @@ use Illuminate\Support\Facades\DB; use Seat\Services\Commands\Seat\Admin\Email; use Seat\Services\Commands\Seat\Version; +use Seat\Services\Services\InjectedRelationRegistry; class ServicesServiceProvider extends AbstractSeatPlugin { @@ -87,6 +88,10 @@ public function register() $this->mergeConfigFrom( __DIR__ . '/Config/services.config.php', 'services.config'); + + $this->app->singleton(InjectedRelationRegistry::class, function () { + return new InjectedRelationRegistry(); + }); } private function addCommands() diff --git a/tests/InjectedRelations/Extensions/ModelAExtension.php b/tests/InjectedRelations/Extensions/ModelAExtension.php new file mode 100644 index 00000000..49cb87de --- /dev/null +++ b/tests/InjectedRelations/Extensions/ModelAExtension.php @@ -0,0 +1,13 @@ +hasOne(ModelB::class); + } +} \ No newline at end of file diff --git a/tests/InjectedRelations/InjectedRelationsTest.php b/tests/InjectedRelations/InjectedRelationsTest.php new file mode 100644 index 00000000..e6ebf182 --- /dev/null +++ b/tests/InjectedRelations/InjectedRelationsTest.php @@ -0,0 +1,131 @@ +set('database.default', 'testbench'); + $app['config']->set('database.connections.testbench', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + $app['config']->set('database.redis.client', 'mock'); + } + + /** + * @param \Illuminate\Foundation\Application $app + * @return array|string[] + */ + protected function getPackageProviders($app) + { + return [ + ServicesServiceProvider::class + ]; + } + + /** + * Define database migrations. + * + * @return void + */ + protected function defineDatabaseMigrations() + { + $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); + } + + /** + * Test if InjectedRelationRegistry works + * @throws \Seat\Services\Exceptions\InjectedRelationConflictException + * @throws \Illuminate\Contracts\Container\BindingResolutionException + */ + public function testRegistry(){ + ModelA::injectRelationsFrom(ModelAExtension::class); + $registry = app()->make(InjectedRelationRegistry::class); + + $this->assertEquals(ModelAExtension::class,$registry->getExtensionClassFor(ModelA::class,'modelBInjected')); + $this->assertEquals(null,$registry->getExtensionClassFor(ModelA::class,'doesntexist')); + } + + /** + * Test if registering the same relation twice errors + * @throws \Illuminate\Contracts\Container\BindingResolutionException + * @throws \Seat\Services\Exceptions\InjectedRelationConflictException + */ + public function testInjectionConflict(){ + $this->expectException(InjectedRelationConflictException::class); + ModelA::injectRelationsFrom(ModelAExtension::class); + ModelA::injectRelationsFrom(ModelAExtension::class); + } + + /** + * Test if injected relations are working + * @return void + * @throws InjectedRelationConflictException + * @throws \Illuminate\Contracts\Container\BindingResolutionException + */ + public function testInjectedRelations(){ + $a = ModelA::factory()->create(); + ModelB::factory() + ->for($a) + ->create(); + + ModelA::injectRelationsFrom(ModelAExtension::class); + + // factory might already trigger the cache, therefore make sure we have new models + $a = ModelA::first(); + $b = ModelB::first(); + + // ensure normal relations are still working + // b->a + $this->assertNotEquals(null, $b->modelA->id); + $this->assertEquals($a->id, $b->modelA->id); + // a->b + $this->assertNotEquals(null, $a->modelB->id); + $this->assertEquals($b->id, $a->modelB->id); + + //test injected relationship + // as attributes + $this->assertNotEquals(null, $a->modelBInjected->id); + $this->assertEquals($b->id, $a->modelBInjected->id); + // as function calls + $this->assertNotEquals(null, $a->modelBInjected()->first()->id); + $this->assertEquals($b->id, $a->modelBInjected()->first()->id); + } + + /** + * Test if eager loading with 'with' works + * @throws InjectedRelationConflictException + * @throws BindingResolutionException + */ + public function testEagerLoading(){ + $a = ModelA::factory()->create(); + $b = ModelB::factory() + ->for($a) + ->create(); + + ModelA::injectRelationsFrom(ModelAExtension::class); + + $result = ModelA::with("modelBInjected")->first(); + $loaded_relations = $result->getRelations(); + + $this->assertArrayHasKey('modelBInjected', $loaded_relations); + + $model_b = $loaded_relations['modelBInjected']; + $this->assertInstanceOf(ModelB::class, $model_b); + $this->assertEquals($model_b->id, $b->id); + } +} \ No newline at end of file diff --git a/tests/InjectedRelations/Models/ModelA.php b/tests/InjectedRelations/Models/ModelA.php new file mode 100644 index 00000000..ed2340d5 --- /dev/null +++ b/tests/InjectedRelations/Models/ModelA.php @@ -0,0 +1,31 @@ +hasOne(ModelB::class); + } +} \ No newline at end of file diff --git a/tests/InjectedRelations/Models/ModelB.php b/tests/InjectedRelations/Models/ModelB.php new file mode 100644 index 00000000..3bd19315 --- /dev/null +++ b/tests/InjectedRelations/Models/ModelB.php @@ -0,0 +1,31 @@ +belongsTo(ModelA::class); + } +} \ No newline at end of file diff --git a/tests/database/factories/ModelAFactory.php b/tests/database/factories/ModelAFactory.php new file mode 100644 index 00000000..7c70fddc --- /dev/null +++ b/tests/database/factories/ModelAFactory.php @@ -0,0 +1,44 @@ +ModelA::factory() + ]; + } +} diff --git a/tests/database/migrations/2023_07_27_053529_create_relation_injection_testing_models.php b/tests/database/migrations/2023_07_27_053529_create_relation_injection_testing_models.php new file mode 100644 index 00000000..fff9bb07 --- /dev/null +++ b/tests/database/migrations/2023_07_27_053529_create_relation_injection_testing_models.php @@ -0,0 +1,57 @@ +increments('id'); + }); + + Schema::create('model_b', function (Blueprint $table) { + $table->increments('id'); + $table->integer('model_a_id')->unsigned(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + + Schema::drop('model_a'); + Schema::drop('model_m'); + } +}