Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.92% covered (warning)
86.92%
93 / 107
42.86% covered (danger)
42.86%
3 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
SqlSearchRepository
86.79% covered (warning)
86.79%
92 / 106
42.86% covered (danger)
42.86%
3 / 7
32.07
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
 searchModule
95.83% covered (success)
95.83%
46 / 48
0.00% covered (danger)
0.00%
0 / 1
5
 resolveTitleField
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 applyOwnerRbacFilter
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
 hydrateSearchResult
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
7
 calculateScore
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 extractSnippet
27.27% covered (danger)
27.27%
3 / 11
0.00% covered (danger)
0.00%
0 / 1
14.62
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\Infrastructure\Repository;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Model\FieldMetadata;
12use App\Core\Engine\Domain\Model\ModuleMetadata;
13use App\Core\Engine\Domain\Model\PermissionContext;
14use App\Core\Search\Application\Service\SearchQueryParser;
15use App\Core\Search\Domain\Model\SearchQuery;
16use App\Core\Search\Domain\Model\SearchResult;
17use App\Core\Search\Domain\Model\SearchResultGroup;
18use App\Core\Search\Domain\Repository\SearchRepositoryInterface;
19use PDO;
20use Throwable;
21
22/**
23 * SQL Implementation of Search Persistence Repository.
24 *
25 * Executes secure parameterized multi-column search queries against CRUD module tables
26 * with strict RBAC access checks, snippet extraction, and relevance ranking.
27 *
28 * @package App\Core\Search\Infrastructure\Repository
29 */
30final readonly class SqlSearchRepository implements SearchRepositoryInterface
31{
32    /**
33     * SqlSearchRepository constructor.
34     *
35     * @param PDO               $pdo    Active database connection.
36     * @param SearchQueryParser $parser Search query tokenizer and condition builder.
37     */
38    public function __construct(
39        private PDO $pdo,
40        private SearchQueryParser $parser
41    ) {
42    }
43
44    /**
45     * {@inheritdoc}
46     */
47    public function searchModule(
48        ModuleMetadata    $module,
49        array             $fields,
50        SearchQuery       $query,
51        PermissionContext $context
52    ): SearchResultGroup {
53        $tableName = $module->tableName;
54        if ($tableName === '' || empty($fields)) {
55            return new SearchResultGroup($module->name, $module->label, $module->getIconClass(), 0, []);
56        }
57
58        $titleField = $this->resolveTitleField($fields);
59        $searchCols = array_map(static fn(FieldMetadata $f): string => sprintf('`t`.`%s`', $f->fieldKey), $fields);
60
61        $params = [];
62        $whereParts = [];
63
64        $this->applyOwnerRbacFilter($module, $context, $whereParts, $params);
65
66        $searchClause = $this->parser->buildCondition($searchCols, $query->term, $query->mode, $params, 'sr_');
67        $whereParts[] = $searchClause;
68
69        $whereSql = implode(' AND ', $whereParts);
70        $limit = $query->limitPerModule;
71
72        $exactParam = ':sr_rel_exact';
73        $prefixParam = ':sr_rel_prefix';
74        $params[$exactParam] = trim($query->term);
75        $params[$prefixParam] = trim($query->term) . '%';
76
77        $scoreExpr = sprintf(
78            '(CASE WHEN `t`.`%s` = %s THEN 100 WHEN `t`.`%s` LIKE %s THEN 50 ELSE 10 END)',
79            $titleField->fieldKey,
80            $exactParam,
81            $titleField->fieldKey,
82            $prefixParam
83        );
84
85        $sql = sprintf(
86            'SELECT `t`.`id`, `t`.`%s` AS `_title`, %s, %s AS `_score` '
87            . 'FROM `%s` AS `t` WHERE %s ORDER BY `_score` DESC, `t`.`id` DESC LIMIT %d',
88            $titleField->fieldKey,
89            implode(', ', $searchCols),
90            $scoreExpr,
91            $tableName,
92            $whereSql,
93            $limit
94        );
95
96        try {
97            $stmt = $this->pdo->prepare($sql);
98            $stmt->execute($params);
99            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
100        } catch (Throwable) {
101            return new SearchResultGroup($module->name, $module->label, $module->getIconClass(), 0, []);
102        }
103
104        $results = [];
105        foreach ($rows as $row) {
106            $results[] = $this->hydrateSearchResult($module, $row, $titleField, $fields, $query->term);
107        }
108
109        return new SearchResultGroup(
110            $module->name,
111            $module->label,
112            $module->getIconClass(),
113            count($results),
114            $results
115        );
116    }
117
118    /**
119     * Resolves the primary title field from candidate fields list.
120     *
121     * @param list<FieldMetadata> $fields Candidates.
122     * @return FieldMetadata Resolved title field.
123     */
124    private function resolveTitleField(array $fields): FieldMetadata
125    {
126        $priorityKeys = [
127            'name', 'title', 'subject', 'label', 'last_name',
128            'ticket_no', 'contract_no', 'code', 'username',
129        ];
130
131        foreach ($priorityKeys as $key) {
132            foreach ($fields as $field) {
133                if ($field->fieldKey === $key) {
134                    return $field;
135                }
136            }
137        }
138
139        return $fields[0];
140    }
141
142    /**
143     * Applies RBAC record ownership filter when module enforces owner scope.
144     *
145     * @param ModuleMetadata       $module     Module metadata.
146     * @param PermissionContext    $context    User security context.
147     * @param list<string>         $whereParts WHERE clauses list.
148     * @param array<string, mixed> $params     SQL parameters.
149     */
150    private function applyOwnerRbacFilter(
151        ModuleMetadata    $module,
152        PermissionContext $context,
153        array             &$whereParts,
154        array             &$params
155    ): void {
156        if ($context->isSuperuser || !$module->hasOwnerScope()) {
157            return;
158        }
159
160        $params[':actor_uid'] = $context->actorUserId;
161        $params[':actor_mod'] = $module->name;
162
163        $ownerClause = '((EXISTS (SELECT 1 FROM `a_core_access_user_owners` AS `_acc` '
164            . 'WHERE `_acc`.`user_id` = :actor_uid AND `_acc`.`module_name` = :actor_mod '
165            . 'AND `_acc`.`owner_id` = `t`.`owner` '
166            . 'AND `_acc`.`can_read` = 1)) '
167            . 'OR (`t`.`owner` = :actor_uid) '
168            . 'OR (`t`.`special_access` >= 1))';
169
170        $whereParts[] = $ownerClause;
171    }
172
173    /**
174     * Hydrates single row into SearchResult with score and snippet.
175     *
176     * @param ModuleMetadata      $module     Module metadata.
177     * @param array<string, mixed>$row        Fetched row.
178     * @param FieldMetadata       $titleField Title field.
179     * @param list<FieldMetadata> $fields     All searched fields.
180     * @param string              $term       Search term.
181     * @return SearchResult Result value object.
182     */
183    private function hydrateSearchResult(
184        ModuleMetadata $module,
185        array          $row,
186        FieldMetadata  $titleField,
187        array          $fields,
188        string         $term
189    ): SearchResult {
190        $id = (int) $row['id'];
191        $rawTitle = (string) ($row['_title'] ?? '');
192        $title = $rawTitle !== '' ? $rawTitle : sprintf('#%d', $id);
193
194        $matchedKey = $titleField->fieldKey;
195        $matchedLabel = $titleField->label;
196        $snippet = $title;
197        $termLower = mb_strtolower(trim($term));
198
199        foreach ($fields as $f) {
200            $val = (string) ($row[$f->fieldKey] ?? '');
201            if ($val !== '' && str_contains(mb_strtolower($val), $termLower)) {
202                $matchedKey = $f->fieldKey;
203                $matchedLabel = $f->label;
204                $snippet = $this->extractSnippet($val, $term);
205                break;
206            }
207        }
208
209        $dbScore = isset($row['_score']) ? (float) $row['_score'] : 10.0;
210        $score = $dbScore + $this->calculateScore($title, $termLower);
211        $baseRoute = ($module->routeUrl !== '')
212            ? '/' . trim($module->routeUrl, '/')
213            : '/' . trim($module->name, '/');
214        $url = sprintf('%s/%d', $baseRoute, $id);
215
216        return new SearchResult(
217            id:                $id,
218            moduleName:        $module->name,
219            moduleLabel:       $module->label,
220            iconClass:         $module->getIconClass(),
221            title:             $title,
222            matchedField:      $matchedKey,
223            matchedFieldLabel: $matchedLabel,
224            snippet:           $snippet,
225            url:               $url,
226            score:             $score
227        );
228    }
229
230    /**
231     * Calculates relevance score based on match position.
232     *
233     * @param string $title     Record title.
234     * @param string $termLower Lowercased search term.
235     * @return float Score.
236     */
237    private function calculateScore(string $title, string $termLower): float
238    {
239        $titleLower = mb_strtolower($title);
240
241        return match (true) {
242            $titleLower === $termLower => 10.0,
243            str_starts_with($titleLower, $termLower) => 5.0,
244            str_contains($titleLower, $termLower) => 3.0,
245            default => 1.0,
246        };
247    }
248
249    /**
250     * Extracts a concise snippet centered around the matching term.
251     *
252     * @param string $text Full field text.
253     * @param string $term Matched search term.
254     * @return string Truncated snippet.
255     */
256    private function extractSnippet(string $text, string $term): string
257    {
258        $len = mb_strlen($text);
259        if ($len <= 100) {
260            return $text;
261        }
262
263        $pos = mb_stripos($text, $term);
264        if ($pos === false) {
265            return mb_substr($text, 0, 97) . '...';
266        }
267
268        $start = max(0, $pos - 35);
269        $excerpt = mb_substr($text, $start, 90);
270        $prefix = $start > 0 ? '...' : '';
271        $suffix = ($start + 90 < $len) ? '...' : '';
272
273        return $prefix . trim($excerpt) . $suffix;
274    }
275}