Skip to content

Commit

Permalink
feat: [BC] add Factories::define() to override module classes
Browse files Browse the repository at this point in the history
Except for Config, if FQCN is specified, preferApp is ignored and that class is loaded.
  • Loading branch information
kenjis committed Jul 24, 2023
1 parent 13a72ac commit 4716098
Show file tree
Hide file tree
Showing 2 changed files with 183 additions and 15 deletions.
95 changes: 85 additions & 10 deletions system/Config/Factories.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use CodeIgniter\Database\ConnectionInterface;
use CodeIgniter\Model;
use Config\Services;
use InvalidArgumentException;

/**
* Factories for creating instances.
Expand Down Expand Up @@ -51,9 +52,11 @@ class Factories
];

/**
* Mapping of class basenames (no namespace) to
* Mapping of classnames (with or without namespace) to
* their instances.
*
* [component => [name => FQCN]]
*
* @var array<string, array<string, string>>
* @phpstan-var array<string, array<string, class-string>>
*/
Expand All @@ -72,6 +75,37 @@ class Factories
*/
protected static $instances = [];

/**
* Define the class to load. You can *override* the concrete class.
*
* @param string $component Lowercase, plural component name
* @param string $name Classname. The first parameter of Factories magic method
* @param string $classname FQCN to load
* @phpstan-param class-string $classname FQCN to load
*/
public static function define(string $component, string $name, string $classname): void
{
if (isset(self::$basenames[$component][$name])) {
if (self::$basenames[$component][$name] === $classname) {
return;
}

throw new InvalidArgumentException(
'Already defined in Factories: ' . $component . ' ' . $name . ' -> ' . self::$basenames[$component][$name]
);
}

if (! class_exists($classname)) {
throw new InvalidArgumentException('No such class: ' . $classname);
}

// Force a configuration to exist for this component.
// Otherwise, getOptions() will reset the component.
self::getOptions($component);

self::$basenames[$component][$name] = $classname;
}

/**
* Loads instances based on the method component name. Either
* creates a new instance or returns an existing shared instance.
Expand All @@ -88,22 +122,52 @@ public static function __callStatic(string $component, array $arguments)
$options = array_merge(self::getOptions(strtolower($component)), $options);

if (! $options['getShared']) {
if (isset(self::$basenames[$component][$name])) {
$class = self::$basenames[$component][$name];

return new $class(...$arguments);
}

if ($class = self::locateClass($options, $name)) {
return new $class(...$arguments);
}

return null;
}

$basename = self::getBasename($name);

// Check for an existing instance
if (isset(self::$basenames[$options['component']][$basename])) {
$class = self::$basenames[$options['component']][$basename];
if (isset(self::$basenames[$options['component']][$name])) {
$class = self::$basenames[$options['component']][$name];

// Need to verify if the shared instance matches the request
if (self::verifyInstanceOf($options, $class)) {
if (isset(self::$instances[$options['component']][$class])) {
return self::$instances[$options['component']][$class];
}
self::$instances[$options['component']][$class] = new $class(...$arguments);

return self::$instances[$options['component']][$class];

}
}

// Check for an existing Config instance with basename.
if (self::isConfig($options['component'])) {
$basename = self::getBasename($name);

if (isset(self::$basenames[$options['component']][$basename])) {
$class = self::$basenames[$options['component']][$basename];

// Need to verify if the shared instance matches the request
if (self::verifyInstanceOf($options, $class)) {
if (isset(self::$instances[$options['component']][$class])) {
return self::$instances[$options['component']][$class];
}
self::$instances[$options['component']][$class] = new $class(...$arguments);

return self::$instances[$options['component']][$class];

}
}
}

Expand All @@ -112,8 +176,13 @@ public static function __callStatic(string $component, array $arguments)
return null;
}

self::$instances[$options['component']][$class] = new $class(...$arguments);
self::$basenames[$options['component']][$basename] = $class;
self::$instances[$options['component']][$class] = new $class(...$arguments);
self::$basenames[$options['component']][$name] = $class;

// If a short classname is specified, also register FQCN to share the instance.
if (! isset(self::$basenames[$options['component']][$class])) {
self::$basenames[$options['component']][$class] = $class;
}

return self::$instances[$options['component']][$class];
}
Expand Down Expand Up @@ -153,7 +222,9 @@ class_exists($name, false)

// If an App version was requested then see if it verifies
if (
$options['preferApp'] && class_exists($appname)
// preferApp is used only for no namespace class or Config class.
(strpos($name, '\\') === false || self::isConfig($options['component']))
&& $options['preferApp'] && class_exists($appname)
&& self::verifyInstanceOf($options, $name)
) {
return $appname;
Expand Down Expand Up @@ -326,8 +397,12 @@ public static function injectMock(string $component, string $name, object $insta
$class = get_class($instance);
$basename = self::getBasename($name);

self::$instances[$component][$class] = $instance;
self::$basenames[$component][$basename] = $class;
self::$instances[$component][$class] = $instance;
self::$basenames[$component][$name] = $class;

if (self::isConfig($component)) {
self::$basenames[$component][$basename] = $class;
}
}

/**
Expand Down
103 changes: 98 additions & 5 deletions tests/system/Config/FactoriesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@
namespace CodeIgniter\Config;

use CodeIgniter\Test\CIUnitTestCase;
use InvalidArgumentException;
use ReflectionClass;
use stdClass;
use Tests\Support\Config\TestRegistrar;
use Tests\Support\Models\EntityModel;
use Tests\Support\Models\UserModel;
use Tests\Support\View\SampleClass;
use Tests\Support\Widgets\OtherWidget;
use Tests\Support\Widgets\SomeWidget;

Expand Down Expand Up @@ -251,7 +255,33 @@ class_alias(SomeWidget::class, $class);
$this->assertInstanceOf(SomeWidget::class, $result);
}

public function testpreferAppOverridesClassname()
public function testPreferAppOverridesConfigClassname()
{
// Create a config class in App
$file = APPPATH . 'Config/TestRegistrar.php';
$source = <<<'EOL'
<?php
namespace Config;
class TestRegistrar
{}
EOL;
file_put_contents($file, $source);

$result = Factories::config(TestRegistrar::class);

$this->assertInstanceOf('Config\TestRegistrar', $result);

Factories::setOptions('config', ['preferApp' => false]);

$result = Factories::config(TestRegistrar::class);

$this->assertInstanceOf(TestRegistrar::class, $result);

// Delete the config class in App
unlink($file);
}

public function testPreferAppIsIgnored()
{
// Create a fake class in App
$class = 'App\Widgets\OtherWidget';
Expand All @@ -260,11 +290,74 @@ class_alias(SomeWidget::class, $class);
}

$result = Factories::widgets(OtherWidget::class);
$this->assertInstanceOf(SomeWidget::class, $result);
$this->assertInstanceOf(OtherWidget::class, $result);
}

Factories::setOptions('widgets', ['preferApp' => false]);
public function testCanLoadTwoCellsWithSameShortName()
{
$cell1 = Factories::cells('\\' . SampleClass::class);
$cell2 = Factories::cells('\\' . \Tests\Support\View\OtherCells\SampleClass::class);

$result = Factories::widgets(OtherWidget::class);
$this->assertInstanceOf(OtherWidget::class, $result);
$this->assertNotSame($cell1, $cell2);
}

public function testDefineTwice()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage(
'Already defined in Factories: models CodeIgniter\Shield\Models\UserModel -> Tests\Support\Models\UserModel'
);

Factories::define(
'models',
'CodeIgniter\Shield\Models\UserModel',
UserModel::class
);
Factories::define(
'models',
'CodeIgniter\Shield\Models\UserModel',
EntityModel::class
);
}

public function testDefineNonExistentClass()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('No such class: App\Models\UserModel');

Factories::define(
'models',
'CodeIgniter\Shield\Models\UserModel',
'App\Models\UserModel'
);
}

public function testDefineAfterLoading()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage(
'Already defined in Factories: models Tests\Support\Models\UserModel -> Tests\Support\Models\UserModel'
);

model(UserModel::class);

Factories::define(
'models',
UserModel::class,
'App\Models\UserModel'
);
}

public function testDefineAndLoad()
{
Factories::define(
'models',
UserModel::class,
EntityModel::class
);

$model = model(UserModel::class);

$this->assertInstanceOf(EntityModel::class, $model);
}
}

0 comments on commit 4716098

Please sign in to comment.