Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.14% covered (success)
97.14%
34 / 35
90.00% covered (success)
90.00%
9 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
SortRequest
97.14% covered (success)
97.14%
34 / 35
90.00% covered (success)
90.00%
9 / 10
21
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 fromRequest
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 validateOrder
85.71% covered (success)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 getField
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOrder
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOrderSql
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasSort
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toSqlOrderBy
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 escapeIdentifier
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 toArray
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * Sort Request Parser
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-11
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Api\Sorting;
21
22use phpMyFAQ\Filter;
23use Symfony\Component\HttpFoundation\Request;
24
25/**
26 * Class SortRequest
27 *
28 * Parses and validates sorting query parameters from HTTP requests.
29 * Provides safe SQL ORDER BY clause generation.
30 */
31class SortRequest
32{
33    private ?string $field;
34
35    private string $order;
36
37    private array $allowedFields;
38
39    /**
40     * Constructor
41     *
42     * @param string|null $field Sort field name
43     * @param string $order Sort order (asc or desc)
44     * @param array $allowedFields Allowed field names for sorting
45     */
46    private function __construct(?string $field, string $order, array $allowedFields)
47    {
48        $this->field = $field;
49        $this->order = $order;
50        $this->allowedFields = $allowedFields;
51    }
52
53    /**
54     * Creates a SortRequest from a Symfony Request object
55     *
56     * @param Request $request The HTTP request
57     * @param array $allowedFields Whitelist of allowed sort fields
58     * @param string|null $defaultField Default sort field if none specified
59     * @param string $defaultOrder Default sort order (asc or desc)
60     * @return self
61     */
62    public static function fromRequest(
63        Request $request,
64        array $allowedFields,
65        ?string $defaultField = null,
66        string $defaultOrder = 'asc',
67    ): self {
68        // Parse sort field from a query
69        $sortField = Filter::filterVar($request->query->get('sort'), FILTER_SANITIZE_SPECIAL_CHARS);
70
71        // Validate sort field against whitelist
72        $field = null;
73        if ($sortField && in_array($sortField, $allowedFields, strict: true)) {
74            $field = $sortField;
75        }
76
77        if ($field === null && $defaultField && in_array($defaultField, $allowedFields, strict: true)) {
78            $field = $defaultField;
79        }
80
81        // Parse sort order
82        $orderParam = Filter::filterVar($request->query->get('order'), FILTER_SANITIZE_SPECIAL_CHARS);
83        $order = self::validateOrder($orderParam, $defaultOrder);
84
85        return new self($field, $order, $allowedFields);
86    }
87
88    /**
89     * Validates sort order value
90     *
91     * @param string|null $order Order value to validate
92     * @param string $default Default order if invalid
93     * @return string Validated order (asc or desc)
94     */
95    private static function validateOrder(?string $order, string $default): string
96    {
97        if ($order === null) {
98            return $default;
99        }
100
101        $orderLower = strtolower($order);
102
103        return match ($orderLower) {
104            'asc', 'ascending' => 'asc',
105            'desc', 'descending' => 'desc',
106            default => $default,
107        };
108    }
109
110    /**
111     * Gets the sort field name
112     *
113     * @return string|null
114     */
115    public function getField(): ?string
116    {
117        return $this->field;
118    }
119
120    /**
121     * Gets the sort order
122     *
123     * @return string
124     */
125    public function getOrder(): string
126    {
127        return $this->order;
128    }
129
130    /**
131     * Gets the uppercase sort order for SQL
132     *
133     * @return string
134     */
135    public function getOrderSql(): string
136    {
137        return strtoupper($this->order);
138    }
139
140    /**
141     * Checks if sorting is active
142     *
143     * @return bool
144     */
145    public function hasSort(): bool
146    {
147        return $this->field !== null;
148    }
149
150    /**
151     * Generates a safe SQL ORDER BY clause
152     *
153     * Returns empty string if no sort field is specified.
154     * Field name is validated against whitelist to prevent SQL injection.
155     *
156     * @return string SQL ORDER BY clause (without "ORDER BY" keyword)
157     */
158    public function toSqlOrderBy(): string
159    {
160        if ($this->field === null) {
161            return '';
162        }
163
164        // Field is already validated against whitelist in constructor
165        // Additional escaping for field name (backticks for MySQL, quotes for others)
166        $escapedField = $this->escapeIdentifier($this->field);
167
168        return sprintf('%s %s', $escapedField, $this->getOrderSql());
169    }
170
171    /**
172     * Escapes a database identifier (table or column name)
173     *
174     * Uses backticks which work for MySQL/MariaDB.
175     * For other databases, this may need to be adjusted.
176     *
177     * @param string $identifier The identifier to escape
178     * @return string Escaped identifier
179     */
180    private function escapeIdentifier(string $identifier): string
181    {
182        // Remove any existing backticks
183        $identifier = str_replace('`', replace: '', subject: $identifier);
184
185        // Wrap in backticks
186        return '`' . $identifier . '`';
187    }
188
189    /**
190     * Converts sort request to array format for API response metadata
191     *
192     * @return array|null
193     */
194    public function toArray(): ?array
195    {
196        if ($this->field === null) {
197            return null;
198        }
199
200        return [
201            'field' => $this->field,
202            'order' => $this->order,
203        ];
204    }
205}