Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.21% covered (warning)
66.21%
96 / 145
33.33% covered (danger)
33.33%
4 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
MySQL
66.21% covered (warning)
66.21%
96 / 145
33.33% covered (danger)
33.33%
4 / 12
272.05
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 connect
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getConnection
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getColumns
80.00% covered (success)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
4.13
 createTable
86.67% covered (success)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
6.09
 columnToSql
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 prepareTable
60.00% covered (warning)
60.00%
27 / 45
0.00% covered (danger)
0.00%
0 / 1
78.18
 query
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 formatParameter
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 getTypes
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
6.32
 fetch
87.50% covered (success)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 getResult
68.18% covered (warning)
68.18%
15 / 22
0.00% covered (danger)
0.00%
0 / 1
8.58
1<?php
2
3namespace Miniframe\ORM\Engine;
4
5use Miniframe\Core\Config;
6use Miniframe\ORM\Annotation\Column;
7use Miniframe\ORM\Annotation\Table;
8use Miniframe\ORM\Exception\OrmException;
9
10class MySQL implements EngineInterface
11{
12    /**
13     * Reference to the MySQL connection
14     *
15     * @var \mysqli
16     */
17    protected $connection;
18
19    /**
20     * List of config values
21     *
22     * @var array
23     */
24    private $configValues = array();
25
26    /**
27     * Initializes the MySQL engine
28     *
29     * @param Config $config Reference to the configuration object.
30     */
31    public function __construct(Config $config)
32    {
33        foreach (['hostname', 'username', 'password', 'database', 'port', 'socket'] as $configName) {
34            $this->configValues[$configName] = $config->has('orm', 'mysql_' . $configName)
35                ? $config->get('orm', 'mysql_' . $configName)
36                : null;
37        }
38        // @codeCoverageIgnoreStart
39        // This exception can't be tested since other tests won't work if \mysqli isn't available
40        if (!class_exists(\mysqli::class)) {
41            throw new \RuntimeException('When using the MySQL engine, the mysqli extension is required.');
42        }
43        // @codeCoverageIgnoreEnd
44
45        mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
46        $this->connect();
47    }
48
49    /**
50     * Creates the MySQL connection
51     *
52     * @return void
53     */
54    private function connect(): void
55    {
56        $this->connection = new \mysqli(
57            $this->configValues['hostname'],
58            $this->configValues['username'],
59            $this->configValues['password'],
60            $this->configValues['database'],
61            $this->configValues['port'],
62            $this->configValues['socket']
63        );
64    }
65
66    /**
67     * Returns the mysqli connection
68     *
69     * @return \mysqli
70     */
71    public function getConnection(): \mysqli
72    {
73        return $this->connection;
74    }
75
76    /**
77     * Fetch column metadata from table
78     *
79     * @param string $tableName Name of the table.
80     *
81     * @return array
82     *
83     * @see prepareTable
84     * @see https://dev.mysql.com/doc/refman/8.0/en/show-columns.html
85     */
86    private function getColumns(string $tableName): array
87    {
88        $mysqlColumns = array();
89        try {
90            $result = $this->connection->query('SHOW COLUMNS FROM `' . $tableName . '`');
91        } catch (\mysqli_sql_exception $exception) {
92            if ($exception->getMessage() == 'MySQL server has gone away') { // @TODO Use getSqlState() on exception?
93                $this->connect();
94                $result = $this->connection->query('SHOW COLUMNS FROM `' . $tableName . '`');
95            } else {
96                throw $exception;
97            }
98        }
99        while ($mysqlColumn = $result->fetch_assoc()) {
100            $mysqlColumns[$mysqlColumn['Field']] = $mysqlColumn;
101        }
102        return $mysqlColumns;
103    }
104
105    /**
106     * Creates a table
107     *
108     * @param string   $tableName Name of the table.
109     * @param Column[] $columns   Column metadata.
110     *
111     * @return void
112     *
113     * @see prepareTable
114     */
115    private function createTable(string $tableName, array $columns): void
116    {
117        $createTable = 'CREATE TABLE `' . $tableName . '` ( ';
118        $primaryKey = array();
119        $indexes = array();
120        foreach ($columns as $column) {
121            if ($column->index) {
122                $indexes[$column->index][] = $column->name;
123            }
124            $createTable .= $this->columnToSql($column) . ',';
125            if ($column->primaryKey) {
126                $primaryKey[] = $column->name;
127            }
128        }
129        if (count($primaryKey)) {
130            $createTable .= 'PRIMARY KEY (`' . implode('`, `', $primaryKey) . '`),';
131        }
132        foreach ($indexes as $indexName => $indexColumns) {
133            $createTable .= 'KEY `' . $indexName . '` (`' . implode('`, `', $indexColumns) . '`),';
134        }
135        $createTable = rtrim($createTable, ',') . ')';
136
137        $this->connection->query($createTable);
138    }
139
140    /**
141     * Column to MySQL formatter
142     *
143     * @param Column $column Column metadata.
144     *
145     * @return string
146     */
147    private function columnToSql(Column $column): string
148    {
149        return '`' . $column->name . '` ' . $column->type
150            . ($column->length ? '(' . $column->length . ') ' : ' ')
151            . ($column->unsigned ? 'UNSIGNED ' : '')
152            . ($column->nullable ? 'DEFAULT NULL ' : 'NOT NULL ')
153            . ($column->autoIncrement ? 'AUTO_INCREMENT ' : '');
154    }
155
156    /**
157     * Prepares a table
158     *
159     * @param Table    $table   Table metadata.
160     * @param Column[] $columns Column metadata.
161     *
162     * @return void
163     */
164    public function prepareTable(Table $table, array $columns): void
165    {
166        // Fetch columns, if table doesn't exist, create it.
167        try {
168            $mysqlColumns = $this->getColumns($table->name);
169        } catch (\mysqli_sql_exception $exception) {
170            if ($exception->getCode() != 1146) { // 1146 = Table '%s' doesn't exist
171                throw $exception;
172            }
173            $this->createTable($table->name, $columns);
174            trigger_error('Table ' . $table->name . ' didn\'t exist, created', E_USER_NOTICE);
175            return;
176        }
177
178        // Do all columns in entity match database?
179        foreach ($columns as $entityColumn) {
180            $dbColumnName = $entityColumn->name;
181            if (!isset($mysqlColumns[$dbColumnName])) {
182                $this->connection->query(
183                    "ALTER TABLE `" . $table->name . "` ADD COLUMN " . $this->columnToSql($entityColumn)
184                );
185                trigger_error('Column ' . $table->name . '.' . $dbColumnName . ' didn\'t exist, added', E_USER_NOTICE);
186                continue;
187            }
188            $mysqlColumn = $mysqlColumns[$dbColumnName];
189            $mysqlColumns[$dbColumnName]['matched'] = true;
190
191            // Parses the type column
192            preg_match(
193                '/^(?P<type>[\w]+)[\s]*(\((?P<params>.*?)\)|)(?P<extra>.*?)$/i',
194                $mysqlColumn['Type'],
195                $typeMatches
196            );
197
198            $correct = true;
199            // Matches the type
200            if (
201                ($typeMatches['type'] != $entityColumn->type)
202                || ($entityColumn->length && $typeMatches['params'] != $entityColumn->length)
203            ) {
204                $correct = false;
205            }
206            // Matches unsigned
207            if (
208                (stripos($typeMatches['extra'], 'unsigned') === false && $entityColumn->unsigned)
209                || (stripos($typeMatches['extra'], 'unsigned') !== false && !$entityColumn->unsigned)
210            ) {
211                $correct = false;
212            }
213            // Matches the nullable value
214            if (
215                ($mysqlColumn['Null'] == 'NO' && $entityColumn->nullable)
216                || ($mysqlColumn['Null'] != 'NO' && !$entityColumn->nullable)
217            ) {
218                $correct = false;
219            }
220            // Matches the primary key
221            if (
222                ($mysqlColumn['Key'] == 'PRI' && !$entityColumn->primaryKey)
223                || ($mysqlColumn['Key'] != 'PRI' && $entityColumn->primaryKey)
224            ) {
225                // @TODO Handle this correctly
226                //$correct = false;
227            }
228            // Matches auto increment
229            if (
230                (stripos($mysqlColumn['Extra'], 'auto_increment') === false && $entityColumn->autoIncrement)
231                || (stripos($mysqlColumn['Extra'], 'auto_increment') !== false && !$entityColumn->autoIncrement)
232            ) {
233                $correct = false;
234            }
235
236            if (!$correct) {
237                $this->connection->query(
238                    "ALTER TABLE `" . $table->name . "` CHANGE `" . $dbColumnName . "` "
239                    . $this->columnToSql($entityColumn)
240                );
241                trigger_error(
242                    'Column ' . $table->name . '.' . $dbColumnName . ' didn\'t match, modified',
243                    E_USER_NOTICE
244                );
245            }
246        }
247
248        // Are there database columns missing in the entity?
249        foreach ($mysqlColumns as $mysqlColumn) {
250            if (!isset($mysqlColumn['matched']) || $mysqlColumn['matched'] !== true) {
251                $this->connection->query(
252                    "ALTER TABLE `" . $table->name . "` DROP COLUMN `" . $mysqlColumn['Field'] . "`"
253                );
254                trigger_error(
255                    'Column ' . $table->name . '.' . $mysqlColumn['Field'] . ' didn\'t exist in entity, removed',
256                    E_USER_NOTICE
257                );
258            }
259        }
260    }
261
262    /**
263     * Executes a query
264     *
265     * Returns the inserted ID for INSERT queries (int or string)
266     * Returns the amount of affected rows for UPDATE and DELETE queries (int)
267     *
268     * @param string $query      The query.
269     * @param array  $parameters A list of parameters.
270     *
271     * @return integer|string
272     */
273    public function query(string $query, array $parameters = array())
274    {
275        if (!preg_match('/^(INSERT|UPDATE|DELETE)\s/i', $query)) {
276            throw new \RuntimeException('Only use query() for INSERT, UPDATE or DELETE queries');
277        }
278
279        // For INSERT queries, return the insert_id
280        if (preg_match('/^INSERT\s/i', $query)) {
281            try {
282                $statement = $this->getResult($query, $parameters);
283            } catch (\mysqli_sql_exception $exception) {
284                if ($exception->getCode() == 1062) { // 1062 = Duplicate entry '%s' for key '%s'
285                    throw new OrmException(OrmException::DUPLICATE_KEY, $exception->getMessage(), $exception);
286                }
287                throw $exception;
288            }
289            $return = $statement->insert_id;
290            $statement->free_result();
291            return $return;
292        }
293
294        $statement = $this->getResult($query, $parameters);
295        $return = $statement->affected_rows;
296        $statement->free_result();
297        return $return;
298    }
299
300    /**
301     * Formats a parameter
302     *
303     * @param mixed $param The parameter.
304     *
305     * @return mixed
306     */
307    protected function formatParameter($param)
308    {
309        if (is_object($param) && in_array(\DateTimeInterface::class, class_implements($param))) {
310            return $param->format('Y-m-d H:i:s');
311        }
312        return $param;
313    }
314
315    /**
316     * Returns a list of character types to be injected as types for bind_param
317     *
318     * @param array $params The parameters.
319     *
320     * @return string
321     */
322    protected function getTypes(array $params): string
323    {
324        $return = '';
325        foreach ($params as $param) {
326            if (is_int($param) || is_bool($param)) {
327                $return .= 'i';
328            } elseif (is_numeric($param)) {
329                $return .= 'd';
330            } else {
331                $return .= 's';
332            }
333        }
334        return $return;
335    }
336
337    /**
338     * Executes a query and returns an array with objects
339     *
340     * @param string $query      The query.
341     * @param array  $parameters A list of parameters.
342     *
343     * @return array[]
344     */
345    public function fetch(string $query, array $parameters = array()): array
346    {
347        if (!preg_match('/^(SELECT|SHOW|DESCRIBE|EXPLAIN)\s/i', $query)) {
348            throw new \RuntimeException('Only use fetch() for SELECT, SHOW, DESCRIBE and EXPLAIN queries');
349        }
350
351        $return = array();
352        $result = $this->getResult($query, $parameters)->get_result();
353        while ($row = $result->fetch_assoc()) {
354            $return[] = $row;
355        };
356        $result->free();
357
358        return $return;
359    }
360
361    /**
362     * Executes a query
363     *
364     * @param string $query      The query.
365     * @param array  $parameters A list of parameters.
366     *
367     * @return \mysqli_stmt
368     */
369    protected function getResult(string $query, array $parameters)
370    {
371        // Prepared query
372        $newParametersList = array();
373        $replacements = array();
374        preg_match_all('/\:([\w]+)/', $query, $matches, PREG_SET_ORDER);
375        foreach ($matches as $parameterIdentifier) {
376            if (!array_key_exists($parameterIdentifier[1], $parameters)) {
377                continue;
378            }
379            $replacements[] = $parameterIdentifier[0];
380            $newParametersList[] = $this->formatParameter($parameters[$parameterIdentifier[1]]);
381        }
382
383        // Replace ":foo" values to "?", but sorted by string length. This is done because if you first replace ":foo"
384        // to "?", and after that ":foobar" to "?", it won't work; it will be ":?bar" instead.
385        usort($replacements, function ($a, $b) {
386            return strlen($b) - strlen($a);
387        });
388
389        foreach ($replacements as $replacement) {
390            $query = str_replace($replacement, '?', $query);
391        }
392
393        try {
394            $statement = $this->connection->prepare($query);
395        } catch (\mysqli_sql_exception $exception) {
396            if ($exception->getMessage() == 'MySQL server has gone away') { // @TODO Use getSqlState() on exception?
397                $this->connect();
398                $statement = $this->connection->prepare($query);
399            } else {
400                throw $exception;
401            }
402        }
403        if (count($newParametersList)) {
404            $statement->bind_param($this->getTypes($newParametersList), ...$newParametersList);
405        }
406        $statement->execute();
407
408        return $statement;
409    }
410}