Lines 97.14% 34 / 35
Methods 90.00% 9 / 10
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 3 / 3 100.00% 1 / 1 1
 fromRequest 100.00% 9 / 9 100.00% 1 / 1 6
 validateOrder 85.71% 6 / 7 0.00% 0 / 1 5.07
 getField 100.00% 1 / 1 100.00% 1 / 1 1
 getOrder 100.00% 1 / 1 100.00% 1 / 1 1
 getOrderSql 100.00% 1 / 1 100.00% 1 / 1 1
 hasSort 100.00% 1 / 1 100.00% 1 / 1 1
 toSqlOrderBy 100.00% 4 / 4 100.00% 1 / 1 2
 escapeIdentifier 100.00% 2 / 2 100.00% 1 / 1 1
 toArray 100.00% 6 / 6 100.00% 1 / 1 2
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}