Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.23% covered (success)
93.23%
124 / 133
81.25% covered (warning)
81.25%
13 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Menus
93.23% covered (success)
93.23%
124 / 133
81.25% covered (warning)
81.25%
13 / 16
47.68
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
78.26% covered (warning)
78.26%
18 / 23
0.00% covered (danger)
0.00%
0 / 1
6.37
 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 Throwable;
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    public function add(
113        string $menu,
114        array $data,
115        ?string $parent = null,
116        ?string $item = null,
117        bool $hidden = false,
118        ?Actor $actor = null,
119    ): string {
120        if (!is_string($data['type'] ?? null) || $data['type'] === '') {
121            throw new RuntimeException('A menu item needs a type');
122        }
123
124        if (!$this->db->menus->exists(['menu' => $menu])->first()) {
125            throw new RuntimeException("Menu '{$menu}' does not exist");
126        }
127
128        if ($parent !== null && $this->itemRow($parent)['menu'] !== $menu) {
129            throw new RuntimeException(
130                "Parent item '{$parent}' belongs to another menu",
131            );
132        }
133
134        if ($item !== null && str_contains($item, '.')) {
135            throw new RuntimeException('A menu item id must not contain a dot');
136        }
137
138        // A fresh item has no children yet, so it adds exactly one level.
139        $this->assertDepth($menu, $parent, 1);
140
141        $item ??= $this->uid->generate();
142        $usr = ($actor ?? Actor::system())->id;
143
144        $this->db->menus->createItem([
145            'item' => $item,
146            'parent' => $parent,
147            'menu' => $menu,
148            'position' => $this->nextPosition($menu, $parent),
149            'hidden' => $hidden,
150            'data' => json_encode($data),
151            'creator' => $usr,
152            'editor' => $usr,
153        ])->run();
154        $this->syncReferences($item, $data);
155
156        return $item;
157    }
158
159    public function updateItem(
160        string $item,
161        array $data,
162        bool $hidden = false,
163        ?Actor $actor = null,
164    ): void {
165        if (!is_string($data['type'] ?? null) || $data['type'] === '') {
166            throw new RuntimeException('A menu item needs a type');
167        }
168
169        $this->itemRow($item);
170        $this->db->menus->updateItem([
171            'item' => $item,
172            'hidden' => $hidden,
173            'data' => json_encode($data),
174            'editor' => ($actor ?? Actor::system())->id,
175        ])->run();
176        $this->syncReferences($item, $data);
177    }
178
179    /**
180     * Moves the item below `parent` (or to the root), at `position` or
181     * appended to its new siblings. Positions are sort keys, not indexes;
182     * they may repeat, and ties order by item id.
183     */
184    public function move(string $item, ?string $parent, ?int $position = null): void
185    {
186        $row = $this->itemRow($item);
187        $this->assertMove($item, (string) $row['menu'], $parent);
188
189        $this->db->menus->moveItem([
190            'item' => $item,
191            'parent' => $parent,
192            'position' => $position ?? $this->nextPosition((string) $row['menu'], $parent),
193        ])->run();
194    }
195
196    /**
197     * Moves the item below `parent` (or to the root) to the zero-based
198     * `index` among its new siblings and renumbers the whole group 1..n,
199     * giving drag ordering exact semantics on top of `move()`'s looser
200     * sort keys. An out-of-range index clamps to the group's ends.
201     */
202    public function place(string $item, ?string $parent, int $index): void
203    {
204        $row = $this->itemRow($item);
205        $this->assertMove($item, (string) $row['menu'], $parent);
206
207        $siblings = array_column(
208            $this->db->menus->siblings(['menu' => $row['menu'], 'parent' => $parent])->all(),
209            'item',
210        );
211        $siblings = array_values(array_diff($siblings, [$item]));
212        array_splice($siblings, max(0, min($index, count($siblings))), 0, [$item]);
213
214        $owns = !$this->db->getConn()->inTransaction();
215
216        if ($owns) {
217            $this->db->begin();
218        }
219
220        try {
221            foreach ($siblings as $offset => $sibling) {
222                $this->db->menus->moveItem([
223                    'item' => $sibling,
224                    'parent' => $parent,
225                    'position' => $offset + 1,
226                ])->run();
227            }
228
229            if ($owns) {
230                $this->db->commit();
231            }
232        } catch (Throwable $e) {
233            if ($owns) {
234                $this->db->rollback();
235            }
236
237            throw $e;
238        }
239    }
240
241    /** Deletes the item including all of its descendants. */
242    public function remove(string $item): void
243    {
244        $this->itemRow($item);
245
246        foreach ($this->db->menus->deleteItemTree(['item' => $item])->all() as $row) {
247            $this->sync->remove('menu', (string) $row['item']);
248        }
249    }
250
251    /**
252     * Keeps the derived reference indexes in step with the item: its `image`
253     * icon and `asset` link target on the asset side, its linked node on the
254     * node side. Mirrors what the rebuild derives from stored menu rows, so
255     * "where is this used?" answers for menus too.
256     */
257    private function syncReferences(string $item, array $data): void
258    {
259        $assets = [];
260
261        foreach (['image', 'asset'] as $key) {
262            $uid = $data[$key] ?? null;
263
264            if (is_string($uid) && $uid !== '') {
265                $assets[] = $uid;
266            }
267        }
268
269        // `node` and `children` items both store the target's uid; legacy rows
270        // carry a numeric stub there, which the index has no use for.
271        $node = $data['node'] ?? null;
272        $nodes = is_string($node) && $node !== '' ? [$node] : [];
273
274        $this->sync->replace('menu', $item, ['assets' => $assets, 'nodes' => $nodes]);
275    }
276
277    /**
278     * Every precondition a move has to satisfy: the target parent belongs to
279     * the same menu, the move does not create a cycle, and the item's whole
280     * subtree still fits within the menu's `max_depth`.
281     */
282    private function assertMove(string $item, string $menu, ?string $parent): void
283    {
284        if ($parent !== null) {
285            if ($this->itemRow($parent)['menu'] !== $menu) {
286                throw new RuntimeException(
287                    "Parent item '{$parent}' belongs to another menu",
288                );
289            }
290
291            $ancestors = array_column(
292                $this->db->menus->ancestors(['item' => $parent])->all(),
293                'item',
294            );
295
296            if (in_array($item, $ancestors, true)) {
297                throw new RuntimeException(
298                    "Cannot move '{$item}' below its own descendant '{$parent}'",
299                );
300            }
301        }
302
303        // A move carries the item's descendants along, so the subtree's height
304        // decides whether it fits â€” not the item alone.
305        $this->assertDepth($menu, $parent, $this->itemHeight($item));
306    }
307
308    /**
309     * Rejects placing a subtree `$height` levels tall below `$parent` when
310     * that would push its deepest node past the menu's `max_depth`.
311     */
312    private function assertDepth(string $menu, ?string $parent, int $height): void
313    {
314        $max = $this->db->menus->maxDepth(['menu' => $menu])->one()['maxDepth'];
315
316        if ($max === null) {
317            return;
318        }
319
320        // `ancestors` returns the parent plus everything above it, which is
321        // exactly the level the parent sits on; the root is level 0.
322        $depth = $parent === null
323            ? 0
324            : count($this->db->menus->ancestors(['item' => $parent])->all());
325
326        if (($depth + $height) > (int) $max) {
327            throw new RuntimeException(
328                "Menu '{$menu}' allows only {$max} levels",
329            );
330        }
331    }
332
333    private function itemHeight(string $item): int
334    {
335        return (int) $this->db->menus->itemHeight(['item' => $item])->one()['height'];
336    }
337
338    private function itemRow(string $item): array
339    {
340        $row = $this->db->menus->itemRow(['item' => $item])->first();
341
342        if (!$row) {
343            throw new RuntimeException("Menu item '{$item}' does not exist");
344        }
345
346        return $row;
347    }
348
349    private function nextPosition(string $menu, ?string $parent): int
350    {
351        $row = $this->db->menus->maxPosition(['menu' => $menu, 'parent' => $parent])->one();
352
353        return (int) $row['position'] + 1;
354    }
355}