Test Failed
Pull Request — master (#37)
by Divine Niiquaye
13:18
created

DefinitionBuilder   F

Complexity

Total Complexity 97

Size/Duplication

Total Lines 529
Duplicated Lines 0 %

Importance

Changes 24
Bugs 0 Features 0
Metric Value
eloc 193
c 24
b 0
f 0
dl 0
loc 529
rs 2
wmc 97

24 Methods

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

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, $condition = true;
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($definition->getTypes());
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 (!$this->condition) {
85
            return $this;
86
        }
87
88
        if (!$id = $this->definition) {
89
            throw $this->createInitializingError($name);
90
        }
91
92
        if ($this->trackDefaults) {
93
            $this->defaults[$id][] = [$name, $arguments];
94
        } else {
95
            try {
96
                $definition = (!isset($this->classes[$id]) ? $this->container->definition($id) : $this->classes[$id][0]);
97
                \call_user_func_array([$definition, $name], $arguments);
98
            } catch (\Throwable $e) {
99
                throw $this->createErrorException($name, $e);
100
            }
101
        }
102
103
        return $this;
104
    }
105
106
    /**
107
     * This method calls the __destruct() method as a way of
108
     * loading namespaced service definitions into container if exist.
109
     *
110
     * @return $this
111
     */
112
    final public function load(): self
113
    {
114
        $this->__destruct();
115
116
        return $this;
117
    }
118
119
    /**
120
     * Resets the builder to initial state.
121
     *
122
     * @return $this
123
     */
124
    public function reset()
125
    {
126
        $this->definition = $this->directory =  null;
127
        $this->classes = $this->defaults = [];
128
        $this->trackDefaults = false;
129
130
        return $this;
131
    }
132
133
    /**
134
     * Set a config into container's parameter.
135
     *
136
     * @param mixed $value
137
     *
138
     * @return $this
139
     */
140
    public function parameter(string $name, $value)
141
    {
142
        if ($this->condition) {
143
            $this->container->parameters[$name] = $value;
144
        }
145
146
        return $this;
147
    }
148
149
    /**
150
     * Marks an alias id to service id.
151
     *
152
     * @return $this
153
     */
154
    public function alias(string $id, string $serviceId = null)
155
    {
156
        if ($this->condition) {
157
            $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

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