Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
116 / 116
100.00% covered (success)
100.00%
10 / 10
CRAP
100.00% covered (success)
100.00%
1 / 1
NewsRepository
100.00% covered (success)
100.00%
116 / 116
100.00% covered (success)
100.00%
10 / 10
37
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLatest
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 getLatestPaginated
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
8
 countLatest
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 getHeaders
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 getById
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 insert
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
 update
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 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
1<?php
2
3/**
4 * News 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 2025 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     2025-11-09
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\News;
21
22use phpMyFAQ\Configuration;
23use phpMyFAQ\Database;
24use phpMyFAQ\Entity\NewsMessage;
25use stdClass;
26
27/**
28 * Repository for faqnews table access.
29 */
30final readonly class NewsRepository implements NewsRepositoryInterface
31{
32    public function __construct(
33        private Configuration $configuration,
34    ) {
35    }
36
37    /**
38     * Fetch list of news rows for a language, optionally filtered by active flag and limited.
39     * Rows are ordered by datum DESC.
40     *
41     * @return iterable<stdClass>
42     */
43    public function getLatest(string $language, bool $active = true, ?int $limit = null): iterable
44    {
45        $whereActive = $active ? "AND active = 'y'" : '';
46        $query = sprintf(
47            "SELECT * FROM %sfaqnews WHERE lang = '%s' %s ORDER BY datum DESC",
48            Database::getTablePrefix(),
49            $this->configuration->getDb()->escape($language),
50            $whereActive,
51        );
52        if ($limit !== null) {
53            $query .= sprintf(' LIMIT %d', $limit);
54        }
55
56        $result = $this->configuration->getDb()->query($query);
57        while (true) {
58            $row = $this->configuration->getDb()->fetchObject($result);
59            if ($row === false || $row === null || $row === []) {
60                break;
61            }
62
63            yield $row;
64        }
65    }
66
67    /**
68     * Fetch paginated news rows for a language with sorting support.
69     *
70     * @param string $language Language code
71     * @param bool $active Filter by active status
72     * @param int $limit Number of items per page
73     * @param int $offset Starting offset
74     * @param string $sortField Field to sort by (id, datum, header, author_name)
75     * @param string $sortOrder Sort direction (ASC, DESC)
76     * @return iterable<stdClass>
77     */
78    public function getLatestPaginated(
79        string $language,
80        bool $active = true,
81        int $limit = 25,
82        int $offset = 0,
83        string $sortField = 'datum',
84        string $sortOrder = 'DESC',
85    ): iterable {
86        // Whitelist validation for sort field
87        $allowedSortFields = ['id', 'datum', 'header', 'author_name', 'active'];
88        if (!in_array($sortField, $allowedSortFields, strict: true)) {
89            $sortField = 'datum';
90        }
91
92        // Both sort field and direction are interpolated as SQL identifiers, so the direction
93        // must be allowlisted too — never trust a caller-supplied value here.
94        $sortOrder = strtoupper($sortOrder) === 'ASC' ? 'ASC' : 'DESC';
95
96        $whereActive = $active ? "AND active = 'y'" : '';
97        $query = sprintf(
98            "SELECT * FROM %sfaqnews WHERE lang = '%s' %s ORDER BY %s %s LIMIT %d OFFSET %d",
99            Database::getTablePrefix(),
100            $this->configuration->getDb()->escape($language),
101            $whereActive,
102            $sortField,
103            $sortOrder,
104            $limit,
105            $offset,
106        );
107
108        $result = $this->configuration->getDb()->query($query);
109        while (true) {
110            $row = $this->configuration->getDb()->fetchObject($result);
111            if ($row === false || $row === null || $row === []) {
112                break;
113            }
114
115            yield $row;
116        }
117    }
118
119    /**
120     * Count total news entries for a language.
121     *
122     * @param string $language Language code
123     * @param bool $active Filter by active status
124     * @return int Total count
125     */
126    public function countLatest(string $language, bool $active = true): int
127    {
128        $whereActive = $active ? "AND active = 'y'" : '';
129        $query = sprintf(
130            "SELECT COUNT(*) as total FROM %sfaqnews WHERE lang = '%s' %s",
131            Database::getTablePrefix(),
132            $this->configuration->getDb()->escape($language),
133            $whereActive,
134        );
135
136        $result = $this->configuration->getDb()->query($query);
137        $row = $this->configuration->getDb()->fetchObject($result);
138
139        return $row instanceof \stdClass ? (int) ($row->total ?? 0) : 0;
140    }
141
142    /**
143     * Fetch headers for a language.
144     *
145     * @return iterable<stdClass>
146     */
147    public function getHeaders(string $language): iterable
148    {
149        $query = sprintf(
150            "SELECT id, datum, lang, header, active FROM %sfaqnews WHERE lang = '%s' ORDER BY datum DESC",
151            Database::getTablePrefix(),
152            $this->configuration->getDb()->escape($language),
153        );
154        $result = $this->configuration->getDb()->query($query);
155        while (true) {
156            $row = $this->configuration->getDb()->fetchObject($result);
157            if ($row === false || $row === null || $row === []) {
158                break;
159            }
160
161            yield $row;
162        }
163    }
164
165    public function getById(int $newsId, string $language): ?stdClass
166    {
167        $query = sprintf(
168            "SELECT * FROM %sfaqnews WHERE id = %d AND lang = '%s'",
169            Database::getTablePrefix(),
170            $newsId,
171            $this->configuration->getDb()->escape($language),
172        );
173        $result = $this->configuration->getDb()->query($query);
174        $row = $this->configuration->getDb()->fetchObject($result);
175        return $row !== false && $row !== null && $row !== [] ? $row : null;
176    }
177
178    public function insert(NewsMessage $newsMessage): bool
179    {
180        $id = $this->configuration->getDb()->nextId(Database::getTablePrefix() . 'faqnews', column: 'id');
181        $query = sprintf(
182            "\n            INSERT INTO %sfaqnews\n            (id, datum, lang, header, artikel, author_name, author_email, active, comment, link, linktitel, target)\n            VALUES\n            (%d, '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s')",
183            Database::getTablePrefix(),
184            $id,
185            $newsMessage->getCreated()->format(format: 'YmdHis'),
186            $this->configuration->getDb()->escape($newsMessage->getLanguage()),
187            $this->configuration->getDb()->escape($newsMessage->getHeader()),
188            $this->configuration->getDb()->escape($newsMessage->getMessage()),
189            $this->configuration->getDb()->escape($newsMessage->getAuthor()),
190            $this->configuration->getDb()->escape($newsMessage->getEmail()),
191            $newsMessage->isActive() ? 'y' : 'n',
192            $newsMessage->isComment() ? 'y' : 'n',
193            $this->configuration->getDb()->escape($newsMessage->getLink() ?? ''),
194            $this->configuration->getDb()->escape($newsMessage->getLinkTitle() ?? ''),
195            $this->configuration->getDb()->escape($newsMessage->getLinkTarget() ?? ''),
196        );
197        return (bool) $this->configuration->getDb()->query($query);
198    }
199
200    public function update(NewsMessage $newsMessage): bool
201    {
202        $query = sprintf(
203            "\n            UPDATE %sfaqnews SET\n                datum = '%s',\n                lang = '%s',\n                header = '%s',\n                artikel = '%s',\n                author_name = '%s',\n                author_email = '%s',\n                active = '%s',\n                comment = '%s',\n                link = '%s',\n                linktitel = '%s',\n                target = '%s'\n            WHERE id = %d",
204            Database::getTablePrefix(),
205            $newsMessage->getCreated()->format(format: 'YmdHis'),
206            $this->configuration->getDb()->escape($newsMessage->getLanguage()),
207            $this->configuration->getDb()->escape($newsMessage->getHeader()),
208            $this->configuration->getDb()->escape($newsMessage->getMessage()),
209            $this->configuration->getDb()->escape($newsMessage->getAuthor()),
210            $this->configuration->getDb()->escape($newsMessage->getEmail()),
211            $newsMessage->isActive() ? 'y' : 'n',
212            $newsMessage->isComment() ? 'y' : 'n',
213            $this->configuration->getDb()->escape($newsMessage->getLink() ?? ''),
214            $this->configuration->getDb()->escape($newsMessage->getLinkTitle() ?? ''),
215            $this->configuration->getDb()->escape($newsMessage->getLinkTarget() ?? ''),
216            $newsMessage->getId(),
217        );
218        return (bool) $this->configuration->getDb()->query($query);
219    }
220
221    public function delete(int $newsId, string $language): bool
222    {
223        $query = sprintf(
224            "DELETE FROM %sfaqnews WHERE id = %d AND lang = '%s'",
225            Database::getTablePrefix(),
226            $newsId,
227            $this->configuration->getDb()->escape($language),
228        );
229        return (bool) $this->configuration->getDb()->query($query);
230    }
231
232    public function activate(int $newsId, bool $status): bool
233    {
234        $query = sprintf(
235            "UPDATE %sfaqnews SET active = '%s' WHERE id = %d",
236            Database::getTablePrefix(),
237            $status ? 'y' : 'n',
238            $newsId,
239        );
240        return (bool) $this->configuration->getDb()->query($query);
241    }
242}