Skip to content

Commit 4716098

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 4716098

File tree

2 files changed

+183
-15
lines changed

2 files changed

+183
-15
lines changed

system/Config/Factories.php

+85-10
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

+98-5
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,13 @@
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;
21+
use Tests\Support\View\SampleClass;
1822
use Tests\Support\Widgets\OtherWidget;
1923
use Tests\Support\Widgets\SomeWidget;
2024

@@ -251,7 +255,33 @@ class_alias(SomeWidget::class, $class);
251255
$this->assertInstanceOf(SomeWidget::class, $result);
252256
}
253257

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

262292
$result = Factories::widgets(OtherWidget::class);
263-
$this->assertInstanceOf(SomeWidget::class, $result);
293+
$this->assertInstanceOf(OtherWidget::class, $result);
294+
}
264295

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

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

0 commit comments

Comments
 (0)