Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.51% covered (success)
99.51%
203 / 204
93.33% covered (success)
93.33%
14 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
CustomPageRepository
99.51% covered (success)
99.51%
203 / 204
93.33% covered (success)
93.33%
14 / 15
55
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
 getAll
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 getAllPaginated
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
6
 getAllLanguagesPaginated
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
6
 countAll
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 countAllLanguages
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getById
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 getBySlug
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 getExistingLanguages
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 insert
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
6
 insertTranslation
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
7
 update
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
6
 delete
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 activate
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 slugExists
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * Custom Page repository 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 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     2026-01-12
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\CustomPage;
21
22use phpMyFAQ\Configuration;
23use phpMyFAQ\Database;
24use phpMyFAQ\Entity\CustomPageEntity;
25use stdClass;
26
27/**
28 * Repository for "faqcustompages" table access.
29 */
30final readonly class CustomPageRepository implements CustomPageRepositoryInterface
31{
32    public function __construct(
33        private Configuration $configuration,
34    ) {
35    }
36
37    /**
38     * Fetch all custom pages for a language, optionally filtered by active status.
39     *
40     * @return iterable<stdClass>
41     */
42    public function getAll(string $language, bool $activeOnly = false): iterable
43    {
44        $whereActive = $activeOnly ? "AND active = 'y'" : '';
45        $query = sprintf(
46            "SELECT * FROM %sfaqcustompages WHERE lang = '%s' %s ORDER BY created DESC",
47            Database::getTablePrefix(),
48            $this->configuration->getDb()->escape($language),
49            $whereActive,
50        );
51
52        $result = $this->configuration->getDb()->query($query);
53        while (true) {
54            $row = $this->configuration->getDb()->fetchObject($result);
55            if (!$row instanceof \stdClass) {
56                break;
57            }
58
59            yield $row;
60        }
61    }
62
63    /**
64     * Fetch paginated custom pages for a language with sorting support.
65     *
66     * @param string $language Language code
67     * @param bool $activeOnly Filter by active status
68     * @param int $limit Number of items per page
69     * @param int $offset Starting offset
70     * @param string $sortField Field to sort by (id, page_title, slug, created, updated)
71     * @param string $sortOrder Sort direction (ASC, DESC)
72     * @return iterable<stdClass>
73     */
74    public function getAllPaginated(
75        string $language,
76        bool $activeOnly,
77        int $limit,
78        int $offset,
79        string $sortField,
80        string $sortOrder,
81    ): iterable {
82        // Whitelist validation for the sort field
83        $allowedSortFields = ['id', 'page_title', 'slug', 'created', 'updated', 'active'];
84        if (!in_array($sortField, $allowedSortFields, strict: true)) {
85            $sortField = 'created';
86        }
87
88        // Validate sort order
89        $sortOrder = strtoupper($sortOrder);
90        if (!in_array($sortOrder, ['ASC', 'DESC'], strict: true)) {
91            $sortOrder = 'DESC';
92        }
93
94        $whereActive = $activeOnly ? "AND active = 'y'" : '';
95        $query = sprintf(
96            "SELECT * FROM %sfaqcustompages WHERE lang = '%s' %s ORDER BY %s %s LIMIT %d OFFSET %d",
97            Database::getTablePrefix(),
98            $this->configuration->getDb()->escape($language),
99            $whereActive,
100            $sortField,
101            $sortOrder,
102            $limit,
103            $offset,
104        );
105
106        $result = $this->configuration->getDb()->query($query);
107        while (true) {
108            $row = $this->configuration->getDb()->fetchObject($result);
109            if (!$row instanceof \stdClass) {
110                break;
111            }
112
113            yield $row;
114        }
115    }
116
117    /**
118     * Get all custom pages across all languages with pagination.
119     *
120     * @param bool $activeOnly Filter by active status
121     * @param int $limit Number of items per page
122     * @param int $offset Starting offset
123     * @param string $sortField Field to sort by
124     * @param string $sortOrder Sort direction (ASC, DESC)
125     * @return iterable<stdClass> Generator of page data
126     */
127    public function getAllLanguagesPaginated(
128        bool $activeOnly,
129        int $limit,
130        int $offset,
131        string $sortField,
132        string $sortOrder,
133    ): iterable {
134        // Validate sort field
135        $allowedSortFields = ['id', 'lang', 'page_title', 'slug', 'created', 'updated'];
136        if (!in_array($sortField, $allowedSortFields, strict: true)) {
137            $sortField = 'created';
138        }
139
140        // Validate sort order
141        $sortOrder = strtoupper($sortOrder);
142        if (!in_array($sortOrder, ['ASC', 'DESC'], strict: true)) {
143            $sortOrder = 'DESC';
144        }
145
146        $whereActive = $activeOnly ? "WHERE active = 'y'" : '';
147        $query = sprintf(
148            'SELECT * FROM %sfaqcustompages %s ORDER BY %s %s LIMIT %d OFFSET %d',
149            Database::getTablePrefix(),
150            $whereActive,
151            $sortField,
152            $sortOrder,
153            $limit,
154            $offset,
155        );
156
157        $result = $this->configuration->getDb()->query($query);
158        while (true) {
159            $row = $this->configuration->getDb()->fetchObject($result);
160            if (!$row instanceof \stdClass) {
161                break;
162            }
163
164            yield $row;
165        }
166    }
167
168    /**
169     * Count total custom pages for a language.
170     *
171     * @param string $language Language code
172     * @param bool $activeOnly Filter by active status
173     * @return int Total count
174     */
175    public function countAll(string $language, bool $activeOnly = false): int
176    {
177        $whereActive = $activeOnly ? "AND active = 'y'" : '';
178        $query = sprintf(
179            "SELECT COUNT(*) as total FROM %sfaqcustompages WHERE lang = '%s' %s",
180            Database::getTablePrefix(),
181            $this->configuration->getDb()->escape($language),
182            $whereActive,
183        );
184
185        $result = $this->configuration->getDb()->query($query);
186        $row = $this->configuration->getDb()->fetchObject($result);
187
188        return $row instanceof \stdClass ? (int) ($row->total ?? 0) : 0;
189    }
190
191    /**
192     * Count total custom pages across all languages.
193     *
194     * @param bool $activeOnly Filter by active status
195     * @return int Total count
196     */
197    public function countAllLanguages(bool $activeOnly = false): int
198    {
199        $whereActive = $activeOnly ? "WHERE active = 'y'" : '';
200        $query = sprintf('SELECT COUNT(*) as total FROM %sfaqcustompages %s', Database::getTablePrefix(), $whereActive);
201
202        $result = $this->configuration->getDb()->query($query);
203        $row = $this->configuration->getDb()->fetchObject($result);
204
205        return $row instanceof \stdClass ? (int) ($row->total ?? 0) : 0;
206    }
207
208    /**
209     * Fetch a custom page by ID and language.
210     *
211     * @param int $pageId Page ID
212     * @param string $language Language code
213     * @return stdClass|null Page data or null if not found
214     */
215    public function getById(int $pageId, string $language): ?stdClass
216    {
217        $query = sprintf(
218            "SELECT * FROM %sfaqcustompages WHERE id = %d AND lang = '%s'",
219            Database::getTablePrefix(),
220            $pageId,
221            $this->configuration->getDb()->escape($language),
222        );
223        $result = $this->configuration->getDb()->query($query);
224        $row = $this->configuration->getDb()->fetchObject($result);
225        return $row === false ? null : $row;
226    }
227
228    /**
229     * Fetch a custom page by slug and language.
230     *
231     * @param string $slug URL slug
232     * @param string $language Language code
233     * @return stdClass|null Page data or null if not found
234     */
235    public function getBySlug(string $slug, string $language): ?stdClass
236    {
237        $query = sprintf(
238            "SELECT * FROM %sfaqcustompages WHERE slug = '%s' AND lang = '%s'",
239            Database::getTablePrefix(),
240            $this->configuration->getDb()->escape($slug),
241            $this->configuration->getDb()->escape($language),
242        );
243        $result = $this->configuration->getDb()->query($query);
244        $row = $this->configuration->getDb()->fetchObject($result);
245        return $row === false ? null : $row;
246    }
247
248    /**
249     * Get all existing languages for a given page ID.
250     *
251     * @param int $pageId Page ID
252     * @return array<string> Array of language codes
253     */
254    public function getExistingLanguages(int $pageId): array
255    {
256        $query = sprintf(
257            'SELECT lang FROM %sfaqcustompages WHERE id = %d ORDER BY lang',
258            Database::getTablePrefix(),
259            $pageId,
260        );
261        $result = $this->configuration->getDb()->query($query);
262        $languages = [];
263        while (true) {
264            $row = $this->configuration->getDb()->fetchObject($result);
265            if (!$row instanceof \stdClass) {
266                break;
267            }
268
269            $languages[] = (string) $row->lang;
270        }
271        return $languages;
272    }
273
274    /**
275     * Insert a new custom page.
276     *
277     * @param CustomPageEntity $page Custom page entity
278     * @return int The new page ID
279     */
280    public function insert(CustomPageEntity $page): int
281    {
282        $id = $this->configuration->getDb()->nextId(Database::getTablePrefix() . 'faqcustompages', column: 'id');
283        $query = sprintf(
284            "
285            INSERT INTO %sfaqcustompages
286            (id, lang, page_title, slug, content, author_name, author_email, active, created, updated, seo_title, seo_description, seo_robots)
287            VALUES
288            (%d, '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', %s, %s, %s, '%s')",
289            Database::getTablePrefix(),
290            $id,
291            $this->configuration->getDb()->escape($page->getLanguage()),
292            $this->configuration->getDb()->escape($page->getPageTitle()),
293            $this->configuration->getDb()->escape($page->getSlug()),
294            $this->configuration->getDb()->escape($page->getContent()),
295            $this->configuration->getDb()->escape($page->getAuthorName()),
296            $this->configuration->getDb()->escape($page->getAuthorEmail()),
297            $page->isActive() ? 'y' : 'n',
298            $page->getCreated()->format(format: 'Y-m-d H:i:s'),
299            'NULL',
300            ($seoTitle = $page->getSeoTitle()) !== null && $seoTitle !== ''
301                ? "'" . $this->configuration->getDb()->escape($seoTitle) . "'"
302                : 'NULL',
303            ($seoDescription = $page->getSeoDescription()) !== null && $seoDescription !== ''
304                ? "'" . $this->configuration->getDb()->escape($seoDescription) . "'"
305                : 'NULL',
306            $this->configuration->getDb()->escape($page->getSeoRobots()),
307        );
308        $this->configuration->getDb()->query($query);
309        $page->setId($id);
310        return $id;
311    }
312
313    /**
314     * Insert a translation for an existing custom page (using same ID, different language).
315     *
316     * @param CustomPageEntity $page Custom page entity
317     * @param int $pageId The existing page ID to use
318     * @return bool Success status
319     */
320    public function insertTranslation(CustomPageEntity $page, int $pageId): bool
321    {
322        $query = sprintf(
323            "
324            INSERT INTO %sfaqcustompages
325            (id, lang, page_title, slug, content, author_name, author_email, active, created, updated, seo_title, seo_description, seo_robots)
326            VALUES
327            (%d, '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', %s, %s, %s, '%s')",
328            Database::getTablePrefix(),
329            $pageId,
330            $this->configuration->getDb()->escape($page->getLanguage()),
331            $this->configuration->getDb()->escape($page->getPageTitle()),
332            $this->configuration->getDb()->escape($page->getSlug()),
333            $this->configuration->getDb()->escape($page->getContent()),
334            $this->configuration->getDb()->escape($page->getAuthorName()),
335            $this->configuration->getDb()->escape($page->getAuthorEmail()),
336            $page->isActive() ? 'y' : 'n',
337            $page->getCreated()->format(format: 'Y-m-d H:i:s'),
338            'NULL',
339            ($seoTitle = $page->getSeoTitle()) !== null && $seoTitle !== ''
340                ? "'" . $this->configuration->getDb()->escape($seoTitle) . "'"
341                : 'NULL',
342            ($seoDescription = $page->getSeoDescription()) !== null && $seoDescription !== ''
343                ? "'" . $this->configuration->getDb()->escape($seoDescription) . "'"
344                : 'NULL',
345            $this->configuration->getDb()->escape($page->getSeoRobots()),
346        );
347
348        $result = $this->configuration->getDb()->query($query);
349        if ($result) {
350            $page->setId($pageId);
351        }
352        return (bool) $result;
353    }
354
355    /**
356     * Update an existing custom page.
357     *
358     * @param CustomPageEntity $page Custom page entity
359     * @return bool Success status
360     */
361    public function update(CustomPageEntity $page): bool
362    {
363        $query = sprintf(
364            "
365            UPDATE %sfaqcustompages SET
366                page_title = '%s',
367                slug = '%s',
368                content = '%s',
369                author_name = '%s',
370                author_email = '%s',
371                active = '%s',
372                updated = '%s',
373                seo_title = %s,
374                seo_description = %s,
375                seo_robots = '%s'
376            WHERE id = %d AND lang = '%s'",
377            Database::getTablePrefix(),
378            $this->configuration->getDb()->escape($page->getPageTitle()),
379            $this->configuration->getDb()->escape($page->getSlug()),
380            $this->configuration->getDb()->escape($page->getContent()),
381            $this->configuration->getDb()->escape($page->getAuthorName()),
382            $this->configuration->getDb()->escape($page->getAuthorEmail()),
383            $page->isActive() ? 'y' : 'n',
384            $page->getUpdated()?->format(format: 'Y-m-d H:i:s') ?? date('Y-m-d H:i:s'),
385            ($seoTitle = $page->getSeoTitle()) !== null && $seoTitle !== ''
386                ? "'" . $this->configuration->getDb()->escape($seoTitle) . "'"
387                : 'NULL',
388            ($seoDescription = $page->getSeoDescription()) !== null && $seoDescription !== ''
389                ? "'" . $this->configuration->getDb()->escape($seoDescription) . "'"
390                : 'NULL',
391            $this->configuration->getDb()->escape($page->getSeoRobots()),
392            $page->getId(),
393            $this->configuration->getDb()->escape($page->getLanguage()),
394        );
395        return (bool) $this->configuration->getDb()->query($query);
396    }
397
398    /**
399     * Delete a custom page.
400     *
401     * @param int $pageId Page ID
402     * @param string $language Language code
403     * @return bool Success status
404     */
405    public function delete(int $pageId, string $language): bool
406    {
407        $query = sprintf(
408            "DELETE FROM %sfaqcustompages WHERE id = %d AND lang = '%s'",
409            Database::getTablePrefix(),
410            $pageId,
411            $this->configuration->getDb()->escape($language),
412        );
413        return (bool) $this->configuration->getDb()->query($query);
414    }
415
416    /**
417     * Activate or deactivate a custom page.
418     *
419     * @param int $pageId Page ID
420     * @param bool $status Active status
421     * @return bool Success status
422     */
423    public function activate(int $pageId, bool $status): bool
424    {
425        $query = sprintf(
426            "UPDATE %sfaqcustompages SET active = '%s' WHERE id = %d",
427            Database::getTablePrefix(),
428            $status ? 'y' : 'n',
429            $pageId,
430        );
431        return (bool) $this->configuration->getDb()->query($query);
432    }
433
434    /**
435     * Check if a slug already exists for a language.
436     *
437     * @param string $slug URL slug
438     * @param string $language Language code
439     * @param int|null $excludeId Optional page ID to exclude from check (for updates)
440     * @return bool True if slug exists
441     */
442    public function slugExists(string $slug, string $language, ?int $excludeId = null): bool
443    {
444        $excludeCondition = $excludeId !== null ? sprintf('AND id != %d', $excludeId) : '';
445        $query = sprintf(
446            "SELECT COUNT(*) as total FROM %sfaqcustompages WHERE slug = '%s' AND lang = '%s' %s",
447            Database::getTablePrefix(),
448            $this->configuration->getDb()->escape($slug),
449            $this->configuration->getDb()->escape($language),
450            $excludeCondition,
451        );
452
453        $result = $this->configuration->getDb()->query($query);
454        $row = $this->configuration->getDb()->fetchObject($result);
455
456        return $row instanceof \stdClass && (int) ($row->total ?? 0) > 0;
457    }
458}