Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
72 / 72
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
SearchIndexOptimizer
100.00% covered (success)
100.00%
71 / 71
100.00% covered (success)
100.00%
6 / 6
26
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getIndexedColumns
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 optimizeModuleIndexes
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
7
 createIndex
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 getSqliteIndexedColumns
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 getMysqlIndexedColumns
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
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\Engine\Domain\Model\ModuleMetadata;
12use PDO;
13use Throwable;
14
15/**
16 * Search Index Optimizer & Advisor.
17 *
18 * Analyzes database index coverage for searchable fields across CRUD modules.
19 * Automatically recommends and applies prefix/B-Tree indexes to ensure optimal query performance.
20 *
21 * @package App\Core\Search\Application\Service
22 */
23final readonly class SearchIndexOptimizer
24{
25    private const string IDENTIFIER_PATTERN = '/^\w+$/';
26
27    /**
28     * SearchIndexOptimizer constructor.
29     *
30     * @param PDO $pdo Active database connection.
31     */
32    public function __construct(private PDO $pdo)
33    {
34    }
35
36    /**
37     * Retrieves currently indexed column names for a given table.
38     *
39     * @param string $tableName Database table name.
40     * @return list<string> List of indexed column names.
41     */
42    public function getIndexedColumns(string $tableName): array
43    {
44        if (!preg_match(self::IDENTIFIER_PATTERN, $tableName)) {
45            return [];
46        }
47
48        $driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
49        if ($driver === 'sqlite') {
50            return $this->getSqliteIndexedColumns($tableName);
51        }
52
53        return $this->getMysqlIndexedColumns($tableName);
54    }
55
56    /**
57     * Inspects index coverage for a module and ensures indexes for specified fields.
58     *
59     * @param ModuleMetadata $module           Module metadata.
60     * @param list<string>   $searchableFields Column names to index.
61     * @return array{indexed: list<string>, created: list<string>, skipped: list<string>}
62     */
63    public function optimizeModuleIndexes(ModuleMetadata $module, array $searchableFields): array
64    {
65        $table = $module->tableName;
66        if ($table === '' || !preg_match(self::IDENTIFIER_PATTERN, $table)) {
67            return ['indexed' => [], 'created' => [], 'skipped' => []];
68        }
69
70        $existing = $this->getIndexedColumns($table);
71        $created = [];
72        $skipped = [];
73
74        foreach ($searchableFields as $field) {
75            if (!preg_match(self::IDENTIFIER_PATTERN, $field)) {
76                $skipped[] = $field;
77                continue;
78            }
79
80            if (in_array($field, $existing, true)) {
81                continue;
82            }
83
84            $success = $this->createIndex($table, $field);
85            if ($success) {
86                $created[] = $field;
87                $existing[] = $field;
88            } else {
89                $skipped[] = $field;
90            }
91        }
92
93        return [
94            'indexed' => $existing,
95            'created' => $created,
96            'skipped' => $skipped,
97        ];
98    }
99
100    /**
101     * Creates a B-Tree / prefix index for a table column.
102     *
103     * @param string $tableName  Target table.
104     * @param string $columnName Target column.
105     * @return bool True if created or exists.
106     */
107    public function createIndex(string $tableName, string $columnName): bool
108    {
109        $driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
110        $idxName = sprintf('idx_srch_%s_%s', substr(md5($tableName), 0, 6), $columnName);
111
112        try {
113            if ($driver === 'sqlite') {
114                $sql = sprintf(
115                    'CREATE INDEX IF NOT EXISTS `%s` ON `%s` (`%s`)',
116                    $idxName,
117                    $tableName,
118                    $columnName
119                );
120            } else {
121                $sql = sprintf(
122                    'CREATE INDEX `%s` ON `%s` (`%s`(64))',
123                    $idxName,
124                    $tableName,
125                    $columnName
126                );
127            }
128            $this->pdo->exec($sql);
129            return true;
130        } catch (Throwable) {
131            return false;
132        }
133    }
134
135    /**
136     * Retrieves indexed columns for SQLite.
137     *
138     * @param string $tableName Table name.
139     * @return list<string> Indexed columns.
140     */
141    private function getSqliteIndexedColumns(string $tableName): array
142    {
143        $cols = [];
144        $idxStmt = $this->pdo->query(sprintf('PRAGMA index_list(`%s`)', $tableName));
145        if ($idxStmt === false) {
146            return [];
147        }
148
149        while ($idx = $idxStmt->fetch(PDO::FETCH_ASSOC)) {
150            $name = (string) ($idx['name'] ?? '');
151            if ($name === '') {
152                continue;
153            }
154            $infoStmt = $this->pdo->query(sprintf('PRAGMA index_info(`%s`)', $name));
155            if ($infoStmt) {
156                while ($info = $infoStmt->fetch(PDO::FETCH_ASSOC)) {
157                    if (isset($info['name'])) {
158                        $cols[] = (string) $info['name'];
159                    }
160                }
161            }
162        }
163
164        return array_values(array_unique($cols));
165    }
166
167    /**
168     * Retrieves indexed columns for MySQL/MariaDB.
169     *
170     * @param string $tableName Table name.
171     * @return list<string> Indexed columns.
172     */
173    private function getMysqlIndexedColumns(string $tableName): array
174    {
175        $cols = [];
176        try {
177            $stmt = $this->pdo->query(sprintf('SHOW INDEX FROM `%s`', $tableName));
178            if ($stmt) {
179                while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
180                    if (isset($row['Column_name'])) {
181                        $cols[] = (string) $row['Column_name'];
182                    }
183                }
184            }
185        } catch (Throwable) {
186            return [];
187        }
188
189        return array_values(array_unique($cols));
190    }
191}