Test Failed
Pull Request — master (#37)
by Divine Niiquaye
03:08
created

DefinitionBuilder   F

Complexity

Total Complexity 84

Size/Duplication

Total Lines 473
Duplicated Lines 0 %

Importance

Changes 23
Bugs 0 Features 0
Metric Value
eloc 173
c 23
b 0
f 0
dl 0
loc 473
rs 2
wmc 84

22 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 3 1
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 17 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 15 3
A extend() 0 5 1
B findClass() 0 31 8
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
                    $resolvedDef = $this->container->set($resource, \unserialize($serializedDef)->replace($resource, true));
62
63
                    if (\str_contains($serializedDef, 'autowired";b:1;')) {
64
                        $resolvedDef->typed(Resolver::autowireService($resource));
65
                    }
66
                }
67
            }
68
69
            $this->classes = [];
70
        }
71
    }
72
73
    /**
74
     * Where all the magic happens.
75
     *
76
     * @param array<int,mixed> $arguments
77
     *
78
     * @return $this
79
     *
80
     * @throws \Throwable
81
     */
82
    public function __call(string $name, array $arguments)
83
    {
84
        if (!$id = $this->definition) {
85
            throw $this->createInitializingError($name);
86
        }
87
88
        if ($this->trackDefaults) {
89
            $this->defaults[$id][] = [$name, $arguments];
90
        } else {
91
            try {
92
                $definition = (!isset($this->classes[$id]) ? $this->container->definition($id) : $this->classes[$id][0]);
93
                \call_user_func_array([$definition, $name], $arguments);
94
            } catch (\Throwable $e) {
95
                throw $this->createErrorException($name, $e);
96
            }
97
        }
98
99
        return $this;
100
    }
101
102
    /**
103
     * This method calls the __destruct() method as a way of
104
     * loading namespaced service definitions into container if exist.
105
     *
106
     * @return $this
107
     */
108
    final public function load(): self
109
    {
110
        $this->__destruct();
111
112
        return $this;
113
    }
114
115
    /**
116
     * Resets the builder to initial state.
117
     *
118
     * @return $this
119
     */
120
    public function reset()
121
    {
122
        $this->definition = $this->directory =  null;
123
        $this->classes = $this->defaults = [];
124
        $this->trackDefaults = false;
125
126
        return $this;
127
    }
128
129
    /**
130
     * Set a config into container's parameter.
131
     *
132
     * @param mixed $value
133
     *
134
     * @return $this
135
     */
136
    public function parameter(string $name, $value)
137
    {
138
        $this->container->parameters[$name] = $value;
139
140
        return $this;
141
    }
142
143
    /**
144
     * Marks an alias id to service id.
145
     *
146
     * @return $this
147
     */
148
    public function alias(string $id, string $serviceId = null)
149
    {
150
        $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

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