Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.83% covered (success)
96.83%
122 / 126
87.50% covered (warning)
87.50%
14 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Menus
96.83% covered (success)
96.83%
122 / 126
87.50% covered (warning)
87.50%
14 / 16
43
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
 create
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 update
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 rename
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 delete
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 add
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
8
 updateItem
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 move
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 place
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 remove
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 syncReferences
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 assertMove
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
4.16
 assertDepth
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 itemHeight
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 itemRow
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 nextPosition
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray;
6
7use Celema\Quma\Database;
8use Cosray\Exception\RuntimeException;
9use Cosray\References\Sync;
10use Cosray\Util\Transaction;
11
12/**
13 * Write API for menus and their item trees. Reading and rendering stay
14 * in `Finder\Menu`.
15 *
16 * Item ids are generated uids by default. Explicit ids must not contain
17 * a dot: the read query builds a dotted path from them, so a dot would
18 * corrupt the tree. Item data is the `type`-discriminated jsonb payload
19 * that `Finder\MenuItem` reads; content validation stays with the
20 * caller.
21 *
22 * @api
23 */
24final class Menus
25{
26    private readonly Sync $sync;
27
28    public function __construct(
29        private readonly Database $db,
30        private readonly Uid $uid = new Uid(Uid::ALPHABET_LOWERCASE_WORD_SAFE, 13),
31    ) {
32        $this->sync = new Sync($db);
33    }
34
35    /**
36     * @param array<string, string> $description the description per locale
37     * @param ?int $maxDepth how deep the tree may be built, null for unlimited
38     * @param ?Actor $actor who is writing; the system user when nothing says
39     */
40    public function create(
41        string $menu,
42        array $description,
43        ?int $maxDepth = null,
44        ?Actor $actor = null,
45    ): void {
46        $usr = ($actor ?? Actor::system())->id;
47
48        $this->db->menus->create([
49            'menu' => $menu,
50            'description' => json_encode($description),
51            'maxDepth' => $maxDepth,
52            'creator' => $usr,
53            'editor' => $usr,
54        ])->run();
55    }
56
57    /**
58     * @param array<string, string> $description the description per locale
59     * @param ?int $maxDepth how deep the tree may be built, null for unlimited
60     */
61    public function update(
62        string $menu,
63        array $description,
64        ?int $maxDepth = null,
65        ?Actor $actor = null,
66    ): void {
67        // A limit shallower than the tree would be inert: nothing rejects the
68        // levels that already exist, so refuse it instead of pretending.
69        $height = (int) $this->db->menus->menuHeight(['menu' => $menu])->one()['height'];
70
71        if ($maxDepth !== null && $height > $maxDepth) {
72            throw new RuntimeException(
73                "Menu '{$menu}' is {$height} levels deep and cannot be limited to {$maxDepth}",
74            );
75        }
76
77        $this->db->menus->update([
78            'menu' => $menu,
79            'description' => json_encode($description),
80            'maxDepth' => $maxDepth,
81            'editor' => ($actor ?? Actor::system())->id,
82        ])->run();
83    }
84
85    /**
86     * Renames the menu's handle; the items follow through the FK cascade.
87     * Templates referencing the old handle must be updated by the caller.
88     */
89    public function rename(string $menu, string $to): void
90    {
91        if (!$this->db->menus->exists(['menu' => $menu])->first()) {
92            throw new RuntimeException("Menu '{$menu}' does not exist");
93        }
94
95        $this->db->menus->rename(['menu' => $menu, 'to' => $to])->run();
96    }
97
98    /** Deletes the menu including all of its items. */
99    public function delete(string $menu): void
100    {
101        foreach ($this->db->menus->deleteItems(['menu' => $menu])->all() as $row) {
102            $this->sync->remove('menu', (string) $row['item']);
103        }
104
105        $this->db->menus->delete(['menu' => $menu])->run();
106    }
107
108    /**
109     * Appends an item to the menu, below `parent` or at the root, and
110     * returns its id.
111     *
112     * @param array<string, mixed> $data
113     */
114    public function add(
115        string $menu,
116        array $data,
117        ?string $parent = null,
118        ?string $item = null,
119        bool $hidden = false,
120        ?Actor $actor = null,
121    ): string {
122        if (!is_string($data['type'] ?? null) || $data['type'] === '') {
123            throw new RuntimeException('A menu item needs a type');
124        }
125
126        if (!$this->db->menus->exists(['menu' => $menu])->first()) {
127            throw new RuntimeException("Menu '{$menu}' does not exist");
128        }
129
130        if ($parent !== null && $this->itemRow($parent)['menu'] !== $menu) {
131            throw new RuntimeException(
132                "Parent item '{$parent}' belongs to another menu",
133            );
134        }
135
136        if ($item !== null && str_contains($item, '.')) {
137            throw new RuntimeException('A menu item id must not contain a dot');
138        }
139
140        // A fresh item has no children yet, so it adds exactly one level.
141        $this->assertDepth($menu, $parent, 1);
142
143        $item ??= $this->uid->generate();
144        $usr = ($actor ?? Actor::system())->id;
145
146        $this->db->menus->createItem([
147            'item' => $item,
148            'parent' => $parent,
149            'menu' => $menu,
150            'position' => $this->nextPosition($menu, $parent),
151            'hidden' => $hidden,
152            'data' => json_encode($data),
153            'creator' => $usr,
154            'editor' => $usr,
155        ])->run();
156        $this->syncReferences($item, $data);
157
158        return $item;
159    }
160
161    /** @param array<string, mixed> $data */
162    public function updateItem(
163        string $item,
164        array $data,
165        bool $hidden = false,
166        ?Actor $actor = null,
167    ): void {
168        if (!is_string($data['type'] ?? null) || $data['type'] === '') {
169            throw new RuntimeException('A menu item needs a type');
170        }
171
172        $this->itemRow($item);
173        $this->db->menus->updateItem([
174            'item' => $item,
175            'hidden' => $hidden,
176            'data' => json_encode($data),
177            'editor' => ($actor ?? Actor::system())->id,
178        ])->run();
179        $this->syncReferences($item, $data);
180    }
181
182    /**
183     * Moves the item below `parent` (or to the root), at `position` or
184     * appended to its new siblings. Positions are sort keys, not indexes;
185     * they may repeat, and ties order by item id.
186     */
187    public function move(string $item, ?string $parent, ?int $position = null): void
188    {
189        $row = $this->itemRow($item);
190        $this->assertMove($item, (string) $row['menu'], $parent);
191
192        $this->db->menus->moveItem([
193            'item' => $item,
194            'parent' => $parent,
195            'position' => $position ?? $this->nextPosition((string) $row['menu'], $parent),
196        ])->run();
197    }
198
199    /**
200     * Moves the item below `parent` (or to the root) to the zero-based
201     * `index` among its new siblings and renumbers the whole group 1..n,
202     * giving drag ordering exact semantics on top of `move()`'s looser
203     * sort keys. An out-of-range index clamps to the group's ends.
204     */
205    public function place(string $item, ?string $parent, int $index): void
206    {
207        $row = $this->itemRow($item);
208        $this->assertMove($item, (string) $row['menu'], $parent);
209
210        $siblings = array_column(
211            $this->db->menus->siblings(['menu' => $row['menu'], 'parent' => $parent])->all(),
212            'item',
213        );
214        $siblings = array_values(array_diff($siblings, [$item]));
215        array_splice($siblings, max(0, min($index, count($siblings))), 0, [$item]);
216
217        Transaction::run($this->db, function () use ($siblings, $parent): void {
218            foreach ($siblings as $offset => $sibling) {
219                $this->db->menus->moveItem([
220                    'item' => $sibling,
221                    'parent' => $parent,
222                    'position' => $offset + 1,
223                ])->run();
224            }
225        });
226    }
227
228    /** Deletes the item including all of its descendants. */
229    public function remove(string $item): void
230    {
231        $this->itemRow($item);
232
233        foreach ($this->db->menus->deleteItemTree(['item' => $item])->all() as $row) {
234            $this->sync->remove('menu', (string) $row['item']);
235        }
236    }
237
238    /**
239     * Keeps the derived reference indexes in step with the item: its `image`
240     * icon and `asset` link target on the asset side, its linked node on the
241     * node side. Mirrors what the rebuild derives from stored menu rows, so
242     * "where is this used?" answers for menus too.
243     *
244     * @param array<string, mixed> $data
245     */
246    private function syncReferences(string $item, array $data): void
247    {
248        $assets = [];
249
250        foreach (['image', 'asset'] as $key) {
251            $uid = $data[$key] ?? null;
252
253            if (is_string($uid) && $uid !== '') {
254                $assets[] = $uid;
255            }
256        }
257
258        // `node` and `children` items both store the target's uid; legacy rows
259        // carry a numeric stub there, which the index has no use for.
260        $node = $data['node'] ?? null;
261        $nodes = is_string($node) && $node !== '' ? [$node] : [];
262
263        $this->sync->replace('menu', $item, ['assets' => $assets, 'nodes' => $nodes]);
264    }
265
266    /**
267     * Every precondition a move has to satisfy: the target parent belongs to
268     * the same menu, the move does not create a cycle, and the item's whole
269     * subtree still fits within the menu's `max_depth`.
270     */
271    private function assertMove(string $item, string $menu, ?string $parent): void
272    {
273        if ($parent !== null) {
274            if ($this->itemRow($parent)['menu'] !== $menu) {
275                throw new RuntimeException(
276                    "Parent item '{$parent}' belongs to another menu",
277                );
278            }
279
280            $ancestors = array_column(
281                $this->db->menus->ancestors(['item' => $parent])->all(),
282                'item',
283            );
284
285            if (in_array($item, $ancestors, true)) {
286                throw new RuntimeException(
287                    "Cannot move '{$item}' below its own descendant '{$parent}'",
288                );
289            }
290        }
291
292        // A move carries the item's descendants along, so the subtree's height
293        // decides whether it fits â€” not the item alone.
294        $this->assertDepth($menu, $parent, $this->itemHeight($item));
295    }
296
297    /**
298     * Rejects placing a subtree `$height` levels tall below `$parent` when
299     * that would push its deepest node past the menu's `max_depth`.
300     */
301    private function assertDepth(string $menu, ?string $parent, int $height): void
302    {
303        $max = $this->db->menus->maxDepth(['menu' => $menu])->one()['maxDepth'];
304
305        if ($max === null) {
306            return;
307        }
308
309        // `ancestors` returns the parent plus everything above it, which is
310        // exactly the level the parent sits on; the root is level 0.
311        $depth = $parent === null
312            ? 0
313            : count($this->db->menus->ancestors(['item' => $parent])->all());
314
315        if (($depth + $height) > (int) $max) {
316            throw new RuntimeException(
317                "Menu '{$menu}' allows only {$max} levels",
318            );
319        }
320    }
321
322    private function itemHeight(string $item): int
323    {
324        return (int) $this->db->menus->itemHeight(['item' => $item])->one()['height'];
325    }
326
327    /** @return array<string, mixed> */
328    private function itemRow(string $item): array
329    {
330        $row = $this->db->menus->itemRow(['item' => $item])->first();
331
332        if (!$row) {
333            throw new RuntimeException("Menu item '{$item}' does not exist");
334        }
335
336        return $row;
337    }
338
339    private function nextPosition(string $menu, ?string $parent): int
340    {
341        $row = $this->db->menus->maxPosition(['menu' => $menu, 'parent' => $parent])->one();
342
343        return (int) $row['position'] + 1;
344    }
345}