Test Failed
Pull Request — master (#37)
by Divine Niiquaye
11:41
created

DefinitionBuilder   F

Complexity

Total Complexity 83

Size/Duplication

Total Lines 471
Duplicated Lines 0 %

Importance

Changes 21
Bugs 0 Features 0
Metric Value
eloc 172
c 21
b 0
f 0
dl 0
loc 471
rs 2
wmc 83

22 Methods

Rating   Name   Duplication   Size   Complexity  
A __call() 0 18 5
A reset() 0 7 1
A load() 0 5 1
A defaults() 0 10 2
A parameter() 0 5 1
A set() 0 5 1
A __destruct() 0 18 5
B findResourcePath() 0 33 8
A alias() 0 5 1
A directory() 0 5 1
A createInitializingError() 0 3 1
A decorate() 0 5 1
B namespaced() 0 28 7
A autowire() 0 25 6
A createErrorException() 0 12 2
A extend() 0 5 1
B findClass() 0 31 8
A __construct() 0 3 1
B doCreate() 0 27 10
A getContainer() 0 3 1
D findClasses() 0 57 17
A instanceOf() 0 11 2

How to fix   Complexity   

Complex Class

Complex classes like DefinitionBuilder often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use DefinitionBuilder, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of DivineNii opensource projects.
7
 *
8
 * PHP version 7.4 and above required
9
 *
10
 * @author    Divine Niiquaye Ibok <[email protected]>
11
 * @copyright 2021 DivineNii (https://divinenii.com/)
12
 * @license   https://opensource.org/licenses/BSD-3-Clause License
13
 *
14
 * For the full copyright and license information, please view the LICENSE
15
 * file that was distributed with this source code.
16
 */
17
18
namespace Rade\DI;
19
20
use Nette\Utils\{FileSystem, Validators};
21
use Rade\DI\Definitions\DefinitionInterface;
22
use Symfony\Component\Config\Resource\{ClassExistenceResource, FileExistenceResource, FileResource, ResourceInterface};
23
use Symfony\Contracts\Service\ResetInterface;
24
25
/**
26
 * A builder specialized in creating homogeneous service definitions.
27
 *
28
 * This class has some performance impact and recommended to be used with ContainerBuilder class.
29
 *
30
 * @experimental in 1.0
31
 *
32
 * @method self|Definition autowire(string $id, Definitions\TypedDefinitionInterface|object|null $definition = null)
33
 *
34
 * @author Divine Niiquaye Ibok <[email protected]>
35
 */
36
class DefinitionBuilder implements ResetInterface
37
{
38
    private AbstractContainer $container;
39
    private ?string $definition = null, $directory = null;
40
    private bool $trackDefaults = false;
41
42
    /** @var array<string,array<int,mixed>> */
43
    private array $classes = [];
44
45
    /** @var array<string,array<int,array<int,mixed>>> */
46
    private array $defaults = [];
47
48
    public function __construct(AbstractContainer $container)
49
    {
50
        $this->container = $container;
51
    }
52
53
    public function __destruct()
54
    {
55
        if (!empty($this->classes)) {
56
            foreach ($this->classes as [$definition, $classes]) {
57
                // prepare for deep cloning
58
                $serializedDef = \serialize($definition);
59
60
                foreach ($classes as $resource) {
61
                    $serializedDef = \str_replace('s:33:"Rade\DI\Tests\Fixtures\Prototype\";', \serialize($resource), $serializedDef);
62
                    $resolvedDef = $this->container->set($resource, \unserialize($serializedDef));
63
64
                    if (\str_contains($serializedDef, 'autowired";b:1;')) {
65
                        $resolvedDef->typed(Resolver::autowireService($resource));
66
                    }
67
                }
68
            }
69
70
            $this->classes = [];
71
        }
72
    }
73
74
    /**
75
     * Where all the magic happens.
76
     *
77
     * @param array<int,mixed> $arguments
78
     *
79
     * @return $this
80
     *
81
     * @throws \Throwable
82
     */
83
    public function __call(string $name, array $arguments)
84
    {
85
        if (!$id = $this->definition) {
86
            throw $this->createInitializingError($name);
87
        }
88
89
        if ($this->trackDefaults) {
90
            $this->defaults[$id][] = [$name, $arguments];
91
        } else {
92
            try {
93
                $definition = (!isset($this->classes[$id]) ? $this->container->definition($id) : $this->classes[$id][0]);
94
                \call_user_func_array([$definition, $name], $arguments);
95
            } catch (\Throwable $e) {
96
                throw $this->createErrorException($name, $e);
97
            }
98
        }
99
100
        return $this;
101
    }
102
103
    /**
104
     * This method calls the __destruct() method as a way of
105
     * loading namespaced service definitions into container if exist.
106
     *
107
     * @return $this
108
     */
109
    final public function load(): self
110
    {
111
        $this->__destruct();
112
113
        return $this;
114
    }
115
116
    /**
117
     * Resets the builder to initial state.
118
     *
119
     * @return $this
120
     */
121
    public function reset()
122
    {
123
        $this->definition = $this->directory =  null;
124
        $this->classes = $this->defaults = [];
125
        $this->trackDefaults = false;
126
127
        return $this;
128
    }
129
130
    /**
131
     * Set a config into container's parameter.
132
     *
133
     * @param mixed $value
134
     *
135
     * @return $this
136
     */
137
    public function parameter(string $name, $value)
138
    {
139
        $this->container->parameters[$name] = $value;
140
141
        return $this;
142
    }
143
144
    /**
145
     * Marks an alias id to service id.
146
     *
147
     * @return $this
148
     */
149
    public function alias(string $id, string $serviceId = null)
150
    {
151
        $this->container->alias($id, $serviceId ?? $this->definition);
0 ignored issues
show
Bug introduced by
It seems like $serviceId ?? $this->definition can also be of type null; however, parameter $serviceId of Rade\DI\AbstractContainer::alias() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

151
        $this->container->alias($id, /** @scrutinizer ignore-type */ $serviceId ?? $this->definition);
Loading history...
152
153
        return $this;
154
    }
155
156
    /**
157
     * Enables autowiring.
158
     *
159
     * @param string                                           $id
160
     * @param array<int,string>                                $types
161
     * @param Definitions\TypedDefinitionInterface|object|null $definition
162
     *
163
     * @return Definition|$this
164
     */
165
    public function autowire(/* string $id, $definition or array $types */)
166
    {
167
        $arguments = \func_get_args();
168
169
        if (\is_string($arguments[0] ?? null)) {
170
            $this->doCreate($this->container->autowire($this->definition = $arguments[0], $arguments[1] ?? null));
171
172
            return $this;
173
        }
174
175
        if (!$id = $this->definition) {
176
            throw $this->createInitializingError(__FUNCTION__);
177
        }
178
179
        if ($this->trackDefaults) {
180
            $this->defaults[$id][] = [__FUNCTION__, $arguments];
181
        } else {
182
            $definition = !isset($this->classes[$id]) ? $this->container->definition($id) : $this->classes[$id][0];
183
184
            if ($definition instanceof Definitions\TypedDefinitionInterface) {
185
                $definition->autowire($arguments[0] ?? []);
186
            }
187
        }
188
189
        return $this;
190
    }
191
192
    /**
193
     * Set a service definition.
194
     *
195
     * @param DefinitionInterface|object|null $definition
196
     *
197
     * @return Definition|$this
198
     */
199
    public function set(string $id, object $definition = null)
200
    {
201
        $this->doCreate($this->container->set($this->definition = $id, $definition));
202
203
        return $this;
204
    }
205
206
    /**
207
     * Extends a service definition.
208
     *
209
     * @return Definition|$this
210
     */
211
    public function extend(string $id)
212
    {
213
        $this->doCreate($this->container->definition($this->definition = $id));
214
215
        return $this;
216
    }
217
218
    /**
219
     * Replaces old service with a new one, but keeps a reference of the old one as: service_id.inner.
220
     *
221
     * @param DefinitionInterface|object|null $definition
222
     *
223
     * @see Rade\DI\Traits\DefinitionTrait::decorate
224
     *
225
     * @return Definition|$this
226
     */
227
    public function decorate(string $id, object $definition = null, ?string $newId = null)
228
    {
229
        $this->doCreate($this->container->decorate($this->definition = $id, $definition, $newId));
230
231
        return $this;
232
    }
233
234
    /**
235
     * Defines a set of defaults for following service definitions.
236
     *
237
     * @param bool $merge If true, new defaults will be merged into existing
238
     *
239
     * @return Definition|$this
240
     */
241
    public function defaults(bool $merge = true)
242
    {
243
        $this->trackDefaults = true;
244
        $this->definition = '#defaults';
245
246
        if (!$merge) {
247
            $this->defaults[$this->definition] = [];
248
        }
249
250
        return $this;
251
    }
252
253
    /**
254
     * Defines a set of defaults only for services whose class matches a defined one.
255
     *
256
     * @return Definition|$this
257
     */
258
    public function instanceOf(string $interfaceOrClass)
259
    {
260
        $this->trackDefaults = true;
261
262
        if (!Validators::isType($interfaceOrClass)) {
263
            throw new \RuntimeException(\sprintf('"%s" is set as an "instanceof" conditional, but it does not exist.', $interfaceOrClass));
264
        }
265
266
        $this->definition = $interfaceOrClass;
267
268
        return $this;
269
    }
270
271
    /**
272
     * Registers a set of classes as services using PSR-4 for discovery.
273
     *
274
     * @param string               $namespace The namespace prefix of classes in the scanned directory
275
     * @param string|null          $resource  The directory to look for classes, glob-patterns allowed
276
     * @param string|string[]|null $exclude   A globbed path of files to exclude or an array of globbed paths of files to exclude
277
     *
278
     * @return Definition|$this
279
     */
280
    public function namespaced(string $namespace, string $resource = null, $exclude = null)
281
    {
282
        if ('\\' !== @$namespace[-1]) {
283
            throw new \InvalidArgumentException(\sprintf('Namespace prefix must end with a "\\": "%s".', $namespace));
284
        }
285
286
        if (!\preg_match('/^(?:[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+\\\\)++$/', $namespace)) {
287
            throw new \InvalidArgumentException(\sprintf('Namespace is not a valid PSR-4 prefix: "%s".', $namespace));
288
        }
289
        $oldDir = $this->directory;
290
291
        if (null !== $resource) {
292
            if ($oldDir && !\is_dir($resource)) {
293
                $resource = $oldDir . \ltrim($resource, '/\\');
294
            }
295
296
            if (\is_dir($resource = $this->container->parameter($resource))) {
297
                $this->directory = $resource;
298
            }
299
        }
300
301
        $classes = $this->findClasses($namespace, $resource ?? $this->findResourcePath($namespace), (array) $exclude);
302
        $this->doCreate($definition = new Definition($this->definition = $namespace));
303
304
        $this->directory = $oldDir;
305
        $this->classes[$namespace] = [$definition, $classes];
306
307
        return $this;
308
    }
309
310
    /**
311
     * Set|Replace a directory for finding classes.
312
     *
313
     * @return $this
314
     */
315
    public function directory(string $path)
316
    {
317
        $this->directory = \rtrim($path, '\\/') . '/';
318
319
        return $this;
320
    }
321
322
    public function getContainer(): AbstractContainer
323
    {
324
        return $this->container;
325
    }
326
327
    private function doCreate(object $definition): void
328
    {
329
        $this->trackDefaults = false;
330
331
        foreach ($this->defaults as $offset => $defaultMethods) {
332
            if ('#defaults' !== $offset) {
333
                $class = $definition instanceof DefinitionInterface ? $definition->getEntity() : $definition;
334
335
                if (!(\is_string($class) || \is_object($class)) || !\is_subclass_of($class, $offset)) {
336
                    continue;
337
                }
338
            }
339
340
            foreach ($defaultMethods as [$defaultMethod, $defaultArguments]) {
341
                if (!\method_exists($definition, $defaultMethod)) {
342
                    continue;
343
                }
344
345
                try {
346
                    $definition->{$defaultMethod}(...$defaultArguments);
347
                } catch (\Throwable $e) {
348
                    throw $this->createErrorException($defaultMethod, $e);
349
                }
350
            }
351
        }
352
353
        $this->__destruct();
354
    }
355
356
    private function findResourcePath(string $namespace): string
357
    {
358
        foreach (\spl_autoload_functions() as $classLoader) {
359
            if (!\is_array($classLoader)) {
360
                continue;
361
            }
362
363
            if ($classLoader[0] instanceof \Composer\Autoload\ClassLoader) {
364
                $psr4Prefixes = $classLoader[0]->getPrefixesPsr4();
365
366
                foreach ($psr4Prefixes as $prefix => $paths) {
367
                    if (!\str_starts_with($namespace, $prefix)) {
368
                        continue;
369
                    }
370
371
                    foreach ($paths as $path) {
372
                        $namespacePostfix = '/' . \substr($namespace, \strlen($prefix));
373
                        $path = FileSystem::normalizePath($path . $namespacePostfix);
374
375
                        if (\file_exists($path)) {
376
                            $this->directory = \dirname($path) . '/';
377
378
                            return $path;
379
                        }
380
                    }
381
                }
382
383
                break;
384
            }
385
        }
386
387
        // This will probably never be reached ...
388
        throw new \RuntimeException('PSR-4 autoloader file can not be found!');
389
    }
390
391
    /**
392
     * @param array<int,string> $excludePatterns
393
     *
394
     * @return array<int,string>
395
     *
396
     * @throws \ReflectionException
397
     */
398
    private function findClasses(string $namespace, string $pattern, array $excludePatterns): array
399
    {
400
        $classNames = [];
401
        $container = $this->container;
402
403
        foreach (\glob($pattern, \GLOB_BRACE) as $directory) {
404
            if (\is_dir($directory)) {
405
                $directoryIterator = new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS);
406
                $files = \iterator_to_array(new \RecursiveIteratorIterator($directoryIterator));
407
                \uksort($files, 'strnatcmp');
408
            } else {
409
                $files = [$directory => new \SplFileInfo($directory)];
410
            }
411
412
            /** @var \SplFileInfo $info */
413
            foreach ($files as $path => $info) {
414
                $path = \str_replace('\\', '/', $path); // normalize Windows slashes
415
                $pathLength = 0;
416
417
                foreach ($excludePatterns as $excludePattern) {
418
                    $excludePattern = $container->parameter($this->directory . $excludePattern);
419
420
                    foreach (\glob($excludePattern, \GLOB_BRACE) ?: [$excludePattern] as $excludedPath) {
421
                        if (\str_starts_with($path, \str_replace('\\', '/', $excludedPath))) {
422
                            continue 3;
423
                        }
424
                    }
425
                }
426
427
                if (!\preg_match('/\\.php$/', $path, $m) || !$info->isReadable()) {
428
                    continue;
429
                }
430
431
                foreach (\explode('\\', $namespace, -1) as $namespaced) {
432
                    if ($pos = \strpos($path, $namespaced . '/')) {
433
                        $pathLength = +$pos + \strlen($namespaced . '/');
434
                    }
435
                }
436
437
                if (0 === $pathLength) {
438
                    $pathLength = \preg_match('/\w+\.php$/', $path, $l) ? \strpos($path, $l[0]) : 0;
439
                }
440
                $class = \str_replace('/', '\\', \substr($path, $pathLength, -\strlen($m[0])));
441
442
                if (null === $class = $this->findClass($container, $namespace . $class, $path, $pattern)) {
443
                    continue;
444
                }
445
                $classNames[] = $class;
446
447
                // track only for new & removed files
448
                if ($container instanceof ContainerBuilder && \interface_exists(ResourceInterface::class)) {
449
                    $container->addResource(new FileExistenceResource($path));
450
                }
451
            }
452
        }
453
454
        return $classNames;
455
    }
456
457
    private function findClass(AbstractContainer $container, string $class, string $path, string $pattern): ?string
458
    {
459
        if (!\preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+(?:\\\\[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+)*+$/', $class)) {
460
            return null;
461
        }
462
463
        try {
464
            $r = new \ReflectionClass($class);
465
        } catch (\Error | \ReflectionException $e) {
466
            if (\preg_match('/^Class .* not found$/', $e->getMessage())) {
467
                return null;
468
            }
469
470
            if ($e instanceof \ReflectionException) {
471
                $e = new \InvalidArgumentException(\sprintf('Expected to find class "%s" in file "%s" while importing services from resource "%s", but it was not found! Check the namespace prefix used with the resource.', $class, $path, $pattern), 0, $e);
472
            }
473
474
            throw $e;
475
        }
476
477
        if ($container instanceof ContainerBuilder && \interface_exists(ResourceInterface::class)) {
478
            $container->addResource(new ClassExistenceResource($class, false));
479
            $container->addResource(new FileExistenceResource($rPath = $r->getFileName()));
480
            $container->addResource(new FileResource($rPath));
481
        }
482
483
        if ($r->isInstantiable()) {
484
            return $class;
485
        }
486
487
        return null;
488
    }
489
490
    private function createErrorException(string $name, \Throwable $e): \Throwable
491
    {
492
        if (\str_starts_with($e->getMessage(), 'call_user_func_array(): Argument #1')) {
493
            $e = new \BadMethodCallException(\sprintf(
494
                'Call to undefined method %s() method must either belong to an instance of %s or the %s class',
495
                $name,
496
                Definitions\DefinitionInterface::class,
497
                __CLASS__,
498
            ), 0, $e);
499
        }
500
501
        return $e;
502
    }
503
504
    private function createInitializingError(string $name): \LogicException
505
    {
506
        return new \LogicException(\sprintf('Did you forget to register a service via "set", "autowire", or "namespaced" methods\' before calling the %s() method.', $name));
507
    }
508
}
509