Skip to content

Commit 1294ee7

Browse files
committed
feat: [BC] add Factories::define() to override module classes
Except for Config, if FQCN is specified, preferApp is ignored and that class is loaded.
1 parent 13a72ac commit 1294ee7

File tree

2 files changed

+182
-15
lines changed

2 files changed

+182
-15
lines changed

system/Config/Factories.php

Lines changed: 85 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use CodeIgniter\Database\ConnectionInterface;
1515
use CodeIgniter\Model;
1616
use Config\Services;
17+
use InvalidArgumentException;
1718

1819
/**
1920
* Factories for creating instances.
@@ -51,9 +52,11 @@ class Factories
5152
];
5253

5354
/**
54-
* Mapping of class basenames (no namespace) to
55+
* Mapping of classnames (with or without namespace) to
5556
* their instances.
5657
*
58+
* [component => [name => FQCN]]
59+
*
5760
* @var array<string, array<string, string>>
5861
* @phpstan-var array<string, array<string, class-string>>
5962
*/
@@ -72,6 +75,37 @@ class Factories
7275
*/
7376
protected static $instances = [];
7477

78+
/**
79+
* Define the class to load. You can *override* the concrete class.
80+
*
81+
* @param string $component Lowercase, plural component name
82+
* @param string $name Classname. The first parameter of Factories magic method
83+
* @param string $classname FQCN to load
84+
* @phpstan-param class-string $classname FQCN to load
85+
*/
86+
public static function define(string $component, string $name, string $classname): void
87+
{
88+
if (isset(self::$basenames[$component][$name])) {
89+
if (self::$basenames[$component][$name] === $classname) {
90+
return;
91+
}
92+
93+
throw new InvalidArgumentException(
94+
'Already defined in Factories: ' . $component . ' ' . $name . ' -> ' . self::$basenames[$component][$name]
95+
);
96+
}
97+
98+
if (! class_exists($classname)) {
99+
throw new InvalidArgumentException('No such class: ' . $classname);
100+
}
101+
102+
// Force a configuration to exist for this component.
103+
// Otherwise, getOptions() will reset the component.
104+
self::getOptions($component);
105+
106+
self::$basenames[$component][$name] = $classname;
107+
}
108+
75109
/**
76110
* Loads instances based on the method component name. Either
77111
* creates a new instance or returns an existing shared instance.
@@ -88,22 +122,52 @@ public static function __callStatic(string $component, array $arguments)
88122
$options = array_merge(self::getOptions(strtolower($component)), $options);
89123

90124
if (! $options['getShared']) {
125+
if (isset(self::$basenames[$component][$name])) {
126+
$class = self::$basenames[$component][$name];
127+
128+
return new $class(...$arguments);
129+
}
130+
91131
if ($class = self::locateClass($options, $name)) {
92132
return new $class(...$arguments);
93133
}
94134

95135
return null;
96136
}
97137

98-
$basename = self::getBasename($name);
99-
100138
// Check for an existing instance
101-
if (isset(self::$basenames[$options['component']][$basename])) {
102-
$class = self::$basenames[$options['component']][$basename];
139+
if (isset(self::$basenames[$options['component']][$name])) {
140+
$class = self::$basenames[$options['component']][$name];
103141

104142
// Need to verify if the shared instance matches the request
105143
if (self::verifyInstanceOf($options, $class)) {
144+
if (isset(self::$instances[$options['component']][$class])) {
145+
return self::$instances[$options['component']][$class];
146+
}
147+
self::$instances[$options['component']][$class] = new $class(...$arguments);
148+
106149
return self::$instances[$options['component']][$class];
150+
151+
}
152+
}
153+
154+
// Check for an existing Config instance with basename.
155+
if (self::isConfig($options['component'])) {
156+
$basename = self::getBasename($name);
157+
158+
if (isset(self::$basenames[$options['component']][$basename])) {
159+
$class = self::$basenames[$options['component']][$basename];
160+
161+
// Need to verify if the shared instance matches the request
162+
if (self::verifyInstanceOf($options, $class)) {
163+
if (isset(self::$instances[$options['component']][$class])) {
164+
return self::$instances[$options['component']][$class];
165+
}
166+
self::$instances[$options['component']][$class] = new $class(...$arguments);
167+
168+
return self::$instances[$options['component']][$class];
169+
170+
}
107171
}
108172
}
109173

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

115-
self::$instances[$options['component']][$class] = new $class(...$arguments);
116-
self::$basenames[$options['component']][$basename] = $class;
179+
self::$instances[$options['component']][$class] = new $class(...$arguments);
180+
self::$basenames[$options['component']][$name] = $class;
181+
182+
// If a short classname is specified, also register FQCN to share the instance.
183+
if (! isset(self::$basenames[$options['component']][$class])) {
184+
self::$basenames[$options['component']][$class] = $class;
185+
}
117186

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

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

329-
self::$instances[$component][$class] = $instance;
330-
self::$basenames[$component][$basename] = $class;
400+
self::$instances[$component][$class] = $instance;
401+
self::$basenames[$component][$name] = $class;
402+
403+
if (self::isConfig($component)) {
404+
self::$basenames[$component][$basename] = $class;
405+
}
331406
}
332407

333408
/**

tests/system/Config/FactoriesTest.php

Lines changed: 97 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@
1212
namespace CodeIgniter\Config;
1313

1414
use CodeIgniter\Test\CIUnitTestCase;
15+
use InvalidArgumentException;
1516
use ReflectionClass;
1617
use stdClass;
18+
use Tests\Support\Config\TestRegistrar;
19+
use Tests\Support\Models\EntityModel;
1720
use Tests\Support\Models\UserModel;
1821
use Tests\Support\Widgets\OtherWidget;
1922
use Tests\Support\Widgets\SomeWidget;
@@ -251,7 +254,33 @@ class_alias(SomeWidget::class, $class);
251254
$this->assertInstanceOf(SomeWidget::class, $result);
252255
}
253256

254-
public function testpreferAppOverridesClassname()
257+
public function testPreferAppOverridesConfigClassname()
258+
{
259+
// Create a config class in App
260+
$file = APPPATH . 'Config/TestRegistrar.php';
261+
$source = <<<'EOL'
262+
<?php
263+
namespace Config;
264+
class TestRegistrar
265+
{}
266+
EOL;
267+
file_put_contents($file, $source);
268+
269+
$result = Factories::config(TestRegistrar::class);
270+
271+
$this->assertInstanceOf('Config\TestRegistrar', $result);
272+
273+
Factories::setOptions('config', ['preferApp' => false]);
274+
275+
$result = Factories::config(TestRegistrar::class);
276+
277+
$this->assertInstanceOf(TestRegistrar::class, $result);
278+
279+
// Delete the config class in App
280+
unlink($file);
281+
}
282+
283+
public function testPreferAppIsIgnored()
255284
{
256285
// Create a fake class in App
257286
$class = 'App\Widgets\OtherWidget';
@@ -260,11 +289,74 @@ class_alias(SomeWidget::class, $class);
260289
}
261290

262291
$result = Factories::widgets(OtherWidget::class);
263-
$this->assertInstanceOf(SomeWidget::class, $result);
292+
$this->assertInstanceOf(OtherWidget::class, $result);
293+
}
264294

265-
Factories::setOptions('widgets', ['preferApp' => false]);
295+
public function testCanLoadTwoCellsWithSameShortName()
296+
{
297+
$cell1 = Factories::cells('\Tests\Support\View\SampleClass');
298+
$cell2 = Factories::cells('\Tests\Support\View\OtherCells\SampleClass');
266299

267-
$result = Factories::widgets(OtherWidget::class);
268-
$this->assertInstanceOf(OtherWidget::class, $result);
300+
$this->assertNotSame($cell1, $cell2);
301+
}
302+
303+
public function testDefineTwice()
304+
{
305+
$this->expectException(InvalidArgumentException::class);
306+
$this->expectExceptionMessage(
307+
'Already defined in Factories: models CodeIgniter\Shield\Models\UserModel -> Tests\Support\Models\UserModel'
308+
);
309+
310+
Factories::define(
311+
'models',
312+
'CodeIgniter\Shield\Models\UserModel',
313+
UserModel::class
314+
);
315+
Factories::define(
316+
'models',
317+
'CodeIgniter\Shield\Models\UserModel',
318+
EntityModel::class
319+
);
320+
}
321+
322+
public function testDefineNonExistentClass()
323+
{
324+
$this->expectException(InvalidArgumentException::class);
325+
$this->expectExceptionMessage('No such class: App\Models\UserModel');
326+
327+
Factories::define(
328+
'models',
329+
'CodeIgniter\Shield\Models\UserModel',
330+
'App\Models\UserModel'
331+
);
332+
}
333+
334+
public function testDefineAfterLoading()
335+
{
336+
$this->expectException(InvalidArgumentException::class);
337+
$this->expectExceptionMessage(
338+
'Already defined in Factories: models Tests\Support\Models\UserModel -> Tests\Support\Models\UserModel'
339+
);
340+
341+
model(UserModel::class);
342+
343+
Factories::define(
344+
'models',
345+
UserModel::class,
346+
'App\Models\UserModel'
347+
);
348+
}
349+
350+
public function testDefineAndLoad()
351+
{
352+
Factories::define(
353+
'models',
354+
UserModel::class,
355+
EntityModel::class
356+
);
357+
358+
$model = model(UserModel::class);
359+
360+
$this->assertInstanceOf(EntityModel::class, $model);
269361
}
270362
}

0 commit comments

Comments
 (0)