Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.05% covered (warning)
85.05%
91 / 107
28.57% covered (danger)
28.57%
2 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
SearchQueryParser
84.91% covered (warning)
84.91%
90 / 106
28.57% covered (danger)
28.57%
2 / 7
57.26
0.00% covered (danger)
0.00%
0 / 1
 parse
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
9
 buildCondition
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 buildExactPhraseClause
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 buildWordsAndClause
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
7.02
 buildWordsOrClause
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
7.03
 buildPartialClause
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
7.02
 buildSmartClause
53.85% covered (warning)
53.85%
14 / 26
0.00% covered (danger)
0.00%
0 / 1
19.83
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Search\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Search\Domain\Model\SearchMode;
12
13/**
14 * Search Query Parser.
15 *
16 * Tokenizes raw query terms into boolean operators, phrases, wildcards, and exclusions.
17 * Builds secure parameterized SQL search clauses.
18 *
19 * @package App\Core\Search\Application\Service
20 */
21final readonly class SearchQueryParser
22{
23    private const string REGEX_WHITESPACE = '/\s+/';
24    private const string PATTERN_LIKE = '%s LIKE %s';
25    private const string CLAUSE_ALWAYS_TRUE = '1 = 1';
26    private const string GLUE_AND = ' AND ';
27
28    /**
29     * Parses raw query string into tokens, phrases, and exclusions.
30     *
31     * @param string $query Raw query string.
32     * @return array{phrases: list<string>, required: list<string>, excluded: list<string>, words: list<string>}
33     */
34    public function parse(string $query): array
35    {
36        $phrases = [];
37        $required = [];
38        $excluded = [];
39        $words = [];
40
41        // Extract quoted phrases: "phrase here"
42        $cleaned = (string) preg_replace_callback('/"([^"]+)"/', static function (array $m) use (&$phrases): string {
43            $val = trim($m[1]);
44            if ($val !== '') {
45                $phrases[] = $val;
46            }
47            return ' ';
48        }, $query);
49
50        $tokens = preg_split(self::REGEX_WHITESPACE, trim($cleaned));
51        if ($tokens === false) {
52            $tokens = [];
53        }
54
55        foreach ($tokens as $token) {
56            $t = trim($token);
57            if ($t === '') {
58                continue;
59            }
60
61            if (str_starts_with($t, '+') && mb_strlen($t) > 1) {
62                $required[] = mb_substr($t, 1);
63            } elseif (str_starts_with($t, '-') && mb_strlen($t) > 1) {
64                $excluded[] = mb_substr($t, 1);
65            } else {
66                $words[] = ltrim($t, '+');
67            }
68        }
69
70        return [
71            'phrases'  => $phrases,
72            'required' => $required,
73            'excluded' => $excluded,
74            'words'    => $words,
75        ];
76    }
77
78    /**
79     * Builds WHERE condition SQL fragment and populates parameter array.
80     *
81     * @param list<string>         $columns SQL column expressions to search against.
82     * @param string               $term    Search string.
83     * @param SearchMode           $mode    Active search mode.
84     * @param array<string, mixed> $params  Query parameters accumulator.
85     * @param string               $prefix  Parameter prefix unique to query context.
86     * @return string SQL boolean expression.
87     */
88    public function buildCondition(
89        array $columns,
90        string $term,
91        SearchMode $mode,
92        array &$params,
93        string $prefix = 'sq_'
94    ): string {
95        if (empty($columns)) {
96            return '1 = 0';
97        }
98
99        return match ($mode) {
100            SearchMode::EXACT_PHRASE => $this->buildExactPhraseClause($columns, $term, $params, $prefix),
101            SearchMode::WORDS_AND    => $this->buildWordsAndClause($columns, $term, $params, $prefix),
102            SearchMode::WORDS_OR     => $this->buildWordsOrClause($columns, $term, $params, $prefix),
103            SearchMode::PARTIAL      => $this->buildPartialClause($columns, $term, $params, $prefix),
104            SearchMode::SMART        => $this->buildSmartClause($columns, $term, $params, $prefix),
105        };
106    }
107
108    /**
109     * Builds exact phrase matching clause.
110     *
111     * @param list<string>         $columns Columns list.
112     * @param string               $term    Full search string.
113     * @param array<string, mixed> $params  Parameters accumulator.
114     * @param string               $prefix  Parameter prefix.
115     * @return string SQL condition.
116     */
117    private function buildExactPhraseClause(
118        array $columns,
119        string $term,
120        array &$params,
121        string $prefix
122    ): string {
123        $pName = ':' . $prefix . 'phrase';
124        $params[$pName] = '%' . trim($term) . '%';
125
126        $parts = [];
127        foreach ($columns as $col) {
128            $parts[] = sprintf(self::PATTERN_LIKE, $col, $pName);
129        }
130
131        return '(' . implode(' OR ', $parts) . ')';
132    }
133
134    /**
135     * Builds AND matching clause (every word must appear in some column).
136     *
137     * @param list<string>         $columns Columns list.
138     * @param string               $term    Search string.
139     * @param array<string, mixed> $params  Parameters accumulator.
140     * @param string               $prefix  Parameter prefix.
141     * @return string SQL condition.
142     */
143    private function buildWordsAndClause(
144        array $columns,
145        string $term,
146        array &$params,
147        string $prefix
148    ): string {
149        $words = preg_split(self::REGEX_WHITESPACE, trim($term));
150        if ($words === false || $words === []) {
151            return self::CLAUSE_ALWAYS_TRUE;
152        }
153
154        $wordClauses = [];
155        foreach ($words as $idx => $word) {
156            $w = trim($word);
157            if ($w === '') {
158                continue;
159            }
160            $pName = sprintf(':%sw_and_%d', $prefix, $idx);
161            $params[$pName] = '%' . $w . '%';
162
163            $colClauses = [];
164            foreach ($columns as $col) {
165                $colClauses[] = sprintf(self::PATTERN_LIKE, $col, $pName);
166            }
167            $wordClauses[] = '(' . implode(' OR ', $colClauses) . ')';
168        }
169
170        return $wordClauses === [] ? self::CLAUSE_ALWAYS_TRUE : '(' . implode(self::GLUE_AND, $wordClauses) . ')';
171    }
172
173    /**
174     * Builds OR matching clause (at least one word appears in some column).
175     *
176     * @param list<string>         $columns Columns list.
177     * @param string               $term    Search string.
178     * @param array<string, mixed> $params  Parameters accumulator.
179     * @param string               $prefix  Parameter prefix.
180     * @return string SQL condition.
181     */
182    private function buildWordsOrClause(
183        array $columns,
184        string $term,
185        array &$params,
186        string $prefix
187    ): string {
188        $words = preg_split(self::REGEX_WHITESPACE, trim($term));
189        if ($words === false || $words === []) {
190            return self::CLAUSE_ALWAYS_TRUE;
191        }
192
193        $disjunctions = [];
194        foreach ($words as $idx => $word) {
195            $w = trim($word);
196            if ($w === '') {
197                continue;
198            }
199            $pName = sprintf(':%sw_or_%d', $prefix, $idx);
200            $params[$pName] = '%' . $w . '%';
201
202            foreach ($columns as $col) {
203                $disjunctions[] = sprintf(self::PATTERN_LIKE, $col, $pName);
204            }
205        }
206
207        return $disjunctions === [] ? self::CLAUSE_ALWAYS_TRUE : '(' . implode(' OR ', $disjunctions) . ')';
208    }
209
210    /**
211     * Builds prefix partial matching clause (prefix search).
212     *
213     * @param list<string>         $columns Columns list.
214     * @param string               $term    Search string.
215     * @param array<string, mixed> $params  Parameters accumulator.
216     * @param string               $prefix  Parameter prefix.
217     * @return string SQL condition.
218     */
219    private function buildPartialClause(
220        array $columns,
221        string $term,
222        array &$params,
223        string $prefix
224    ): string {
225        $words = preg_split(self::REGEX_WHITESPACE, trim($term));
226        if ($words === false || $words === []) {
227            return self::CLAUSE_ALWAYS_TRUE;
228        }
229
230        $clauses = [];
231        foreach ($words as $idx => $word) {
232            $clean = rtrim(trim($word), '*');
233            if ($clean === '') {
234                continue;
235            }
236            $pName = sprintf(':%spart_%d', $prefix, $idx);
237            $params[$pName] = $clean . '%';
238
239            $colParts = [];
240            foreach ($columns as $col) {
241                $colParts[] = sprintf(self::PATTERN_LIKE, $col, $pName);
242            }
243            $clauses[] = '(' . implode(' OR ', $colParts) . ')';
244        }
245
246        return $clauses === [] ? self::CLAUSE_ALWAYS_TRUE : '(' . implode(self::GLUE_AND, $clauses) . ')';
247    }
248
249    /**
250     * Builds smart clause supporting phrases, required words, and exclusions.
251     *
252     * @param list<string>         $columns Columns list.
253     * @param string               $term    Raw query.
254     * @param array<string, mixed> $params  Parameters accumulator.
255     * @param string               $prefix  Parameter prefix.
256     * @return string SQL condition.
257     */
258    private function buildSmartClause(
259        array $columns,
260        string $term,
261        array &$params,
262        string $prefix
263    ): string {
264        $parsed = $this->parse($term);
265        $clauses = [];
266
267        // Exact phrases (must all match)
268        foreach ($parsed['phrases'] as $idx => $phrase) {
269            $pName = sprintf(':%sphr_%d', $prefix, $idx);
270            $params[$pName] = '%' . $phrase . '%';
271            $pCols = [];
272            foreach ($columns as $col) {
273                $pCols[] = sprintf(self::PATTERN_LIKE, $col, $pName);
274            }
275            $clauses[] = '(' . implode(' OR ', $pCols) . ')';
276        }
277
278        // Required terms (+)
279        foreach ($parsed['required'] as $idx => $req) {
280            $pName = sprintf(':%sreq_%d', $prefix, $idx);
281            $params[$pName] = '%' . $req . '%';
282            $rCols = [];
283            foreach ($columns as $col) {
284                $rCols[] = sprintf(self::PATTERN_LIKE, $col, $pName);
285            }
286            $clauses[] = '(' . implode(' OR ', $rCols) . ')';
287        }
288
289        // Standard words
290        if (!empty($parsed['words'])) {
291            $wClause = $this->buildWordsAndClause($columns, implode(' ', $parsed['words']), $params, $prefix . 'sm_');
292            if ($wClause !== self::CLAUSE_ALWAYS_TRUE) {
293                $clauses[] = $wClause;
294            }
295        }
296
297        // Excluded terms (-)
298        foreach ($parsed['excluded'] as $idx => $excl) {
299            $pName = sprintf(':%sexcl_%d', $prefix, $idx);
300            $params[$pName] = '%' . $excl . '%';
301            foreach ($columns as $col) {
302                $clauses[] = sprintf('%s NOT LIKE %s', $col, $pName);
303            }
304        }
305
306        return $clauses === [] ? self::CLAUSE_ALWAYS_TRUE : '(' . implode(self::GLUE_AND, $clauses) . ')';
307    }
308}