Issues (26)

src/viewmodel/ViewModelRenderer.php (6 issues)

1
<?php declare(strict_types = 1);
2
namespace Templado\Engine;
3
4
use DOMAttr;
5
use DOMDocumentFragment;
6
use DOMElement;
7
use DOMNode;
8
9
class ViewModelRenderer {
10
11
    /** @var array */
12
    private $stack = [];
13
14
    /** @var string[] */
15
    private $stackNames = [];
16
17
    /** @var SnapshotDOMNodelist[] */
18
    private $listStack = [];
19
20
    /** @var object */
21
    private $resourceModel;
22
23
    /** @var array */
24
    private $prefixes = [];
25
26
    /**
27
     * @throws ViewModelRendererException
28
     */
29 102
    public function render(DOMNode $context, object $model): void {
30 102
        $this->resourceModel = $model;
31 102
        $this->stack         = [$model];
32 102
        $this->stackNames    = [];
33 102
        $this->listStack     = [];
34 102
        $this->prefixes      = [];
35 102
        $this->walk($context);
36 54
    }
37
38
    /**
39
     * @throws ViewModelRendererException
40
     */
41 102
    private function walk(DOMNode $context): void {
42 102
        if (!$context instanceof DOMElement) {
43 48
            return;
44
        }
45
46 102
        $stackAdded = 0;
47
48 102
        if ($context->hasAttribute('prefix')) {
49 18
            $this->resolvePrefixDefinition($context->getAttribute('prefix'));
50
        }
51
52 96
        if ($context->hasAttribute('resource')) {
53 9
            $this->addResourceToStack($context);
54 6
            $stackAdded++;
55
        }
56
57 93
        if ($context->hasAttribute('property')) {
58 93
            $this->addToStack($context);
59 87
            $stackAdded++;
60 87
            $context = $this->applyCurrent($context);
61
        }
62
63 60
        if ($context->hasChildNodes()) {
64 54
            $list              = new SnapshotDOMNodelist($context->childNodes);
65 54
            $this->listStack[] = $list;
66
67 54
            while ($list->hasNext()) {
68 54
                $childNode = $list->getNext();
69
                /* @var \DOMNode $childNode */
70 54
                $this->walk($childNode);
71
            }
72 48
            \array_pop($this->listStack);
73
        }
74
75 54
        while ($stackAdded > 0) {
76 54
            $this->dropFromStack();
77 54
            $stackAdded--;
78
        }
79 54
    }
80
81
    /**
82
     * @throws ViewModelRendererException
83
     */
84 93
    private function addToStack(DOMElement $context): void {
85 93
        $model    = $this->current();
86 93
        $property = $context->getAttribute('property');
87
88 93
        if (\substr_count($property, ':') === 1) {
89 15
            [$prefix, $property] = \explode(':', $property);
90
91 15
            if (!\array_key_exists($prefix, $this->prefixes)) {
92 3
                throw new ViewModelRendererException(\sprintf('Undefined prefix %s', $prefix));
93
            }
94
95 12
            $model = $this->prefixes[$prefix];
96
97 12
            if ($model === null) {
98 3
                return;
99
            }
100
        }
101
102 87
        $this->ensureIsObject($model, $property);
103
104 87
        $this->stackNames[] = $property;
105
106 87
        foreach ([$property, 'get' . \ucfirst($property)] as $method) {
107 87
            if (\method_exists($model, $method)) {
108 78
                $this->stack[] = $model->{$method}($context->nodeValue);
109
110 81
                return;
111
            }
112
        }
113
114 15
        if (\method_exists($model, '__call')) {
115 12
            $this->stack[] = $model->{$property}($context->nodeValue);
116
117 12
            return;
118
        }
119
120 3
        throw new ViewModelRendererException(
121 3
            \sprintf('Viewmodel method missing: $model->%s', \implode('()->', $this->stackNames) . '()')
122
        );
123
    }
124
125 93
    private function current() {
126 93
        return \end($this->stack);
127
    }
128
129 54
    private function dropFromStack(): void {
130 54
        \array_pop($this->stack);
131 54
        \array_pop($this->stackNames);
132 54
    }
133
134
    /**
135
     * @throws ViewModelRendererException
136
     */
137 87
    private function applyCurrent(DOMElement $context): DOMNode {
138
        /** @psalm-suppress MixedAssignment */
139 87
        $model = $this->current();
140
141 87
        switch (\gettype($model)) {
142 87
            case 'boolean': {
143
                /** @var bool $model */
144 9
                return $this->processBoolean($context, $model);
145
            }
146 81
            case 'string': {
147
                /** @var string $model */
148 39
                $this->processString($context, $model);
149
150 39
                return $context;
151
            }
152 75
            case 'object': {
153
                /** @var object $model */
154 63
                return $this->processObject($context, $model);
155
            }
156
157 39
            case 'array': {
158 36
                return $this->processArray($context, $model);
159
            }
160
161
            default: {
162 3
                throw new ViewModelRendererException(
163 3
                    \sprintf(
164 3
                        'Value returned by $model->%s must not be of type %s',
165 3
                        \implode('()->', $this->stackNames) . '()',
166 3
                        \gettype($model)
167
                    )
168
                );
169
            }
170
        }
171
    }
172
173
    /**
174
     * @throws ViewModelRendererException
175
     *
176
     * @return DOMDocumentFragment|DOMElement
177
     */
178 12
    private function processBoolean(DOMElement $context, bool $model) {
179 12
        if ($model === true) {
180 6
            return $context;
181
        }
182
183 6
        if ($context->isSameNode($context->ownerDocument->documentElement)) {
184 3
            throw new ViewModelRendererException('Cannot remove root element');
185
        }
186
187 3
        $this->removeNodeFromCurrentSnapshotList($context);
188 3
        $context->parentNode->removeChild($context);
189
190 3
        return $context->ownerDocument->createDocumentFragment();
191
    }
192
193 39
    private function processString(DOMElement $context, string $model): void {
194 39
        $context->nodeValue   = '';
195 39
        $context->textContent = $model;
196 39
    }
197
198
    /**
199
     * @throws ViewModelRendererException
200
     *
201
     * @return \DOMDocumentFragment|\DOMElement
202
     */
203 63
    private function processObject(DOMElement $context, object $model) {
204 63
        if (\is_iterable($model)) {
205
            /** @var iterable $model */
206 6
            return $this->processArray($context, $model);
207
        }
208
209 57
        return $this->processObjectAsModel($context, $model);
210
    }
211
212
    /**
213
     * @throws ViewModelRendererException
214
     */
215 57
    private function processObjectAsModel(DOMElement $context, object $model): DOMElement {
216 57
        $container   = $this->moveToContainer($context, false);
217 57
        $workContext = $this->selectMatchingWorkContext($container->firstChild, $model);
0 ignored issues
show
It seems like $container->firstChild can also be of type null; however, parameter $context of Templado\Engine\ViewMode...ctMatchingWorkContext() does only seem to accept DOMElement, 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

217
        $workContext = $this->selectMatchingWorkContext(/** @scrutinizer ignore-type */ $container->firstChild, $model);
Loading history...
218
219 48
        if (\method_exists($model, 'asString') ||
220 48
            \method_exists($model, '__call')
221
        ) {
222
            /** @psalm-suppress MixedAssignment */
223 30
            $value = $model->asString($workContext->nodeValue);
224
225 30
            if (!\is_null($value) && !\is_string($value)) {
226 3
                throw new ViewModelRendererException(
227 3
                    \sprintf(
228 3
                        "Method \$model->%s must return 'null' or 'string', got '%s'",
229 3
                        \implode('()->', $this->stackNames) . '()->asString()',
230 3
                        \gettype($value)
231
                    )
232
                );
233
            }
234
235
            /** @psalm-var null|string $value */
236 27
            if ($value !== null) {
237 27
                $workContext->nodeValue   = '';
238 27
                $workContext->textContent = $value;
239
            }
240
        }
241
242 45
        foreach (new SnapshotAttributeList($workContext->attributes) as $attribute) {
0 ignored issues
show
It seems like $workContext->attributes can also be of type null; however, parameter $map of Templado\Engine\Snapshot...buteList::__construct() does only seem to accept DOMNamedNodeMap, 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

242
        foreach (new SnapshotAttributeList(/** @scrutinizer ignore-type */ $workContext->attributes) as $attribute) {
Loading history...
243 45
            $this->processAttribute($attribute, $model);
244
        }
245
246 42
        $container->parentNode->insertBefore($workContext, $container);
247 42
        $container->parentNode->removeChild($container);
248
249 42
        return $workContext;
250
    }
251
252
    /**
253
     * @throws ViewModelRendererException
254
     */
255 42
    private function processArray(DOMElement $context, iterable $model): DOMDocumentFragment {
256 42
        $count = $this->getElementCount($model);
257
258 39
        if ($count > 1 && $context->isSameNode($context->ownerDocument->documentElement)) {
259 3
            throw new ViewModelRendererException(
260 3
                'Cannot render multiple copies of root element'
261
            );
262
        }
263
264 36
        if ($count === 0) {
265 3
            return $this->processBoolean($context, false);
266
        }
267
268 33
        $container = $this->moveToContainer($context, true);
269
270
        /**
271
         * @psalm-suppress MixedAssignment
272
         * @psalm-var int $pos
273
         */
274 33
        foreach ($model as $pos => $entry) {
275 33
            $subcontext = $container->cloneNode(true);
276 33
            $container->parentNode->insertBefore($subcontext, $container);
277
278 33
            $result = $this->processArrayEntry($subcontext->firstChild, $entry, $pos);
0 ignored issues
show
It seems like $subcontext->firstChild can also be of type null; however, parameter $context of Templado\Engine\ViewMode...er::processArrayEntry() does only seem to accept DOMElement, 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

278
            $result = $this->processArrayEntry(/** @scrutinizer ignore-type */ $subcontext->firstChild, $entry, $pos);
Loading history...
279
280 30
            $container->parentNode->insertBefore($result, $subcontext);
281 30
            $container->parentNode->removeChild($subcontext);
282
        }
283
284 30
        $fragment = $container->ownerDocument->createDocumentFragment();
285 30
        $container->parentNode->removeChild($container);
286
287 30
        return $fragment;
288
    }
289
290
    /**
291
     * @throws ViewModelRendererException
292
     * @psalm-suppress MissingParamType
293
     */
294 33
    private function processArrayEntry(DOMElement $context, $entry, int $pos): DOMElement {
295 33
        $workContext = $this->selectMatchingWorkContext($context, $entry);
296
        /* @var DOMElement $clone */
297 30
        $this->stack[]      = $entry;
298 30
        $this->stackNames[] = (string)$pos;
299
300 30
        $this->applyCurrent($workContext);
301
302 30
        if ($workContext->hasChildNodes()) {
303 30
            $list              = new SnapshotDOMNodelist($workContext->childNodes);
304 30
            $this->listStack[] = $list;
305
306 30
            while ($list->hasNext()) {
307 30
                $this->walk($list->getNext());
308
            }
309 30
            \array_pop($this->listStack);
310
        }
311 30
        $this->dropFromStack();
312
313 30
        return $workContext;
314
    }
315
316
    /**
317
     * @throws ViewModelRendererException
318
     */
319 45
    private function processAttribute(DOMAttr $attribute, object $model): void {
320 45
        $attributeName = $attribute->nodeName;
321
322 45
        if (\strpos($attributeName, '-') !== false) {
323 3
            $parts = \explode('-', $attributeName);
324 3
            \array_walk(
325 3
                $parts,
326
                static function (string &$value, int $pos): void {
327 3
                    $value = \ucfirst($value);
328 3
                }
329
            );
330 3
            $attributeName = \implode('', $parts);
331
        }
332
333 45
        foreach ([$attributeName, 'get' . \ucfirst($attributeName), '__call'] as $method) {
334 45
            if (!\method_exists($model, $method)) {
335 45
                continue;
336
            }
337
338 39
            if ($method === '__call') {
339 3
                $method = $attribute->name;
340
            }
341
342
            /** @psalm-var null|bool|string $value */
343 39
            $value = $model->{$method}($attribute->value);
344
345 39
            if ($value === null) {
346 3
                return;
347
            }
348
349
            /** @var DOMElement $parent */
350 39
            $parent = $attribute->parentNode;
351
352 39
            if ($value === false) {
353 18
                $parent->removeAttribute($attribute->name);
354
355 18
                return;
356
            }
357
358 39
            if (!\is_string($value)) {
359 3
                throw new ViewModelRendererException(
360 3
                    \sprintf(
361 3
                        'Attribute value must be string or boolean false - type %s received from $model->%s',
362 3
                        \gettype($value),
363 3
                        \implode('()->', $this->stackNames) . '()'
364
                    )
365
                );
366
            }
367
368 36
            $parent->setAttribute($attribute->name, $value);
369
370 36
            return;
371
        }
372 42
    }
373
374
    /**
375
     * @throws ViewModelRendererException
376
     * @psalm-assert object $mode
377
     * @psalm-suppress MissingParamType
378
     */
379 87
    private function ensureIsObject($model, string $property): void {
380 87
        if (!\is_object($model)) {
381 3
            throw new ViewModelRendererException(
382 3
                \sprintf(
383 3
                    'Trying to add "%s" failed - Non object (%s) on stack: $%s',
384 3
                    $property,
385 3
                    \gettype($model),
386 3
                    \implode('()->', $this->stackNames) . '() '
387
                )
388
            );
389
        }
390 87
    }
391
392
    /**
393
     * @throws ViewModelRendererException
394
     * @psalm-suppress MissingParamType
395
     */
396 63
    private function selectMatchingWorkContext(DOMElement $context, $entry): DOMElement {
397 63
        if (!$context->hasAttribute('typeof')) {
398 42
            return $context;
399
        }
400
401 24
        if (!\is_object($entry)) {
402 3
            throw new ViewModelRendererException(
403 3
                \sprintf(
404 3
                    "Cannot call 'typeOf' on none object type '%s' returned from \$model->%s()",
405 3
                    \gettype($entry),
406 3
                    \implode('()->', $this->stackNames)
407
                )
408
            );
409
        }
410
411 21
        if (!\method_exists($entry, 'typeOf')) {
412 3
            throw new ViewModelRendererException(
413 3
                \sprintf(
414 3
                    "No 'typeOf' method in model returned from \$model->%s() but current context is conditional",
415 3
                    \implode('()->', $this->stackNames)
416
                )
417
            );
418
        }
419
420
        /** @psalm-suppress MixedAssignment */
421 18
        $requestedTypeOf = $entry->typeOf();
422
423 18
        if (!\is_string($requestedTypeOf)) {
424 3
            throw new ViewModelRendererException(
425 3
                \sprintf(
426 3
                    "Return value of \$model->%s()->typeOf() must be string, got '%s'",
427 3
                    \implode('()->', $this->stackNames),
428 3
                    \gettype($entry)
429
                )
430
            );
431
        }
432
433 15
        if ($context->getAttribute('typeof') === $requestedTypeOf) {
434 6
            return $context;
435
        }
436
437 15
        $xp   = new \DOMXPath($context->ownerDocument);
0 ignored issues
show
It seems like $context->ownerDocument can also be of type null; however, parameter $document of DOMXPath::__construct() does only seem to accept DOMDocument, 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

437
        $xp   = new \DOMXPath(/** @scrutinizer ignore-type */ $context->ownerDocument);
Loading history...
438 15
        $list = $xp->query(
439 15
            \sprintf(
440 15
                '(following-sibling::*)[@property="%s" and @typeof="%s"]',
441 15
                $context->getAttribute('property'),
442 15
                $requestedTypeOf
443
            ),
444 5
            $context
445
        );
446
447 15
        $newContext = $list->item(0);
448
449 15
        if (!$newContext instanceof DOMElement) {
450 3
            throw new ViewModelRendererException(
451 3
                \sprintf(
452 3
                    "Context for type '%s' not found.",
453 3
                    $requestedTypeOf
454
                )
455
            );
456
        }
457
458 12
        return $newContext;
459
    }
460
461 63
    private function moveToContainer(DOMElement $context, bool $greedy = true): DOMElement {
462 63
        $container = $context->ownerDocument->createElement('container');
463 63
        $context->parentNode->insertBefore($container, $context);
464
465 63
        if (!$greedy && !$context->hasAttribute('typeof')) {
466 63
            $container->appendChild($context);
467 63
            $this->removeNodeFromCurrentSnapshotList($context);
468 63
469
            return $container;
470
        }
471 63
472 63
        $xp   = new \DOMXPath($container->ownerDocument);
0 ignored issues
show
It seems like $container->ownerDocument can also be of type null; however, parameter $document of DOMXPath::__construct() does only seem to accept DOMDocument, 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

472
        $xp   = new \DOMXPath(/** @scrutinizer ignore-type */ $container->ownerDocument);
Loading history...
473 63
        $list = $xp->query(
474
            \sprintf('*[@property="%s"]', $context->getAttribute('property')),
475
            $context->parentNode
476 63
        );
477
478
        foreach ($list as $node) {
479 63
            $container->appendChild($node);
480 63
            $this->removeNodeFromCurrentSnapshotList($node);
481
        }
482 63
483 51
        return $container;
484
    }
485 39
486 39
    private function removeNodeFromCurrentSnapshotList(DOMElement $context): void {
487
        $stackList = \end($this->listStack);
488 42
489 42
        if ((!$stackList instanceof SnapshotDOMNodelist) || !$stackList->hasNode($context)) {
490 39
            return;
491
        }
492
        $stackList->removeNode($context);
493 3
    }
494 3
495 3
    private function getElementCount(iterable $model): int {
496 3
        if (\is_array($model) || $model instanceof \Countable) {
497
            return \count($model);
498
        }
499
500
        throw new ViewModelRendererException(
501 9
            \sprintf(
502 9
                'Class %s must implement \Countable to be used as array',
503
                \get_class($model)
0 ignored issues
show
$model of type iterable is incompatible with the type object expected by parameter $object of get_class(). ( Ignorable by Annotation )

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

503
                \get_class(/** @scrutinizer ignore-type */ $model)
Loading history...
504 9
            )
505
        );
506 9
    }
507 9
508 3
    private function addResourceToStack(DOMElement $context): void {
509
        $resource = $context->getAttribute('resource');
510 5
511
        $this->stackNames[] = $resource;
512
513
        foreach ([$resource, 'get' . \ucfirst($resource)] as $method) {
514 6
            if (\method_exists($this->resourceModel, $method)) {
515 3
                $this->stack[] = $this->resourceModel->{$method}();
516
517 3
                return;
518
            }
519
        }
520 3
521 3
        if (\method_exists($this->resourceModel, '__call')) {
522
            $this->stack[] = $this->resourceModel->{$resource}();
523
524
            return;
525 18
        }
526 18
527
        throw new ViewModelRendererException(
528 18
            \sprintf('Resource Viewmodel method missing: $model->%s', \implode('()->', $this->stackNames) . '()')
529 3
        );
530 3
    }
531
532
    private function resolvePrefixDefinition(string $prefixDefinition): void {
533
        $parts = \explode(' ', $prefixDefinition);
534 15
535 15
        if (\count($parts) !== 2) {
536
            throw new ViewModelRendererException(
537 15
                \sprintf('Invalid prefix definition "%s" - must be of format "prefix resourcename"', $prefixDefinition)
538 3
            );
539
        }
540 3
541
        [$prefix, $resource] = $parts;
542
        $prefix              = \rtrim($prefix, ':');
543 12
544 12
        if (\strpos($resource, ':') !== false) {
545 6
            $this->prefixes[$prefix] = null;
546
547 8
            return;
548
        }
549
550
        foreach ([$resource, 'get' . \ucfirst($resource)] as $method) {
551 6
            if (\method_exists($this->resourceModel, $method)) {
552 3
                $this->prefixes[$prefix] = $this->resourceModel->{$method}();
553
554 3
                return;
555
            }
556
        }
557 3
558 3
        if (\method_exists($this->resourceModel, '__call')) {
559
            $this->prefixes[$prefix] = $this->resourceModel->{$resource}();
560
561
            return;
562
        }
563
564
        throw new ViewModelRendererException(
565
            \sprintf('No method %s to resolve prefix %s', $resource, $prefix)
566
        );
567
    }
568
}
569