Passed
Push — main ( 31af8b...652a21 )
by Thierry
04:07
created

ComponentContainer::getComponentOptions()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 5
c 0
b 0
f 0
nc 1
nop 2
dl 0
loc 8
rs 10
1
<?php
2
3
/**
4
 * ComponentContainer.php
5
 *
6
 * Jaxon DI container. Provide container service for the registered classes.
7
 *
8
 * @package jaxon-core
9
 * @author Thierry Feuzeu <[email protected]>
10
 * @copyright 2024 Thierry Feuzeu <[email protected]>
11
 * @license https://opensource.org/licenses/BSD-3-Clause BSD 3-Clause License
12
 * @link https://github.com/jaxon-php/jaxon-core
13
 */
14
15
namespace Jaxon\Di;
16
17
use Jaxon\App\Component\AbstractComponent;
18
use Jaxon\App\Component\Pagination;
19
use Jaxon\App\Config\ConfigManager;
20
use Jaxon\App\FuncComponent;
21
use Jaxon\App\NodeComponent;
22
use Jaxon\App\I18n\Translator;
23
use Jaxon\App\Metadata\InputData;
24
use Jaxon\App\Metadata\Metadata;
25
use Jaxon\Config\Config;
26
use Jaxon\Exception\SetupException;
27
use Jaxon\Plugin\Request\CallableClass\CallableObject;
28
use Jaxon\Plugin\Request\CallableClass\ComponentHelper;
29
use Jaxon\Plugin\Request\CallableClass\ComponentOptions;
30
use Jaxon\Plugin\Request\CallableClass\ComponentRegistry;
31
use Jaxon\Request\Handler\CallbackManager;
32
use Jaxon\Request\Target;
33
use Jaxon\Script\Call\JxnCall;
34
use Jaxon\Script\Call\JxnClassCall;
35
use Pimple\Container as PimpleContainer;
36
use Closure;
37
use ReflectionClass;
38
use ReflectionException;
39
use ReflectionMethod;
40
use ReflectionProperty;
41
42
use function array_filter;
43
use function array_map;
44
use function call_user_func;
45
use function in_array;
46
use function str_replace;
47
use function trim;
48
49
class ComponentContainer
50
{
51
    use Traits\ComponentKeyTrait;
52
    use Traits\DiAutoTrait;
53
54
    /**
55
     * If the underscore is used as separator in js class names.
56
     *
57
     * @var bool
58
     */
59
    private $bUsingUnderscore = false;
60
61
    /**
62
     * The Dependency Injection Container for registered classes
63
     *
64
     * @var PimpleContainer
65
     */
66
    private $xContainer;
67
68
    /**
69
     * The classes, both registered and found in registered directories.
70
     *
71
     * @var array
72
     */
73
    protected $aComponents = [];
74
75
    /**
76
     * This will be set only when getting the object targetted by the ajax request.
77
     *
78
     * @var Target
79
     */
80
    private $xTarget = null;
81
82
    /**
83
     * The class constructor
84
     *
85
     * @param Container $di
86
     */
87
    public function __construct(private Container $di)
88
    {
89
        $this->xContainer = new PimpleContainer();
90
        $this->val(ComponentContainer::class, $this);
91
92
        // Register the call factory for registered functions
93
        $this->set($this->getRequestFactoryKey(JxnCall::class), fn() =>
94
            new JxnCall($this->di->g(ConfigManager::class)
95
                ->getOption('core.prefix.function', '')));
96
97
        // Register the pagination component, but do not export to js.
98
        $this->registerComponent(Pagination::class, [
99
            'excluded' => true,
100
            'namespace' => 'Jaxon\\App\\Component',
101
        ]);
102
    }
103
104
    /**
105
     * The container for parameters
106
     *
107
     * @return Container
108
     */
109
    protected function cn(): Container
110
    {
111
        return $this->di;
112
    }
113
114
115
    /**
116
     * @return void
117
     */
118
    public function useUnderscore()
119
    {
120
        $this->bUsingUnderscore = true;
121
    }
122
123
    /**
124
     * Check if a class is defined in the container
125
     *
126
     * @param class-string $sClass    The full class name
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string.
Loading history...
127
     *
128
     * @return bool
129
     */
130
    public function has(string $sClass): bool
131
    {
132
        return $this->xContainer->offsetExists($sClass);
133
    }
134
135
    /**
136
     * Save a closure in the container
137
     *
138
     * @param class-string $sClass    The full class name
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string.
Loading history...
139
     * @param Closure $xClosure    The closure
140
     *
141
     * @return void
142
     */
143
    public function set(string $sClass, Closure $xClosure)
144
    {
145
       $this->xContainer->offsetSet($sClass, function() use($xClosure) {
146
            return $xClosure($this);
147
        });
148
    }
149
150
    /**
151
     * Save a value in the container
152
     *
153
     * @param string|class-string $sKey    The key
0 ignored issues
show
Documentation Bug introduced by
The doc comment string|class-string at position 2 could not be parsed: Unknown type name 'class-string' at position 2 in string|class-string.
Loading history...
154
     * @param mixed $xValue    The value
155
     *
156
     * @return void
157
     */
158
    public function val(string $sKey, $xValue)
159
    {
160
       $this->xContainer->offsetSet($sKey, $xValue);
161
    }
162
163
    /**
164
     * Get a class instance
165
     *
166
     * @template T
167
     * @param class-string<T> $sClass The full class name
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string<T> at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string<T>.
Loading history...
168
     *
169
     * @return T
170
     */
171
    public function get(string $sClass)
172
    {
173
        return $this->xContainer->offsetGet($sClass);
174
    }
175
176
    /**
177
     * Get a component when one of its method needs to be called
178
     *
179
     * @template T
180
     * @param class-string<T> $sClassName the class name
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string<T> at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string<T>.
Loading history...
181
     * @param Target $xTarget
182
     *
183
     * @return T|null
184
     */
185
    public function getTargetComponent(string $sClassName, Target $xTarget): mixed
186
    {
187
        // Set the target only when getting the object targetted by the ajax request.
188
        $this->xTarget = $xTarget;
189
        $xComponent = $this->get($sClassName);
190
        $this->xTarget = null;
191
192
        return $xComponent;
193
    }
194
195
    /**
196
     * Save a component options
197
     *
198
     * @param class-string $sClassName    The class name
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string.
Loading history...
199
     * @param array $aOptions    The class options
200
     *
201
     * @return void
202
     */
203
    public function registerComponent(string $sClassName, array $aOptions = [])
204
    {
205
        try
206
        {
207
            // Make sure the registered class exists
208
            if(isset($aOptions['include']))
209
            {
210
                require_once $aOptions['include'];
211
            }
212
            $xReflectionClass = new ReflectionClass($sClassName);
213
            // Check if the class is registrable
214
            if(!$xReflectionClass->isInstantiable())
215
            {
216
                return;
217
            }
218
219
            $this->aComponents[$sClassName] = $aOptions;
220
            $this->val($this->getReflectionClassKey($sClassName), $xReflectionClass);
221
            // Register the user class, but only if the user didn't already.
222
            if(!$this->has($sClassName))
223
            {
224
                $this->set($sClassName, function() use($sClassName) {
225
                    return $this->make($this->get($this->getReflectionClassKey($sClassName)));
226
                });
227
            }
228
        }
229
        catch(ReflectionException $e)
230
        {
231
            throw new SetupException($this->di->g(Translator::class)
232
                ->trans('errors.class.invalid', ['name' => $sClassName]));
233
        }
234
    }
235
236
    /**
237
     * Find a component amongst the registered namespaces and directories.
238
     *
239
     * @param class-string $sClassName The class name
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string.
Loading history...
240
     *
241
     * @return void
242
     * @throws SetupException
243
     */
244
    private function discoverComponent(string $sClassName)
245
    {
246
        if(!isset($this->aComponents[$sClassName]))
247
        {
248
            $xRegistry = $this->di->g(ComponentRegistry::class);
249
            $xRegistry->updateHash(false); // Disable hash calculation.
250
            $aOptions = $xRegistry->getNamespaceComponentOptions($sClassName);
251
            if($aOptions !== null)
252
            {
253
                $this->registerComponent($sClassName, $aOptions);
254
            }
255
            else // if(!isset($this->aComponents[$sClassName]))
256
            {
257
                // The component was not found in a registered namespace. We need to parse all
258
                // the directories to be able to find a component registered without a namespace.
259
                $xRegistry->registerComponentsInDirectories();
260
            }
261
        }
262
        if(!isset($this->aComponents[$sClassName]))
263
        {
264
            throw new SetupException($this->di->g(Translator::class)
265
                ->trans('errors.class.invalid', ['name' => $sClassName]));
266
        }
267
    }
268
269
    /**
270
     * Get callable objects for known classes
271
     *
272
     * @return array
273
     * @throws SetupException
274
     */
275
    public function getCallableObjects(): array
276
    {
277
        $aCallableObjects = [];
278
        foreach($this->aComponents as $sClassName => $_)
279
        {
280
            $aCallableObjects[$sClassName] = $this->makeCallableObject($sClassName);
281
        }
282
        return $aCallableObjects;
283
    }
284
285
    /**
286
     * @param ReflectionClass $xReflectionClass
287
     * @param string $sMethodName
288
     *
289
     * @return bool
290
     */
291
    private function isNotCallable(ReflectionClass $xReflectionClass, string $sMethodName): bool
292
    {
293
        // Don't take magic __call, __construct, __destruct methods
294
        // The public methods of the Component base classes are protected.
295
        return substr($sMethodName, 0, 2) === '__' ||
296
            ($xReflectionClass->isSubclassOf(NodeComponent::class) &&
297
            in_array($sMethodName, ['item', 'html'])) ||
298
            ($xReflectionClass->isSubclassOf(FuncComponent::class) &&
299
            in_array($sMethodName, ['paginator']));
300
    }
301
302
    /**
303
     * Get the public methods of the callable object
304
     *
305
     * @param ReflectionClass $xReflectionClass
306
     *
307
     * @return array
308
     */
309
    public function getPublicMethods(ReflectionClass $xReflectionClass): array
310
    {
311
        $aMethods = array_map(fn($xMethod) => $xMethod->getShortName(),
312
            $xReflectionClass->getMethods(ReflectionMethod::IS_PUBLIC));
313
314
        return array_filter($aMethods, fn($sMethodName) =>
315
            !$this->isNotCallable($xReflectionClass, $sMethodName));
316
    }
317
318
    /**
319
     * @param ReflectionClass $xReflectionClass
320
     * @param array $aOptions
321
     *
322
     * @return Metadata|null
323
     */
324
    private function getComponentMetadata(ReflectionClass $xReflectionClass,
325
        array $aOptions): ?Metadata
326
    {
327
        /** @var Config|null */
328
        $xConfig = $aOptions['config'] ?? null;
329
        if($xConfig === null || (bool)($aOptions['excluded'] ?? false))
330
        {
331
            return null;
332
        }
333
        $sReaderId = $xConfig->getOption('metadata.reader');
334
        if(!in_array($sReaderId, ['attributes', 'annotations']))
335
        {
336
            return null;
337
        }
338
339
        // Try to get the class metadata from the cache.
340
        $sClassName = $xReflectionClass->getName();
341
        $xMetadataCache = !$xConfig->getOption('metadata.cache') ?
342
            null : $this->di->getMetadataCache();
343
        $xMetadata = $xMetadataCache?->read($sClassName) ?? null;
344
345
        if($xMetadata !== null)
346
        {
347
            return $xMetadata;
348
        }
349
350
        $aProperties = array_map(fn($xProperty) => $xProperty->getName(),
351
            $xReflectionClass->getProperties(ReflectionProperty::IS_PUBLIC |
352
                ReflectionProperty::IS_PROTECTED));
353
        $aMethods = $this->getPublicMethods($xReflectionClass);
354
355
        $xMetadataReader = $this->di->getMetadataReader($sReaderId);
356
        $xInput = new InputData($xReflectionClass, $aMethods, $aProperties);
357
        $xMetadata = $xMetadataReader->getAttributes($xInput);
358
359
        // Try to save the metadata in the cache
360
        if($xMetadataCache !== null && $xMetadata !== null)
361
        {
362
            $xMetadataCache->save($sClassName, $xMetadata);
363
        }
364
        return $xMetadata;
365
    }
366
367
    /**
368
     * @param ReflectionClass $xReflectionClass
369
     * @param array $aOptions
370
     *
371
     * @return ComponentOptions
372
     */
373
    private function getComponentOptions(ReflectionClass $xReflectionClass,
374
        array $aOptions): ComponentOptions
375
    {
376
        $xMetadata = $this->getComponentMetadata($xReflectionClass, $aOptions);
377
        $bExcluded = $xMetadata?->isExcluded() ?? false;
378
        $aProtectedMethods = $xMetadata?->getProtectedMethods() ?? [];
379
        $aProperties = $xMetadata?->getProperties() ?? [];
380
        return new ComponentOptions($aOptions, $bExcluded, $aProtectedMethods, $aProperties);
381
    }
382
383
    /**
384
     * Register a component
385
     *
386
     * @param class-string $sClassName The component name
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string.
Loading history...
387
     *
388
     * @return void
389
     * @throws SetupException
390
     */
391
    private function _registerComponent(string $sClassName)
392
    {
393
        $sComponentObject = $this->getCallableObjectKey($sClassName);
394
        // Prevent duplication. It's important not to use the class name here.
395
        if($this->has($sComponentObject))
396
        {
397
            return;
398
        }
399
400
        // Register the helper class
401
        $this->set($this->getCallableHelperKey($sClassName), function() use($sClassName) {
402
            $xFactory = $this->di->getCallFactory();
403
            return new ComponentHelper($this, $xFactory->rq($sClassName),
404
                $xFactory, $this->di->getViewRenderer(),
405
                $this->di->getLogger(), $this->di->getSessionManager(),
406
                $this->di->getStash(), $this->di->getUploadHandler());
407
        });
408
409
        $this->discoverComponent($sClassName);
410
        $aOptions = $this->aComponents[$sClassName];
411
412
        // Register the callable object
413
        $this->set($sComponentObject, function() use($sClassName, $aOptions) {
414
            $xReflectionClass = $this->get($this->getReflectionClassKey($sClassName));
415
            $xOptions = $this->getComponentOptions($xReflectionClass, $aOptions);
416
            return new CallableObject($this, $this->di, $xReflectionClass, $xOptions);
417
        });
418
419
        // Initialize the user class instance
420
        $this->xContainer->extend($sClassName, function($xClassInstance) use($sClassName) {
421
            if($xClassInstance instanceof AbstractComponent)
422
            {
423
                $xHelper = $this->get($this->getCallableHelperKey($sClassName));
424
                $xHelper->xTarget = $this->xTarget;
425
426
                // Call the protected "initComponent()" method of the Component class.
427
                $cSetter = function($di, $xHelper) {
428
                    $this->initComponent($di, $xHelper);  // "$this" here refers to the Component class.
0 ignored issues
show
Bug introduced by
The method initComponent() does not exist on Jaxon\Di\ComponentContainer. ( Ignorable by Annotation )

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

428
                    $this->/** @scrutinizer ignore-call */ 
429
                           initComponent($di, $xHelper);  // "$this" here refers to the Component class.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
429
                };
430
                $cSetter = $cSetter->bindTo($xClassInstance, $xClassInstance);
431
                call_user_func($cSetter, $this->di, $xHelper);
432
            }
433
434
            // Run the callbacks for class initialisation
435
            $this->di->g(CallbackManager::class)->onInit($xClassInstance);
436
437
            // Set attributes from the DI container.
438
            // The class level DI options are set on any component.
439
            // The method level DI options are set only on the targetted component.
440
            /** @var CallableObject */
441
            $xCallableObject = $this->get($this->getCallableObjectKey($sClassName));
442
            $xCallableObject->setDiClassAttributes($xClassInstance);
443
            if($this->xTarget !== null)
444
            {
445
                $sMethodName = $this->xTarget->getMethodName();
446
                $xCallableObject->setDiMethodAttributes($xClassInstance, $sMethodName);
447
            }
448
449
            return $xClassInstance;
450
        });
451
    }
452
453
    /**
454
     * Get the callable object for a given class
455
     *
456
     * @param class-string $sClassName
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string.
Loading history...
457
     *
458
     * @return CallableObject
459
     */
460
    public function getCallableObject(string $sClassName): CallableObject
461
    {
462
        return $this->get($this->getCallableObjectKey($sClassName));
463
    }
464
465
    /**
466
     * @param string $sClassName A class name, but possibly with dot or underscore as separator
467
     *
468
     * @return class-string
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string.
Loading history...
469
     * @throws SetupException
470
     */
471
    private function getClassName(string $sClassName): string
472
    {
473
        // Replace all separators ('.' or '_') with antislashes, and trim the class name.
474
        $sSeparator = !$this->bUsingUnderscore ? '.' : '_';
475
        return trim(str_replace($sSeparator, '\\', $sClassName), '\\');
476
    }
477
478
    /**
479
     * Get the callable object for a given class
480
     * The callable object is registered if it is not already in the DI.
481
     *
482
     * @param class-string $sClassName The class name of the callable object
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string.
Loading history...
483
     *
484
     * @return CallableObject|null
485
     * @throws SetupException
486
     */
487
    public function makeCallableObject(string $sClassName): ?CallableObject
488
    {
489
        $sClassName = $this->getClassName($sClassName);
490
        $this->_registerComponent($sClassName);
491
        return $this->getCallableObject($sClassName);
492
    }
493
494
    /**
495
     * Get an instance of a component by name
496
     *
497
     * @template T
498
     * @param class-string<T> $sClassName the class name
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string<T> at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string<T>.
Loading history...
499
     *
500
     * @return T|null
501
     * @throws SetupException
502
     */
503
    public function makeComponent(string $sClassName): mixed
504
    {
505
        $sClassName = $this->getClassName($sClassName);
506
        $this->_registerComponent($sClassName);
507
        return $this->get($sClassName);
508
    }
509
510
    /**
511
     * @param class-string $sClassName
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string.
Loading history...
512
     * @param string $sFactoryKey
513
     *
514
     * @return void
515
     */
516
    private function registerRequestFactory(string $sClassName, string $sFactoryKey)
517
    {
518
        $this->xContainer->offsetSet($sFactoryKey, function() use($sClassName) {
519
            if(!($xCallable = $this->makeCallableObject($sClassName)))
520
            {
521
                return null;
522
            }
523
524
            $sPrefix = $this->di->g(ConfigManager::class)->getOption('core.prefix.class', '');
525
            return new JxnClassCall($sPrefix . $xCallable->getJsName());
526
        });
527
    }
528
529
    /**
530
     * Get a factory for a call to a registered function.
531
     *
532
     * @return JxnCall
533
     */
534
    private function getFunctionRequestFactory(): JxnCall
535
    {
536
        return $this->get($this->getRequestFactoryKey(JxnCall::class));
537
    }
538
539
    /**
540
     * Get a factory for a call to a registered component.
541
     *
542
     * @param class-string $sClassName
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string.
Loading history...
543
     *
544
     * @return JxnCall|null
545
     */
546
    private function getComponentRequestFactory(string $sClassName): ?JxnCall
547
    {
548
        $sClassName = trim($sClassName, " \t");
549
        if($sClassName === '')
550
        {
551
            return null;
552
        }
553
554
        $sFactoryKey = $this->getRequestFactoryKey($sClassName);
555
        if(!$this->has($sFactoryKey))
556
        {
557
            $this->registerRequestFactory($sClassName, $sFactoryKey);
558
        }
559
        return $this->get($sFactoryKey);
560
    }
561
562
    /**
563
     * Get a factory.
564
     *
565
     * @param string|class-string $sClassName
0 ignored issues
show
Documentation Bug introduced by
The doc comment string|class-string at position 2 could not be parsed: Unknown type name 'class-string' at position 2 in string|class-string.
Loading history...
566
     *
567
     * @return JxnCall|null
568
     */
569
    public function getRequestFactory(string $sClassName = ''): JxnCall
570
    {
571
        return $sClassName === '' ? $this->getFunctionRequestFactory() :
0 ignored issues
show
Bug Best Practice introduced by
The expression return $sClassName === '...estFactory($sClassName) could return the type null which is incompatible with the type-hinted return Jaxon\Script\Call\JxnCall. Consider adding an additional type-check to rule them out.
Loading history...
572
            $this->getComponentRequestFactory($sClassName);
573
    }
574
}
575