Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.73% covered (warning)
72.73%
40 / 55
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Wrapper
72.73% covered (warning)
72.73%
40 / 55
50.00% covered (danger)
50.00%
5 / 10
57.45
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 path
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 unwrap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 meta
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 title
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 label
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 children
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
 __get
50.00% covered (danger)
50.00%
6 / 12
0.00% covered (danger)
0.00%
0 / 1
13.12
 __isset
50.00% covered (danger)
50.00%
6 / 12
0.00% covered (danger)
0.00%
0 / 1
10.50
 __call
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Node;
6
7use Cosray\Cms;
8use Cosray\Context;
9use Cosray\Exception\RuntimeException;
10use Cosray\Finder\Nodes;
11use Cosray\Finder\Order;
12use Cosray\Finder\SortField;
13use Cosray\Locale;
14use Cosray\Title\Resolver as TitleResolver;
15use ReflectionClass;
16
17class Wrapper
18{
19    public readonly Meta $meta;
20
21    /** @param list<string> $fieldNames */
22    public function __construct(
23        private readonly object $node,
24        private readonly array $fieldNames,
25        private readonly Types $types,
26        private readonly ?Context $context = null,
27        private readonly ?Cms $cms = null,
28        private readonly ?Factory $nodeFactory = null,
29    ) {
30        $this->meta = new Meta($this->node, $this->types);
31    }
32
33    /**
34     * Resolve the locale-aware URL path for this node.
35     *
36     * Uses the paths stored in Factory's WeakMap data,
37     * walking the locale fallback chain until a path is found.
38     */
39    public function path(?Locale $locale = null): string
40    {
41        $data = Factory::dataFor($this->node);
42        $paths = $data['paths'] ?? [];
43
44        if (!$locale && $this->context) {
45            $locale = $this->context->locale();
46        }
47
48        while ($locale) {
49            if (isset($paths[$locale->id])) {
50                return $paths[$locale->id];
51            }
52
53            $locale = $locale->fallback();
54        }
55
56        throw new RuntimeException('No url path found');
57    }
58
59    /**
60     * Return the inner node if the given object is a Wrapper,
61     * otherwise return the object unchanged.
62     */
63    public static function unwrap(object $object): object
64    {
65        return $object instanceof self ? $object->node : $object;
66    }
67
68    public function meta(string $key, mixed $default = null): mixed
69    {
70        return $this->meta->get($key, $default);
71    }
72
73    public function title(): string
74    {
75        return new TitleResolver($this->types)->resolve(self::unwrap($this->node));
76    }
77
78    /**
79     * The node's title for listings, pickers and other labelling, read from the
80     * materialized title column instead of resolving it again per node.
81     *
82     * Falls back to title() when the stored map has nothing for the active
83     * locale chain: a node saved before the column existed, or a locale added
84     * since the last `db:titles` rebuild.
85     *
86     * A dynamic title composed from *other* nodes can be stale here â€” the map
87     * is rewritten when the node itself is saved, not when its sources change.
88     * Use title() where that matters; rendering still resolves live.
89     */
90    public function label(?Locale $locale = null): string
91    {
92        $map = Factory::dataFor($this->node)['title'] ?? null;
93
94        if (!is_array($map)) {
95            return $this->title();
96        }
97
98        if (!$locale && $this->context) {
99            $locale = $this->context->locale();
100        }
101
102        return new TitleResolver($this->types)->stored($map, $locale) ?? $this->title();
103    }
104
105    public function children(string $query = ''): Nodes
106    {
107        if ($this->context === null || $this->cms === null || $this->nodeFactory === null) {
108            // Finders and the view renderer pass the cms and context, so every
109            // wrapper reaching a template or a node can do this. Only the
110            // title-resolution proxy in Serializer is built without them.
111            throw new RuntimeException('This node proxy was built without cms access');
112        }
113
114        $children = new Nodes($this->context, $this->cms, $this->nodeFactory, $this->types)
115            ->published(null)
116            ->hidden(null)
117            ->childrenOf($this->meta->uid);
118
119        if (trim($query) !== '') {
120            $children->filter($query);
121        }
122
123        // Arranged children come in the editors' order unless the caller
124        // orders them otherwise; order() replaces this.
125        if ($this->meta->type->get('sortableChildren', false) === true) {
126            $children->order(new Order(SortField::position()));
127        }
128
129        return $children;
130    }
131
132    public function __get(string $name): mixed
133    {
134        if (in_array($name, $this->fieldNames, true)) {
135            $value = Factory::fieldFor($this->node, $name)->value();
136
137            return $value->isset() ? $value : null;
138        }
139
140        $embedded = Factory::embeddedFor($this->node, $name);
141
142        if ($embedded !== null) {
143            return $embedded;
144        }
145
146        if (!property_exists($this->node, $name)) {
147            return null;
148        }
149
150        $property = new ReflectionClass($this->node)->getProperty($name);
151
152        return $property->isPublic() && $property->isInitialized($this->node)
153            ? $property->getValue($this->node)
154            : null;
155    }
156
157    public function __isset(string $name): bool
158    {
159        if (in_array($name, $this->fieldNames, true)) {
160            return Factory::fieldFor($this->node, $name)->value()->isset();
161        }
162
163        if (Factory::embeddedFor($this->node, $name) !== null) {
164            return true;
165        }
166
167        if (!property_exists($this->node, $name)) {
168            return false;
169        }
170
171        $property = new ReflectionClass($this->node)->getProperty($name);
172
173        return (
174            $property->isPublic()
175                && $property->isInitialized($this->node)
176                && $property->getValue($this->node) !== null
177        );
178    }
179
180    /** @param array<mixed> $args */
181    public function __call(string $name, array $args): mixed
182    {
183        return $this->node->{$name}(...$args);
184    }
185}