Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.36% covered (success)
91.36%
74 / 81
88.89% covered (success)
88.89%
8 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
QueryHelper
91.36% covered (success)
91.36%
74 / 81
88.89% covered (success)
88.89%
8 / 9
34.75
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
 queryPermission
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 queryPermissionExistsAll
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 queryPermissionExistsAny
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 userGrantExists
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 groupGrantExists
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getQuery
80.00% covered (success)
80.00%
28 / 35
0.00% covered (danger)
0.00%
0 / 1
18.05
 getCategoryIdWhereSequence
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 normalizeIdList
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * The query helpers for the FAQ 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 2024-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     2024-03-17
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Faq;
21
22use phpMyFAQ\Category;
23use phpMyFAQ\Configuration;
24use phpMyFAQ\Database;
25use phpMyFAQ\Utils;
26
27readonly class QueryHelper
28{
29    public const string FAQ_SQL_ACTIVE_YES = 'yes';
30
31    public const string FAQ_SQL_ACTIVE_NO = 'no';
32
33    public const string FAQ_QUERY_TYPE_APPROVAL = 'faq_approval';
34
35    public const string FAQ_QUERY_TYPE_EXPORT_PDF = 'faq_export_pdf';
36
37    public const string FAQ_QUERY_TYPE_EXPORT_JSON = 'faq_export_json';
38
39    private Configuration $configuration;
40
41    /**
42     * @param int[] $groups
43     */
44    public function __construct(
45        private int $user,
46        private array $groups,
47    ) {
48        $this->configuration = Configuration::getConfigurationInstance();
49    }
50
51    public function queryPermission(bool $hasGroupSupport = false): string
52    {
53        $groupList = $this->normalizeIdList($this->groups);
54        if ($hasGroupSupport) {
55            if (-1 === $this->user) {
56                return sprintf('AND fdg.group_id IN (%s)', $groupList);
57            }
58
59            return sprintf('AND ( fdu.user_id = %d OR fdg.group_id IN (%s) )', $this->user, $groupList);
60        }
61
62        if (-1 !== $this->user) {
63            return sprintf('AND ( fdu.user_id = %d OR fdu.user_id = -1 )', $this->user);
64        }
65
66        return 'AND fdu.user_id = -1';
67    }
68
69    /**
70     * Permission filter as EXISTS subqueries requiring BOTH a user grant and a group
71     * grant (the effective statistics semantics). Unlike queryPermission(), this does
72     * not depend on LEFT JOINs against faqdata_user/faqdata_group in the outer query,
73     * so it never fans one FAQ out into multiple result rows â€” a prerequisite for SQL LIMIT.
74     */
75    public function queryPermissionExistsAll(bool $hasGroupSupport = false): string
76    {
77        $clause = 'AND ' . $this->userGrantExists();
78
79        if ($hasGroupSupport) {
80            $clause .= ' AND ' . $this->groupGrantExists();
81        }
82
83        return $clause;
84    }
85
86    /**
87     * Permission filter as EXISTS subqueries with the same semantics as queryPermission()
88     * (a user grant OR a group grant suffices), but without the LEFT JOIN fanout, so
89     * result rows stay one-per-FAQ and SQL LIMIT/COUNT are reliable.
90     */
91    public function queryPermissionExistsAny(bool $hasGroupSupport = false): string
92    {
93        if (!$hasGroupSupport) {
94            return 'AND ' . $this->userGrantExists();
95        }
96
97        if (-1 === $this->user) {
98            return 'AND ' . $this->groupGrantExists();
99        }
100
101        return sprintf(
102            'AND (EXISTS (SELECT 1 FROM %sfaqdata_user pfdu '
103            . 'WHERE pfdu.record_id = fd.id AND pfdu.user_id = %d) OR %s)',
104            Database::getTablePrefix(),
105            $this->user,
106            $this->groupGrantExists(),
107        );
108    }
109
110    private function userGrantExists(): string
111    {
112        $userIds = -1 === $this->user ? '-1' : sprintf('-1, %d', $this->user);
113
114        return sprintf(
115            'EXISTS (SELECT 1 FROM %sfaqdata_user pfdu WHERE pfdu.record_id = fd.id AND pfdu.user_id IN (%s))',
116            Database::getTablePrefix(),
117            $userIds,
118        );
119    }
120
121    private function groupGrantExists(): string
122    {
123        return sprintf(
124            'EXISTS (SELECT 1 FROM %sfaqdata_group pfdg WHERE pfdg.record_id = fd.id AND pfdg.group_id IN (%s))',
125            Database::getTablePrefix(),
126            $this->normalizeIdList($this->groups),
127        );
128    }
129
130    /**
131     * Build the SQL query for retrieving FAQ records according to the constraints provided.
132     */
133    public function getQuery(
134        string $queryType,
135        int $categoryId,
136        bool $bDownwards,
137        string $lang,
138        string $date,
139        int $faqId = 0,
140    ): string {
141        $query = sprintf(
142            '
143            SELECT
144                fd.id AS id,
145                fd.solution_id AS solution_id,
146                fd.revision_id AS revision_id,
147                fd.lang AS lang,
148                fcr.category_id AS category_id,
149                fd.active AS active,
150                fd.sticky AS sticky,
151                fd.keywords AS keywords,
152                fd.thema AS thema,
153                fd.content AS content,
154                fd.author AS author,
155                fd.email AS email,
156                fd.comment AS comment,
157                fd.updated AS updated,
158                fd.notes AS notes,
159                fv.visits AS visits,
160                fv.last_visit AS last_visit
161            FROM
162                %sfaqdata fd,
163                %sfaqvisits fv,
164                %sfaqcategoryrelations fcr
165            WHERE
166                fd.id = fcr.record_id
167            AND
168                fd.lang = fcr.record_lang
169            AND ',
170            Database::getTablePrefix(),
171            Database::getTablePrefix(),
172            Database::getTablePrefix(),
173        );
174        // faqvisits data selection
175        if ($faqId !== 0) {
176            // Select ONLY the faq with the provided $faqid
177            $query .= "fd.id = '" . $faqId . "' AND ";
178        }
179
180        $query .= 'fd.id = fv.id
181            AND
182                fd.lang = fv.lang';
183
184        if ($categoryId > 0) {
185            $query .= ' AND';
186            $query .= ' (fcr.category_id = ' . $categoryId;
187            if ($bDownwards) {
188                $query .= $this->getCategoryIdWhereSequence($categoryId);
189            }
190
191            $query .= ')';
192        }
193
194        if ($date !== '' && $date !== '0' && Utils::isLikeOnPMFDate($date)) {
195            $query .= ' AND';
196            $query .= " fd.updated LIKE '" . $date . "'";
197        }
198
199        if ($lang !== '' && $lang !== '0' && Utils::isLanguage($lang)) {
200            $query .= ' AND';
201            $query .= " fd.lang = '" . $this->configuration->getDb()->escape($lang) . "'";
202        }
203
204        switch ($queryType) {
205            case self::FAQ_QUERY_TYPE_APPROVAL:
206                $query .= ' AND';
207                $query .= " fd.active = '" . self::FAQ_SQL_ACTIVE_NO . "'";
208                break;
209            case self::FAQ_QUERY_TYPE_EXPORT_PDF:
210            case self::FAQ_QUERY_TYPE_EXPORT_JSON:
211            default:
212                $query .= ' AND';
213                $query .= " fd.active = '" . self::FAQ_SQL_ACTIVE_YES . "'";
214                break;
215        }
216
217        match ($queryType) {
218            self::FAQ_QUERY_TYPE_EXPORT_PDF,
219            self::FAQ_QUERY_TYPE_EXPORT_JSON,
220                => $query .= "\nORDER BY fcr.category_id, fd.id",
221            default => $query .= "\nORDER BY fcr.category_id, fd.id",
222        };
223
224        return $query;
225    }
226
227    /**
228     * Build a logic sequence, for a WHERE statement, of those category IDs
229     * children of the provided category ID, if any.
230     */
231    private function getCategoryIdWhereSequence(int $categoryId, ?Category $category = null): string
232    {
233        $sqlWhereFilter = '';
234
235        if ($category === null) {
236            $category = new Category($this->configuration);
237        }
238
239        $aChildren = array_values($category->getChildren($categoryId));
240
241        foreach ($aChildren as $aChild) {
242            $childCategoryId = (int) $aChild;
243            $sqlWhereFilter .= ' OR fcr.category_id = ' . $childCategoryId;
244            $sqlWhereFilter .= $this->getCategoryIdWhereSequence($childCategoryId, $category);
245        }
246
247        return $sqlWhereFilter;
248    }
249
250    /**
251     * @param array<int|string> $ids
252     */
253    private function normalizeIdList(array $ids): string
254    {
255        $normalizedIds = array_map(static fn($id): int => (int) $id, $ids);
256
257        return $normalizedIds === [] ? '-1' : implode(', ', $normalizedIds);
258    }
259}