Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 454
0.00% covered (danger)
0.00%
0 / 37
CRAP
0.00% covered (danger)
0.00%
0 / 1
DbUpgrade
0.00% covered (danger)
0.00%
0 / 454
0.00% covered (danger)
0.00%
0 / 37
28392
0.00% covered (danger)
0.00%
0 / 1
 loadSql
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 getAdapter
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 setAdapter
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 query
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getTableInfo
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getAllTables
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCharsetAndCollationProblemsForTableColumns
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 getTableStatus
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getCharsetAndCollationProblemsForTable
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
56
 getCharsetAndCollationProblems
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 fixCharsetAndCollationProblems
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 getTableColumns
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getTableConstraints
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
72
 getMissingTables
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 createMissingTables
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 removeDeprecatedColumns
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getMissingColumns
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
56
 explodeFields
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 compareConstraints
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
42
 getMissingConstraints
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
42
 normalizeConstraints
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 compareConstraintActions
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 constraintIsMissing
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getModifiedConstraints
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
90
 defaultMatches
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
56
 nullableMatches
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 typeMatches
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
210
 parseKeysFromCreateTable
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 columnIsMissing
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getDeprecatedColumns
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 getModifiedColumns
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
132
 createMissingColumns
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 createMissingConstraints
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 updateModifiedColumns
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 updateModifiedConstraints
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
 getModifiedKeys
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
110
 updateModifiedKeys
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3/**
4 * VuFind Action Helper - Database upgrade tools
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2010.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  Controller_Plugins
25 * @author   Demian Katz <demian.katz@villanova.edu>
26 * @author   Ere Maijala <ere.maijala@helsinki.fi>
27 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
28 * @link     https://vufind.org Main Page
29 */
30
31namespace VuFind\Controller\Plugin;
32
33use Laminas\Db\Adapter\Adapter as DbAdapter;
34use Laminas\Db\Metadata\Source\Factory as DbMetadataSourceFactory;
35use Laminas\Mvc\Controller\Plugin\AbstractPlugin;
36
37use function count;
38use function in_array;
39use function is_object;
40
41/**
42 * Action helper to perform database upgrades
43 *
44 * @category VuFind
45 * @package  Controller_Plugins
46 * @author   Demian Katz <demian.katz@villanova.edu>
47 * @author   Ere Maijala <ere.maijala@helsinki.fi>
48 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
49 * @link     https://vufind.org Main Page
50 */
51class DbUpgrade extends AbstractPlugin
52{
53    /**
54     * Database commands to generate table
55     *
56     * @var array
57     */
58    protected $dbCommands = [];
59
60    /**
61     * Database adapter
62     *
63     * @var DbAdapter
64     */
65    protected $adapter;
66
67    /**
68     * Table metadata
69     *
70     * @var array
71     */
72    protected $tableInfo = false;
73
74    /**
75     * Deprecated columns, keyed by table name
76     *
77     * @var array
78     */
79    protected $deprecatedColumns = [
80        'search' => ['folder_id'],
81    ];
82
83    /**
84     * Given a SQL file, parse it for table creation commands.
85     *
86     * @param string $file Filename to load.
87     *
88     * @return void
89     */
90    public function loadSql($file)
91    {
92        $sql = file_get_contents($file);
93        $statements = explode(';', $sql);
94
95        // Create an array, indexed by table name, of commands necessary to create
96        // the keyed table:
97        foreach ($statements as $statement) {
98            preg_match(
99                '/(create\s+table|alter\s+table)\s+([^\s(]+).*/mi',
100                $statement,
101                $matches
102            );
103            if (isset($matches[2])) {
104                $table = str_replace('`', '', $matches[2]);
105                if (!isset($this->dbCommands[$table])) {
106                    $this->dbCommands[$table] = [];
107                }
108                $this->dbCommands[$table][] = $statement;
109            }
110        }
111    }
112
113    /**
114     * Get the database adapter.
115     *
116     * @return DbAdapter
117     */
118    public function getAdapter()
119    {
120        if (!is_object($this->adapter)) {
121            throw new \Exception('Database adapter not set.');
122        }
123        return $this->adapter;
124    }
125
126    /**
127     * Set a database adapter.
128     *
129     * @param DbAdapter $adapter Adapter to set
130     *
131     * @return DbUpgrade
132     */
133    public function setAdapter($adapter)
134    {
135        $this->adapter = $adapter;
136        return $this;
137    }
138
139    /**
140     * Execute a query.
141     *
142     * @param string $sql    SQL to run
143     * @param bool   $logsql Should we return the SQL as a string rather than
144     * execute it?
145     *
146     * @return string        SQL if $logsql is true, empty string otherwise
147     */
148    public function query($sql, $logsql)
149    {
150        if ($logsql) {
151            return rtrim($sql, ';') . ";\n";
152        } else {
153            $this->getAdapter()->query($sql, DbAdapter::QUERY_MODE_EXECUTE);
154        }
155        return '';
156    }
157
158    /**
159     * Load table metadata.
160     *
161     * @param bool $reload Force a reload? (Default is false).
162     *
163     * @return array
164     */
165    protected function getTableInfo($reload = false)
166    {
167        if ($reload || !$this->tableInfo) {
168            $metadata = DbMetadataSourceFactory::createSourceFromAdapter(
169                $this->getAdapter()
170            );
171            $tables = $metadata->getTables();
172            $this->tableInfo = [];
173            foreach ($tables as $current) {
174                $this->tableInfo[$current->getName()] = $current;
175            }
176        }
177        return $this->tableInfo;
178    }
179
180    /**
181     * Get a list of all tables in the database.
182     *
183     * @throws \Exception
184     * @return array
185     */
186    protected function getAllTables()
187    {
188        return array_keys($this->getTableInfo());
189    }
190
191    /**
192     * Support method for getCharsetAndCollationProblemsForTable() -- get column
193     * details
194     *
195     * @param string $table     Table to check
196     * @param string $collation The desired collation
197     *
198     * @throws \Exception
199     * @return array
200     */
201    protected function getCharsetAndCollationProblemsForTableColumns(
202        $table,
203        $collation
204    ) {
205        $collation = strtolower($collation);
206
207        // Get column summary:
208        $sql = "SHOW FULL COLUMNS FROM `{$table}`";
209        $results = $this->getAdapter()->query($sql, DbAdapter::QUERY_MODE_EXECUTE);
210
211        // Get expected column types:
212        // Parse column names out of the CREATE TABLE SQL, which will always be
213        // the first entry in the array; we assume the standard mysqldump
214        // formatting is used here.
215        preg_match_all(
216            '/^  `([^`]*)`\s+([^\s,]+)[\t ,]+.*$/m',
217            $this->dbCommands[$table][0],
218            $matches
219        );
220        $expectedTypes = array_combine($matches[1], $matches[2]);
221
222        // Load details:
223        $retVal = [];
224        foreach ($results as $current) {
225            // json fields default to utf8mb4_bin, and we only support that:
226            if (($expectedTypes[$current->Field] ?? '') === 'json') {
227                continue;
228            }
229            if (!empty($current->Collation)) {
230                $normalizedCollation = strtolower($current->Collation);
231                if ($normalizedCollation !== $collation) {
232                    $retVal[$current->Field] = (array)$current;
233                }
234            }
235        }
236        return $retVal;
237    }
238
239    /**
240     * Retrieve (and statically cache) table status information.
241     *
242     * @return array
243     */
244    public function getTableStatus()
245    {
246        static $status = false;
247        if (!$status) {
248            $status = $this->getAdapter()
249                ->query('SHOW TABLE STATUS', DbAdapter::QUERY_MODE_EXECUTE)
250                ->toArray();
251        }
252        return $status;
253    }
254
255    /**
256     * Check whether the actual table charset and collation match the expected
257     * ones; return false if there is no problem, the desired character set and
258     * collation otherwise.
259     *
260     * @param array $table Information about a table (from getTableStatus())
261     *
262     * @return bool|string
263     */
264    protected function getCharsetAndCollationProblemsForTable($table)
265    {
266        if (!isset($this->dbCommands[$table['Name']][0])) {
267            return false;
268        }
269        $match = preg_match(
270            '/(CHARSET|CHARACTER SET)[\s=]+(utf8(mb4)?)/',
271            $this->dbCommands[$table['Name']][0],
272            $matches
273        );
274        if (!$match) {
275            return false;
276        }
277        $charset = $matches[2];
278        // Check collation:
279        $match = preg_match(
280            '/COLLATE[\s=]+(\w+)/',
281            $this->dbCommands[$table['Name']][0],
282            $matches
283        );
284        if (!$match) {
285            return false;
286        }
287        $collation = $matches[1];
288        // The table definition does not include character set, but collation must
289        // begin with the character set name, so take it from there
290        // (See https://dev.mysql.com/doc/refman/8.0/en/show-table-status.html for
291        // more information):
292        [$tableCharset] = explode('_', $table['Collation']);
293        $problemColumns = $this->getCharsetAndCollationProblemsForTableColumns(
294            $table['Name'],
295            $collation
296        );
297        if (
298            strcasecmp($collation, $table['Collation']) !== 0
299            || strcasecmp($charset, $tableCharset) !== 0
300            || !empty($problemColumns)
301        ) {
302            return compact('charset', 'collation', 'problemColumns');
303        }
304        return false;
305    }
306
307    /**
308     * Get information on character set and collation problems. Return value is an
309     * associative array of table name => correct character set and collation values.
310     *
311     * @throws \Exception
312     * @return array
313     */
314    public function getCharsetAndCollationProblems()
315    {
316        // Load details:
317        $retVal = [];
318        foreach ($this->getTableStatus() as $current) {
319            if ($problem = $this->getCharsetAndCollationProblemsForTable($current)) {
320                $retVal[$current['Name']] = $problem;
321            }
322        }
323        return $retVal;
324    }
325
326    /**
327     * Fix character set and collation problems based on the output of
328     * getCharsetAndCollationProblems().
329     *
330     * @param array $tables Output of getCharsetAndCollationProblems()
331     * @param bool  $logsql Should we return the SQL as a string rather than
332     * execute it?
333     *
334     * @throws \Exception
335     * @return string       SQL if $logsql is true, empty string otherwise
336     */
337    public function fixCharsetAndCollationProblems($tables, $logsql = false)
338    {
339        $sqlcommands = '';
340        foreach ($tables as $table => $newSettings) {
341            foreach ($newSettings['problemColumns'] as $column => $details) {
342                // Set up default:
343                if (null !== $details['Default']) {
344                    $safeDefault = $this->getAdapter()->getPlatform()
345                        ->quoteValue($details['Default']);
346                    $currentDefault = " DEFAULT {$safeDefault}";
347                } else {
348                    $currentDefault = '';
349                }
350
351                // Change column to appropriate character encoding:
352                $sql = "ALTER TABLE `$table` MODIFY `$column" . $details['Type']
353                    . ' COLLATE ' . $newSettings['collation']
354                    . (strtoupper($details['Null']) == 'NO' ? ' NOT NULL' : '')
355                    . $currentDefault
356                    . ';';
357                $sqlcommands .= $this->query($sql, $logsql);
358            }
359            // Adjust table character set and collation:
360            $sql = "ALTER TABLE `$table` CONVERT TO CHARACTER SET"
361                . " {$newSettings['charset']} COLLATE {$newSettings['collation']};";
362            $sqlcommands .= $this->query($sql, $logsql);
363        }
364        return $sqlcommands;
365    }
366
367    /**
368     * Get information on all columns in a table, keyed by column name.
369     *
370     * @param string $table Table to describe.
371     *
372     * @throws \Exception
373     * @return array
374     */
375    protected function getTableColumns($table)
376    {
377        $info = $this->getTableInfo();
378        $columns = isset($info[$table]) ? $info[$table]->getColumns() : [];
379        $retVal = [];
380        foreach ($columns as $current) {
381            $retVal[strtolower($current->getName())] = $current;
382        }
383        return $retVal;
384    }
385
386    /**
387     * Get information on all constraints in a table, keyed by type and constraint
388     * name. Primary key is double-keyed as ['primary']['primary'] to keep the
389     * structure consistent (since primary keys are not explicitly named in the
390     * source SQL).
391     *
392     * @param string $table Table to describe.
393     *
394     * @throws \Exception
395     * @return array
396     */
397    protected function getTableConstraints($table)
398    {
399        $info = $this->getTableInfo();
400        $constraints = isset($info[$table]) ? $info[$table]->getConstraints() : [];
401        $retVal = [];
402        foreach ($constraints as $current) {
403            $fields = [
404                'fields' => $current->getColumns(),
405                'deleteRule' => $current->getDeleteRule(),
406                'updateRule' => $current->getUpdateRule(),
407            ];
408            switch ($current->getType()) {
409                case 'FOREIGN KEY':
410                    $retVal['foreign'][$current->getName()] = $fields;
411                    break;
412                case 'PRIMARY KEY':
413                    $retVal['primary']['primary'] = $fields;
414                    break;
415                case 'UNIQUE':
416                    $retVal['unique'][$current->getName()] = $fields;
417                    break;
418                case 'CHECK':
419                    // We don't get enough information from getConstraints() to
420                    // handle CHECK constraints, so just ignore them for now:
421                    break;
422                default:
423                    throw new \Exception(
424                        'Unexpected constraint type: ' . $current->getType()
425                    );
426                    break;
427            }
428        }
429        return $retVal;
430    }
431
432    /**
433     * Get a list of missing tables in the database.
434     *
435     * @throws \Exception
436     * @return array
437     */
438    public function getMissingTables()
439    {
440        $tables = $this->getAllTables();
441        $missing = [];
442        foreach (array_keys($this->dbCommands) as $table) {
443            if (!in_array(trim(strtolower($table)), $tables)) {
444                $missing[] = $table;
445            }
446        }
447
448        // If we got this far, no tables need to be added:
449        return $missing;
450    }
451
452    /**
453     * Create missing tables based on the output of getMissingTables().
454     *
455     * @param array $tables Output of getMissingTables()
456     * @param bool  $logsql Should we return the SQL as a string rather than
457     * execute it?
458     *
459     * @throws \Exception
460     * @return string       SQL if $logsql is true, empty string otherwise
461     */
462    public function createMissingTables($tables, $logsql = false)
463    {
464        $sqlcommands = '';
465        foreach ($tables as $table) {
466            $sqlcommands .= $this->query($this->dbCommands[$table][0], $logsql);
467        }
468        return $sqlcommands;
469    }
470
471    /**
472     * Remove deprecated columns based on the output of getDeprecatedColumns().
473     *
474     * @param array $details Output of getDeprecatedColumns()
475     * @param bool  $logsql  Should we return the SQL as a string rather than
476     * execute it?
477     *
478     * @throws \Exception
479     * @return string       SQL if $logsql is true, empty string otherwise
480     */
481    public function removeDeprecatedColumns($details, $logsql = false)
482    {
483        $sqlcommands = '';
484        foreach ($details as $table => $columns) {
485            foreach ($columns as $column) {
486                $query = "ALTER TABLE `$table` DROP COLUMN `$column`;";
487                $sqlcommands .= $this->query($query, $logsql);
488            }
489        }
490        return $sqlcommands;
491    }
492
493    /**
494     * Get a list of missing columns in the database tables (associative array,
495     * key = table name, value = array of missing column definitions).
496     *
497     * @param array $missingTables List of missing tables
498     *
499     * @throws \Exception
500     * @return array
501     */
502    public function getMissingColumns($missingTables = [])
503    {
504        $missing = [];
505        $this->getTableInfo(true); // force reload of table info
506        foreach ($this->dbCommands as $table => $sql) {
507            // Skip missing tables if we're logging
508            if (in_array($table, $missingTables)) {
509                continue;
510            }
511
512            // Parse column names out of the CREATE TABLE SQL, which will always be
513            // the first entry in the array; we assume the standard mysqldump
514            // formatting is used here.
515            preg_match_all('/^  `([^`]*)`.*$/m', $sql[0], $matches);
516            $expectedColumns = $matches[1];
517
518            // Create associative array of column name => SQL defining that column
519            $columnDefinitions = [];
520            foreach ($expectedColumns as $i => $name) {
521                // Strip off any comments:
522                $parts = explode('--', $matches[0][$i]);
523
524                // Fix trailing whitespace/punctuation:
525                $columnDefinitions[$name] = trim(trim($parts[0]), ',;');
526            }
527
528            // Now check for missing columns and build our return array:
529            $actualColumns = array_keys($this->getTableColumns($table));
530            foreach ($expectedColumns as $column) {
531                if (!in_array(strtolower($column), $actualColumns)) {
532                    if (!isset($missing[$table])) {
533                        $missing[$table] = [];
534                    }
535                    $missing[$table][] = $columnDefinitions[$column];
536                }
537            }
538        }
539        return $missing;
540    }
541
542    /**
543     * Given a field list extracted from a MySQL table definition (e.g. `a`,`b`)
544     * return an array of fields (e.g. ['a', 'b']).
545     *
546     * @param string $fields Field list
547     *
548     * @return array
549     */
550    protected function explodeFields($fields)
551    {
552        return array_map('trim', explode(',', str_replace('`', '', $fields)));
553    }
554
555    /**
556     * Compare expected vs. actual constraints and return an array of SQL
557     * clauses required to create the missing constraints.
558     *
559     * @param array $expected Expected constraints (based on mysql.sql)
560     * @param array $actual   Actual constraints (pulled from database metadata)
561     *
562     * @return array
563     */
564    protected function compareConstraints($expected, $actual)
565    {
566        $missing = [];
567        foreach ($expected as $type => $constraints) {
568            foreach ($constraints as $constraint) {
569                $matchFound = false;
570                foreach ($actual[$type] ?? [] as $existing) {
571                    $diffCount = count(
572                        array_diff($constraint['fields'], $existing['fields'])
573                    ) + count(
574                        array_diff($existing['fields'], $constraint['fields'])
575                    );
576                    if ($diffCount == 0) {
577                        $matchFound = true;
578                        break;
579                    }
580                }
581                if (!$matchFound) {
582                    $missing[] = trim(rtrim($constraint['sql'], ','));
583                }
584            }
585        }
586        return $missing;
587    }
588
589    /**
590     * Get a list of missing constraints in the database tables (associative array,
591     * key = table name, value = array of missing constraint definitions).
592     *
593     * @param array $missingTables List of missing tables
594     *
595     * @throws \Exception
596     * @return array
597     */
598    public function getMissingConstraints($missingTables = [])
599    {
600        $missing = [];
601        foreach ($this->dbCommands as $table => $sql) {
602            // Skip missing tables if we're logging
603            if (in_array($table, $missingTables)) {
604                continue;
605            }
606
607            // Parse column names out of the CREATE TABLE SQL, which will always be
608            // the first entry in the array; we assume the standard mysqldump
609            // formatting is used here.
610            preg_match_all(
611                '/^  PRIMARY KEY \(`([^)]*)`\).*$/m',
612                $sql[0],
613                $primaryMatches
614            );
615            preg_match_all(
616                '/^  CONSTRAINT `([^`]+)` FOREIGN KEY \(`([^)]*)`\).*$/m',
617                $sql[0],
618                $foreignKeyMatches
619            );
620            preg_match_all(
621                '/^  UNIQUE KEY `([^`]+)`.*\(`([^)]*)`\).*$/m',
622                $sql[0],
623                $uniqueMatches
624            );
625            $expectedConstraints = [
626                'primary' => [
627                    'primary' => [
628                        'sql' => $primaryMatches[0][0],
629                        'fields' => $this->explodeFields($primaryMatches[1][0]),
630                    ],
631                ],
632            ];
633            foreach ($uniqueMatches[0] as $i => $sql) {
634                $expectedConstraints['unique'][$uniqueMatches[1][$i]] = [
635                    'sql' => $sql,
636                    'fields' => $this->explodeFields($uniqueMatches[2][$i]),
637                ];
638            }
639            foreach ($foreignKeyMatches[0] as $i => $sql) {
640                $expectedConstraints['foreign'][$foreignKeyMatches[1][$i]] = [
641                    'sql' => $sql,
642                    'fields' => $this->explodeFields($foreignKeyMatches[2][$i]),
643                ];
644            }
645
646            // Now check for missing columns and build our return array:
647            $actualConstraints = $this->getTableConstraints($table);
648
649            $mismatches = $this
650                ->compareConstraints($expectedConstraints, $actualConstraints);
651            if (!empty($mismatches)) {
652                $missing[$table] = $mismatches;
653            }
654        }
655        return $missing;
656    }
657
658    /**
659     * Normalize constraint values.
660     *
661     * @param array $constraints Constraints to normalize
662     *
663     * @return array
664     */
665    protected function normalizeConstraints($constraints)
666    {
667        foreach (['deleteRule', 'updateRule'] as $key) {
668            // NO ACTION and RESTRICT are equivalent in MySQL, but different
669            // versions return different values. Here we normalize them to RESTRICT
670            // for simplicity/consistency.
671            if ($constraints[$key] == 'NO ACTION') {
672                $constraints[$key] = 'RESTRICT';
673            }
674        }
675        return $constraints;
676    }
677
678    /**
679     * Compare expected vs. actual constraint actions and return an array of SQL
680     * clauses required to create the modified constraints.
681     *
682     * @param array $expected Expected constraints (based on mysql.sql)
683     * @param array $actual   Actual constraints (pulled from database metadata)
684     *
685     * @return array
686     */
687    protected function compareConstraintActions($expected, $actual)
688    {
689        $modified = [];
690        foreach ($expected as $type => $constraints) {
691            foreach ($constraints as $name => $constraint) {
692                if (!isset($actual[$type][$name])) {
693                    throw new \Exception(
694                        "Could not find constraint '$name' in actual constraints"
695                    );
696                }
697                $actualConstr = $this->normalizeConstraints($actual[$type][$name]);
698                if (
699                    $constraint['deleteRule'] !== $actualConstr['deleteRule']
700                    || $constraint['updateRule'] !== $actualConstr['updateRule']
701                ) {
702                    $modified[$name] = $constraint;
703                }
704            }
705        }
706        return $modified;
707    }
708
709    /**
710     * Support method for getModifiedConstraints() -- check if the current constraint
711     * is in the missing constraint list so we can avoid modifying something that
712     * does not exist.
713     *
714     * @param string $constraint Column to check
715     * @param array  $missing    Missing constraint list for constraint's table.
716     *
717     * @return bool
718     */
719    public function constraintIsMissing($constraint, $missing)
720    {
721        foreach ($missing as $current) {
722            preg_match('/^\s*CONSTRAINT\s*`([^`]*)`.*$/', $current, $matches);
723            if ($constraint == $matches[1]) {
724                return true;
725            }
726        }
727        return false;
728    }
729
730    /**
731     * Get a list of modified constraints in the database tables (associative array,
732     * key = table name, value = array of modified constraint definitions).
733     *
734     * @param array $missingTables      List of missing tables
735     * @param array $missingConstraints List of missing constraints
736     *
737     * @throws \Exception
738     * @return array
739     */
740    public function getModifiedConstraints(
741        $missingTables = [],
742        $missingConstraints = []
743    ) {
744        $modified = [];
745        foreach ($this->dbCommands as $table => $sql) {
746            // Skip missing tables if we're logging
747            if (in_array($table, $missingTables)) {
748                continue;
749            }
750
751            $expectedConstraints = [];
752
753            // Parse column names out of the CREATE TABLE SQL, which will always be
754            // the first entry in the array; we assume the standard mysqldump
755            // formatting is used here.
756            preg_match_all(
757                '/^\s*CONSTRAINT `([^`]+)` FOREIGN KEY \(`([^)]*)`\)(.*)$/m',
758                $sql[0],
759                $foreignKeyMatches
760            );
761            foreach ($foreignKeyMatches[0] as $i => $sql) {
762                $fkName = $foreignKeyMatches[1][$i];
763                // Skip constraint if we're logging and it's missing
764                if (
765                    isset($missingConstraints[$table])
766                    && $this->constraintIsMissing(
767                        $fkName,
768                        $missingConstraints[$table]
769                    )
770                ) {
771                    continue;
772                }
773
774                $deleteRule = 'RESTRICT';
775                $updateRule = 'RESTRICT';
776                $options = 'RESTRICT|CASCADE|SET NULL|NO ACTION|SET DEFAULT';
777                $actions = $foreignKeyMatches[3][$i] ?? '';
778                if (preg_match("/ON DELETE ($options)/", $actions, $matches)) {
779                    $deleteRule = $matches[1];
780                }
781                if (preg_match("/ON UPDATE ($options)/", $actions, $matches)) {
782                    $updateRule = $matches[1];
783                }
784
785                // Fix trailing whitespace/punctuation:
786                $sql = trim(trim($sql), ',;');
787
788                $expectedConstraints['foreign'][$fkName] = [
789                    'sql' => $sql,
790                    'fields' => $this->explodeFields($foreignKeyMatches[2][$i]),
791                    'deleteRule' => $deleteRule,
792                    'updateRule' => $updateRule,
793                ];
794            }
795
796            // Now check for missing columns and build our return array:
797            $actualConstraints = $this->getTableConstraints($table);
798
799            $mismatches = $this
800                ->compareConstraintActions($expectedConstraints, $actualConstraints);
801            if (!empty($mismatches)) {
802                $modified[$table]['foreign'] = $mismatches;
803            }
804        }
805        return $modified;
806    }
807
808    /**
809     * Given a current row default, return true if the current default matches the
810     * one found in the SQL provided as the $sql parameter. Return false if there
811     * is a mismatch that will require table structure updates.
812     *
813     * @param string $currentDefault Object to check
814     * @param string $sql            SQL to compare against
815     *
816     * @return bool
817     */
818    protected function defaultMatches($currentDefault, $sql)
819    {
820        // Normalize current default:
821        if ($currentDefault && strtoupper($currentDefault) === 'NULL') {
822            $currentDefault = null;
823        }
824        if (null !== $currentDefault) {
825            $currentDefault = trim($currentDefault, "'");
826            if (strtoupper($currentDefault) === 'CURRENT_TIMESTAMP()') {
827                $currentDefault = 'CURRENT_TIMESTAMP';
828            }
829        }
830
831        preg_match('/.* DEFAULT (.*)$/', $sql, $matches);
832        $expectedDefault = $matches[1] ?? null;
833        if (null !== $expectedDefault) {
834            $expectedDefault = trim(rtrim($expectedDefault, ','), "'");
835            $expectedDefault = (strtoupper($expectedDefault) == 'NULL')
836                ? null : $expectedDefault;
837        }
838        return $expectedDefault === $currentDefault;
839    }
840
841    /**
842     * Given a current row default, return true if the current nullability matches
843     * the one found in the SQL provided as the $sql parameter. Return false if there
844     * is a mismatch that will require table structure updates.
845     *
846     * @param bool   $currentNullable Current nullability
847     * @param string $sql             SQL to compare against
848     *
849     * @return bool
850     */
851    protected function nullableMatches(bool $currentNullable, string $sql): bool
852    {
853        $expectedNullable = stripos($sql, 'NOT NULL') ? false : true;
854        return $expectedNullable === $currentNullable;
855    }
856
857    /**
858     * Given a table column object, return true if the object's type matches the
859     * specified $type parameter. Return false if there is a mismatch that will
860     * require table structure updates.
861     *
862     * @param \Laminas\Db\Metadata\Object\ColumnObject $column       Object to check
863     * @param string                                   $expectedType Type to compare
864     *
865     * @return bool
866     */
867    protected function typeMatches($column, $expectedType)
868    {
869        // Get base type:
870        $type = $column->getDataType();
871
872        // If it's not a blob or a text (which don't have explicit sizes in our SQL),
873        // we should see what the character length is, if any:
874        if (
875            $type != 'blob' && $type != 'text' && $type !== 'mediumtext'
876            && $type != 'longtext' && $type != 'json'
877        ) {
878            $charLen = $column->getCharacterMaximumLength();
879            if ($charLen) {
880                $type .= '(' . $charLen . ')';
881            }
882        }
883
884        // If it's an integer, the expected type will have a parenthetical value;
885        // this is a display width which we can't retrieve using the column metadata
886        // object.  Since display width is not important to VuFind, we should ignore
887        // this factor when comparing things.
888        if (
889            $type == 'int' || $type == 'tinyint' || $type == 'smallint'
890            || $type == 'mediumint' || $type == 'bigint'
891        ) {
892            [$expectedType] = explode('(', $expectedType);
893        }
894
895        // Some versions of MariaDB store json fields as longtext, while MySQL
896        // actually has an explicit json type. We need a special case to handle
897        // this inconsistency. See: https://mariadb.com/kb/en/json-data-type/
898        return $type == $expectedType
899            || ($type === 'longtext' && $expectedType === 'json');
900    }
901
902    /**
903     * Parse keys from a "create table" statement
904     *
905     * @param string $createSql Create table statement
906     *
907     * @return array
908     */
909    protected function parseKeysFromCreateTable(string $createSql): array
910    {
911        $keys = [];
912        // Parse key names etc. out of the CREATE TABLE SQL, which will always be
913        // the first entry in the array; we assume the standard mysqldump
914        // formatting is used here.
915        preg_match_all(
916            '/^\s*(UNIQUE\s+)?KEY `([^`]+)` \((.+)\),?$/m',
917            $createSql,
918            $keyMatches
919        );
920        foreach (array_keys($keyMatches[0]) as $i) {
921            $unique = !empty($keyMatches[1][$i]);
922            $name = $keyMatches[2][$i];
923            // Normalize trailing whitespace and spaces after commas:
924            $definition = preg_replace('/,\s+`/', ',`', trim($keyMatches[3][$i]));
925
926            $keys[$name] = compact('unique', 'definition');
927        }
928        return $keys;
929    }
930
931    /**
932     * Support method for getModifiedColumns() -- check if the current column is
933     * in the missing column list so we can avoid modifying something that does
934     * not exist.
935     *
936     * @param string $column  Column to check
937     * @param array  $missing Missing column list for column's table.
938     *
939     * @return bool
940     */
941    public function columnIsMissing($column, $missing)
942    {
943        foreach ($missing as $current) {
944            preg_match('/^\s*`([^`]*)`.*$/', $current, $matches);
945            if ($column == $matches[1]) {
946                return true;
947            }
948        }
949        return false;
950    }
951
952    /**
953     * Get a list of deprecated columns found in the database.
954     *
955     * @return array
956     */
957    public function getDeprecatedColumns()
958    {
959        $result = [];
960        foreach ($this->deprecatedColumns as $table => $columns) {
961            $tableData = $this->getTableColumns(($table));
962            foreach ($columns as $column) {
963                if (isset($tableData[$column])) {
964                    $result[$table] = array_merge($result[$table] ?? [], [$column]);
965                }
966            }
967        }
968        return $result;
969    }
970
971    /**
972     * Get a list of changed columns in the database tables (associative array,
973     * key = table name, value = array of column name => new data type).
974     *
975     * @param array $missingTables  List of missing tables
976     * @param array $missingColumns List of missing columns
977     *
978     * @throws \Exception
979     * @return array
980     */
981    public function getModifiedColumns(
982        $missingTables = [],
983        $missingColumns = []
984    ) {
985        $modified = [];
986        $this->getTableInfo(true); // force reload of table info
987        foreach ($this->dbCommands as $table => $sql) {
988            // Skip missing tables if we're logging
989            if (in_array($table, $missingTables)) {
990                continue;
991            }
992
993            // Parse column names out of the CREATE TABLE SQL, which will always be
994            // the first entry in the array; we assume the standard mysqldump
995            // formatting is used here.
996            preg_match_all(
997                '/^  `([^`]*)`\s+([^\s,]+)[\t ,]+.*$/m',
998                $sql[0],
999                $matches
1000            );
1001            $expectedColumns = array_map('strtolower', $matches[1]);
1002            $expectedTypes = $matches[2];
1003
1004            // Create associative array of column name => SQL defining that column
1005            $columnDefinitions = [];
1006            foreach ($expectedColumns as $i => $name) {
1007                // Strip off any comments:
1008                $parts = explode('--', $matches[0][$i]);
1009
1010                // Fix trailing whitespace/punctuation:
1011                $columnDefinitions[$name] = trim(trim($parts[0]), ',;');
1012            }
1013
1014            // Now check for mismatched types:
1015            $actualColumns = $this->getTableColumns($table);
1016            foreach ($expectedColumns as $i => $column) {
1017                // Skip column if we're logging and it's missing
1018                if (
1019                    isset($missingColumns[$table])
1020                    && $this->columnIsMissing($column, $missingColumns[$table])
1021                ) {
1022                    continue;
1023                }
1024                $currentColumn = $actualColumns[$column];
1025                if (
1026                    !$this->typeMatches($currentColumn, $expectedTypes[$i])
1027                    || !$this->defaultMatches(
1028                        $currentColumn->getColumnDefault(),
1029                        $columnDefinitions[$column]
1030                    )
1031                    || !$this->nullableMatches(
1032                        $currentColumn->getIsNullable(),
1033                        $columnDefinitions[$column]
1034                    )
1035                ) {
1036                    if (!isset($modified[$table])) {
1037                        $modified[$table] = [];
1038                    }
1039                    $modified[$table][] = $columnDefinitions[$column];
1040                }
1041            }
1042        }
1043        return $modified;
1044    }
1045
1046    /**
1047     * Create missing columns based on the output of getMissingColumns().
1048     *
1049     * @param array $columns Output of getMissingColumns()
1050     * @param bool  $logsql  Should we return the SQL as a string rather than
1051     * execute it?
1052     *
1053     * @throws \Exception
1054     * @return string        SQL if $logsql is true, empty string otherwise
1055     */
1056    public function createMissingColumns($columns, $logsql = false)
1057    {
1058        $sqlcommands = '';
1059        foreach ($columns as $table => $sql) {
1060            foreach ($sql as $column) {
1061                $sqlcommands .= $this->query(
1062                    "ALTER TABLE `{$table}` ADD COLUMN {$column}",
1063                    $logsql
1064                );
1065            }
1066        }
1067        return $sqlcommands;
1068    }
1069
1070    /**
1071     * Create missing constraints based on the output of getMissingConstraints().
1072     *
1073     * @param array $constraints Output of getMissingConstraints()
1074     * @param bool  $logsql      Should we return the SQL as a string rather than
1075     * execute it?
1076     *
1077     * @throws \Exception
1078     * @return string        SQL if $logsql is true, empty string otherwise
1079     */
1080    public function createMissingConstraints($constraints, $logsql = false)
1081    {
1082        $sqlcommands = '';
1083        foreach ($constraints as $table => $sql) {
1084            foreach ($sql as $constraint) {
1085                $sqlcommands .= $this->query(
1086                    "ALTER TABLE $table ADD {$constraint};",
1087                    $logsql
1088                );
1089            }
1090        }
1091        return $sqlcommands;
1092    }
1093
1094    /**
1095     * Modify columns based on the output of getModifiedColumns().
1096     *
1097     * @param array $columns Output of getModifiedColumns()
1098     * @param bool  $logsql  Should we return the SQL as a string rather than
1099     * execute it?
1100     *
1101     * @throws \Exception
1102     * @return string        SQL if $logsql is true, empty string otherwise
1103     */
1104    public function updateModifiedColumns($columns, $logsql = false)
1105    {
1106        $sqlcommands = '';
1107        foreach ($columns as $table => $sql) {
1108            foreach ($sql as $column) {
1109                $sqlcommands .= $this->query(
1110                    "ALTER TABLE `{$table}` MODIFY COLUMN {$column}",
1111                    $logsql
1112                );
1113            }
1114        }
1115        return $sqlcommands;
1116    }
1117
1118    /**
1119     * Modify constraints based on the output of getModifiedConstraints().
1120     *
1121     * @param array $constraints Output of getModifiedConstraints()
1122     * @param bool  $logsql      Should we return the SQL as a string rather than
1123     * execute it?
1124     *
1125     * @throws \Exception
1126     * @return string            SQL if $logsql is true, empty string otherwise
1127     */
1128    public function updateModifiedConstraints($constraints, $logsql = false)
1129    {
1130        $sqlcommands = '';
1131        foreach ($constraints as $table => $constraintTypeList) {
1132            foreach ($constraintTypeList as $type => $constraintList) {
1133                if ('foreign' !== $type) {
1134                    throw new \Exception(
1135                        "Unable to handle modification of constraint type '$type'"
1136                    );
1137                }
1138                foreach ($constraintList as $name => $constraint) {
1139                    $sqlcommands .= $this->query(
1140                        "ALTER TABLE `{$table}` DROP FOREIGN KEY `{$name}`",
1141                        $logsql
1142                    );
1143                    $sqlcommands .= $this->query(
1144                        "ALTER TABLE $table ADD {$constraint['sql']}",
1145                        $logsql
1146                    );
1147                }
1148            }
1149        }
1150        return $sqlcommands;
1151    }
1152
1153    /**
1154     * Get a list of modified keys in the database tables (associative array,
1155     * key = table name, value = array of modified key definitions).
1156     *
1157     * @param array $missingTables List of missing tables
1158     *
1159     * @throws \Exception
1160     * @return array
1161     */
1162    public function getModifiedKeys(array $missingTables = []): array
1163    {
1164        $modified = [];
1165        foreach ($this->dbCommands as $table => $sql) {
1166            // Skip missing tables if we're logging
1167            if (in_array($table, $missingTables)) {
1168                continue;
1169            }
1170
1171            $expectedKeys = $this->parseKeysFromCreateTable($sql[0]);
1172
1173            $result = $this->getAdapter()->query(
1174                "SHOW CREATE TABLE $table",
1175                DbAdapter::QUERY_MODE_EXECUTE
1176            )->current();
1177            $resultArray = $result ? $result->getArrayCopy() : [''];
1178            $actualCreateSQL = end($resultArray);
1179            $actualKeys = $this->parseKeysFromCreateTable($actualCreateSQL);
1180
1181            // Create lists of keys to drop and add:
1182            $add = [];
1183            // Should we want to drop any keys not found in our database definition
1184            // so that it would be possible to e.g. drop columns if necessary, the
1185            // following line could be used:
1186            //$drop = array_diff(array_keys($actualKeys), array_keys($expectedKeys));
1187            $drop = [];
1188            foreach ($expectedKeys as $name => $expected) {
1189                if (!isset($actualKeys[$name])) {
1190                    $add[$name] = $expected;
1191                } elseif (
1192                    $actualKeys[$name]['unique'] !== $expected['unique']
1193                    || $actualKeys[$name]['definition'] !== $expected['definition']
1194                ) {
1195                    $drop[] = $name;
1196                    $add[$name] = $expected;
1197                }
1198            }
1199            if ($add || $drop) {
1200                $modified[$table] = compact('add', 'drop');
1201            }
1202        }
1203        return $modified;
1204    }
1205
1206    /**
1207     * Update keys based on the output of getModifiedKeys().
1208     *
1209     * @param array $tables Output of getModifiedKeys()
1210     * @param bool  $logsql Should we return the SQL as a string rather than
1211     * execute it?
1212     *
1213     * @throws \Exception
1214     * @return string       SQL if $logsql is true, empty string otherwise
1215     */
1216    public function updateModifiedKeys($tables, $logsql = false)
1217    {
1218        $sqlcommands = '';
1219        foreach ($tables as $table => $newSettings) {
1220            foreach ($newSettings['drop'] as $key) {
1221                $sql = "ALTER TABLE `$table` DROP KEY `$key`";
1222                $sqlcommands .= $this->query($sql, $logsql);
1223            }
1224            foreach ($newSettings['add'] as $keyName => $keyDetails) {
1225                $sql = "ALTER TABLE `$table` ADD "
1226                    . ($keyDetails['unique'] ? 'UNIQUE ' : '')
1227                    . "KEY `$keyName` ({$keyDetails['definition']})";
1228                $sqlcommands .= $this->query($sql, $logsql);
1229            }
1230        }
1231        return $sqlcommands;
1232    }
1233}