Lines 98.27% 114 / 116
Methods 75.00% 6 / 8
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 add 100.00% 8 / 8 100.00% 1 / 1 1
 remove 100.00% 6 / 6 100.00% 1 / 1 1
 setCategoryTree 96.15% 25 / 26 0.00% 0 / 1 9
 getCategoryTree 100.00% 13 / 13 100.00% 1 / 1 5
 getParentId 92.85% 13 / 14 0.00% 0 / 1 7.02
 getOrderedFlatList 100.00% 32 / 32 100.00% 1 / 1 8
 getAllCategories 100.00% 16 / 16 100.00% 1 / 1 5
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}