Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.28% covered (success)
98.28%
114 / 116
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Order
98.28% covered (success)
98.28%
114 / 116
75.00% covered (warning)
75.00%
6 / 8
37
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
 add
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 remove
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 setCategoryTree
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
9
 getCategoryTree
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 getParentId
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
7.02
 getOrderedFlatList
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
8
 getAllCategories
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3/**
4 * The category order class.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2020-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2020-09-06
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Category;
21
22use phpMyFAQ\Configuration;
23use phpMyFAQ\Database;
24use stdClass;
25
26/**
27 * Class CategoryOrder
28 *
29 * @package phpMyFAQ\Category
30 */
31readonly class Order
32{
33    /**
34     * Constructor.
35     */
36    public function __construct(
37        private Configuration $configuration,
38    ) {
39    }
40
41    /**
42     * Adds a given category ID to the last position.
43     */
44    public function add(int $categoryId, int $parentId): bool
45    {
46        $query = sprintf(
47            'INSERT INTO %sfaqcategory_order (category_id, parent_id, position) VALUES (%d, %d, %d)',
48            Database::getTablePrefix(),
49            $categoryId,
50            $parentId,
51            $this->configuration->getDb()->nextId(Database::getTablePrefix() . 'faqcategory_order', 'position'),
52        );
53
54        return (bool) $this->configuration->getDb()->query($query);
55    }
56
57    /**
58     * Deletes a given category ID.
59     */
60    public function remove(int $categoryId): bool
61    {
62        $query = sprintf(
63            'DELETE FROM %sfaqcategory_order WHERE category_id = %d',
64            Database::getTablePrefix(),
65            $categoryId,
66        );
67
68        return (bool) $this->configuration->getDb()->query($query);
69    }
70
71    /**
72     * Stores the category tree in the database.
73     *
74     * @param stdClass[] $categoryTree
75     * @param string[] $insertQueries
76     */
77    public function setCategoryTree(
78        array $categoryTree,
79        ?int $parentId = null,
80        int $position = 1,
81        array &$insertQueries = [],
82    ): void {
83        // Clear the existing category order table
84        if ($parentId === null) {
85            $this->configuration
86                ->getDb()
87                ->query(sprintf('DELETE FROM %sfaqcategory_order', Database::getTablePrefix()));
88        }
89
90        foreach ($categoryTree as $category) {
91            $id = (int) $category->id;
92
93            if ($id > 0) {
94                $insertQueries[] = sprintf(
95                    'INSERT INTO %sfaqcategory_order(category_id, parent_id, position) VALUES (%d, %d, %d)',
96                    Database::getTablePrefix(),
97                    $id,
98                    $parentId,
99                    $position,
100                );
101
102                $children = property_exists($category, 'children') && is_array($category->children)
103                    ? array_values(array_filter(
104                        $category->children,
105                        static fn(mixed $child): bool => $child instanceof stdClass,
106                    ))
107                    : [];
108                if ($children !== []) {
109                    // Pass the same reference of $insertQueries to the recursive call
110                    $this->setCategoryTree($children, $id, 1, $insertQueries);
111                }
112
113                ++$position;
114            }
115        }
116
117        // Execute queries only on the top-level call
118        if ($parentId === null) {
119            foreach ($insertQueries as $insertQuery) {
120                $this->configuration->getDb()->query($insertQuery);
121            }
122        }
123    }
124
125    /**
126     * Returns the category tree.
127     *
128     * @param array<array-key, array{category_id: int|string, parent_id: int|string, position?: int|string}> $categories
129     * @param array<int, bool> $visited Array to track visited category IDs to prevent infinite recursion
130     */
131    public function getCategoryTree(array $categories, int $parentId = 0, array &$visited = []): array
132    {
133        $result = [];
134
135        foreach ($categories as $category) {
136            $categoryId = (int) $category['category_id'];
137            $categoryParentId = (int) $category['parent_id'];
138
139            // Skip if category is its own parent or creates a cycle
140            if ($categoryId === $categoryParentId) {
141                continue;
142            }
143
144            if ($categoryParentId === $parentId) {
145                // Check if this category has already been visited to prevent cycles
146                if (array_key_exists($categoryId, $visited)) {
147                    continue;
148                }
149
150                // Add the current category to a visited list
151                $visited[$categoryId] = true;
152
153                $childCategories = $this->getCategoryTree($categories, $categoryId, $visited);
154                $result[$categoryId] = $childCategories;
155            }
156        }
157
158        return $result;
159    }
160
161    /**
162     * Returns the parent ID of a given categoryTree.
163     *
164     * @param stdClass[] $categoryTree
165     */
166    public function getParentId(array $categoryTree, int $categoryId, ?int $parentId = null): ?int
167    {
168        foreach ($categoryTree as $category) {
169            if ((int) $category->id === $categoryId) {
170                return (int) $parentId;
171            }
172
173            $children = property_exists($category, 'children') && is_array($category->children)
174                ? array_values(array_filter(
175                    $category->children,
176                    static fn(mixed $child): bool => $child instanceof stdClass,
177                ))
178                : [];
179            if ($children !== []) {
180                $foundParentId = $this->getParentId($children, $categoryId, (int) $category->id);
181                if ($foundParentId !== null) {
182                    return $foundParentId;
183                }
184            }
185        }
186
187        return null;
188    }
189
190    /**
191     * Returns the given categories as a flat list in depth-first tree order:
192     * every subcategory directly follows its parent and carries its nesting
193     * depth in a `level` entry. Sibling order follows the stored category
194     * order; categories missing from it keep their relative position at the
195     * end, and categories whose parent is not part of the list are appended
196     * as roots.
197     *
198     * @param array<int, array<array-key, mixed>> $categories flat category list, each entry with `id` and `parent_id`
199     * @return array<int, array<array-key, mixed>> the same entries, depth-first, each with a `level` entry
200     */
201    public function getOrderedFlatList(array $categories): array
202    {
203        $sequence = [];
204        foreach ($this->getAllCategories() as $index => $orderRow) {
205            $sequence[(int) $orderRow['category_id']] = $index;
206        }
207
208        $categories = array_values($categories);
209        if ($sequence !== []) {
210            usort(
211                $categories,
212                static fn(array $a, array $b): int => (
213                    ($sequence[(int) $a['id']] ?? PHP_INT_MAX) <=> ($sequence[(int) $b['id']] ?? PHP_INT_MAX)
214                ),
215            );
216        }
217
218        $ordered = [];
219        $listed = [];
220        $appendChildren = static function (int $parentId, int $level) use (
221            &$appendChildren,
222            $categories,
223            &$ordered,
224            &$listed,
225        ): void {
226            foreach ($categories as $category) {
227                if ((int) $category['parent_id'] !== $parentId || array_key_exists((int) $category['id'], $listed)) {
228                    continue;
229                }
230
231                $listed[(int) $category['id']] = true;
232                $ordered[] = [...$category, 'level' => $level];
233                $appendChildren((int) $category['id'], $level + 1);
234            }
235        };
236        $appendChildren(parentId: 0, level: 0);
237
238        foreach ($categories as $category) {
239            if (array_key_exists((int) $category['id'], $listed)) {
240                continue;
241            }
242
243            $ordered[] = [...$category, 'level' => 0];
244        }
245
246        return $ordered;
247    }
248
249    /**
250     * Returns all categories.
251     *
252     * @return array<int, array{category_id: int, parent_id: int, position: int}>
253     */
254    public function getAllCategories(): array
255    {
256        $query = sprintf(
257            'SELECT category_id, parent_id, position FROM %sfaqcategory_order ORDER BY position',
258            Database::getTablePrefix(),
259        );
260        $result = $this->configuration->getDb()->query($query);
261
262        $categories = [];
263
264        while (true) {
265            $row = $this->configuration->getDb()->fetchArray($result);
266            if ($row === false || $row === null || $row === []) {
267                break;
268            }
269
270            $categories[] = [
271                'category_id' => (int) $row['category_id'],
272                'parent_id' => (int) $row['parent_id'],
273                'position' => (int) $row['position'],
274            ];
275        }
276
277        return $categories;
278    }
279}