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');
+ }
+}