Relation::foreignDropdown()   B
last analyzed

Complexity

Conditions 11
Paths 108

Size

Total Lines 56
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 132

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 26
c 1
b 0
f 0
dl 0
loc 56
ccs 0
cts 27
cp 0
rs 7.25
cc 11
nc 108
nop 5
crap 132

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace PhpMyAdmin\ConfigStorage;
6
7
use PhpMyAdmin\Config;
8
use PhpMyAdmin\ConfigStorage\Features\PdfFeature;
9
use PhpMyAdmin\Current;
10
use PhpMyAdmin\DatabaseInterface;
11
use PhpMyAdmin\Dbal\ConnectionType;
12
use PhpMyAdmin\Identifiers\DatabaseName;
0 ignored issues
show
Bug introduced by
The type PhpMyAdmin\Identifiers\DatabaseName was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
13
use PhpMyAdmin\Identifiers\TableName;
0 ignored issues
show
Bug introduced by
The type PhpMyAdmin\Identifiers\TableName was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
14
use PhpMyAdmin\InternalRelations;
15
use PhpMyAdmin\SqlParser\Parser;
16
use PhpMyAdmin\SqlParser\Statements\CreateStatement;
17
use PhpMyAdmin\SqlParser\Utils\Table as TableUtils;
18
use PhpMyAdmin\Table\Table;
19
use PhpMyAdmin\Util;
20
use PhpMyAdmin\Version;
21
22
use function __;
23
use function array_fill_keys;
24
use function array_keys;
25
use function array_reverse;
26
use function array_search;
27
use function array_shift;
28
use function asort;
29
use function bin2hex;
30
use function count;
31
use function explode;
32
use function file_get_contents;
33
use function htmlspecialchars;
34
use function implode;
35
use function in_array;
36
use function is_string;
37
use function ksort;
38
use function mb_check_encoding;
39
use function mb_strlen;
40
use function mb_strtolower;
41
use function mb_strtoupper;
42
use function mb_substr;
43
use function natcasesort;
44
use function preg_match;
45
use function sprintf;
46
use function str_contains;
47
use function str_replace;
48
use function strnatcasecmp;
49
use function trim;
50
use function uksort;
51
use function usort;
52
53
use const SQL_DIR;
54
55
/**
56
 * Set of functions used with the relation and PDF feature
57
 */
58
class Relation
59
{
60
    private static RelationParameters|null $cache = null;
61
    private readonly Config $config;
62
63 116
    public function __construct(public DatabaseInterface $dbi, Config|null $config = null)
64
    {
65 116
        $this->config = $config ?? Config::getInstance();
0 ignored issues
show
Bug introduced by
The property config is declared read-only in PhpMyAdmin\ConfigStorage\Relation.
Loading history...
Deprecated Code introduced by
The function PhpMyAdmin\Config::getInstance() has been deprecated: Use dependency injection instead. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

65
        $this->config = $config ?? /** @scrutinizer ignore-deprecated */ Config::getInstance();

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
66
    }
67
68 76
    public function getRelationParameters(): RelationParameters
69
    {
70 76
        if (self::$cache === null) {
71 48
            self::$cache = RelationParameters::fromArray($this->checkRelationsParam());
72
        }
73
74 76
        return self::$cache;
0 ignored issues
show
Bug Best Practice introduced by
The expression return self::cache could return the type null which is incompatible with the type-hinted return PhpMyAdmin\ConfigStorage\RelationParameters. Consider adding an additional type-check to rule them out.
Loading history...
75
    }
76
77
    /**
78
     * @param array<string, bool|string|null> $relationParams
79
     *
80
     * @return array<string, bool|string|null>
81
     */
82 32
    private function checkTableAccess(array $relationParams): array
83
    {
84 32
        if (isset($relationParams['relation'], $relationParams['table_info'])) {
85
            if ($this->canAccessStorageTable((string) $relationParams['table_info'])) {
86
                $relationParams['displaywork'] = true;
87
            }
88
        }
89
90 32
        if (isset($relationParams['table_coords'], $relationParams['pdf_pages'])) {
91
            if ($this->canAccessStorageTable((string) $relationParams['table_coords'])) {
92
                if ($this->canAccessStorageTable((string) $relationParams['pdf_pages'])) {
93
                    $relationParams['pdfwork'] = true;
94
                }
95
            }
96
        }
97
98 32
        if (isset($relationParams['column_info'])) {
99
            if ($this->canAccessStorageTable((string) $relationParams['column_info'])) {
100
                $relationParams['commwork'] = true;
101
                // phpMyAdmin 4.3+
102
                // Check for input transformations upgrade.
103
                $relationParams['mimework'] = $this->tryUpgradeTransformations();
104
            }
105
        }
106
107 32
        if (isset($relationParams['users'], $relationParams['usergroups'])) {
108
            if ($this->canAccessStorageTable((string) $relationParams['users'])) {
109
                if ($this->canAccessStorageTable((string) $relationParams['usergroups'])) {
110
                    $relationParams['menuswork'] = true;
111
                }
112
            }
113
        }
114
115 32
        $settings = [
116 32
            'export_templates' => 'exporttemplateswork',
117 32
            'designer_settings' => 'designersettingswork',
118 32
            'central_columns' => 'centralcolumnswork',
119 32
            'savedsearches' => 'savedsearcheswork',
120 32
            'navigationhiding' => 'navwork',
121 32
            'bookmark' => 'bookmarkwork',
122 32
            'userconfig' => 'userconfigwork',
123 32
            'tracking' => 'trackingwork',
124 32
            'table_uiprefs' => 'uiprefswork',
125 32
            'favorite' => 'favoritework',
126 32
            'recent' => 'recentwork',
127 32
            'history' => 'historywork',
128 32
            'relation' => 'relwork',
129 32
        ];
130
131 32
        foreach ($settings as $setingName => $worksKey) {
132 32
            if (! isset($relationParams[$setingName])) {
133 32
                continue;
134
            }
135
136 28
            if (! $this->canAccessStorageTable((string) $relationParams[$setingName])) {
137 4
                continue;
138
            }
139
140 24
            $relationParams[$worksKey] = true;
141
        }
142
143 32
        return $relationParams;
144
    }
145
146
    /**
147
     * @param array<string, bool|string|null> $relationParams
148
     *
149
     * @return array<string, bool|string|null>|null
150
     */
151 40
    private function fillRelationParamsWithTableNames(array $relationParams): array|null
152
    {
153 40
        if ($this->arePmadbTablesAllDisabled()) {
154
            return null;
155
        }
156
157 40
        $tables = $this->dbi->getTables($this->config->selectedServer['pmadb'], ConnectionType::ControlUser);
158 40
        if ($tables === []) {
159 8
            return null;
160
        }
161
162 32
        foreach ($tables as $table) {
163 32
            if ($table == $this->config->selectedServer['bookmarktable']) {
164
                $relationParams['bookmark'] = $table;
165 32
            } elseif ($table == $this->config->selectedServer['relation']) {
166
                $relationParams['relation'] = $table;
167 32
            } elseif ($table == $this->config->selectedServer['table_info']) {
168
                $relationParams['table_info'] = $table;
169 32
            } elseif ($table == $this->config->selectedServer['table_coords']) {
170
                $relationParams['table_coords'] = $table;
171 32
            } elseif ($table == $this->config->selectedServer['column_info']) {
172
                $relationParams['column_info'] = $table;
173 32
            } elseif ($table == $this->config->selectedServer['pdf_pages']) {
174
                $relationParams['pdf_pages'] = $table;
175 32
            } elseif ($table == $this->config->selectedServer['history']) {
176
                $relationParams['history'] = $table;
177 32
            } elseif ($table == $this->config->selectedServer['recent']) {
178
                $relationParams['recent'] = $table;
179 32
            } elseif ($table == $this->config->selectedServer['favorite']) {
180 4
                $relationParams['favorite'] = $table;
181 28
            } elseif ($table == $this->config->selectedServer['table_uiprefs']) {
182
                $relationParams['table_uiprefs'] = $table;
183 28
            } elseif ($table == $this->config->selectedServer['tracking']) {
184
                $relationParams['tracking'] = $table;
185 28
            } elseif ($table == $this->config->selectedServer['userconfig']) {
186 24
                $relationParams['userconfig'] = $table;
187 8
            } elseif ($table == $this->config->selectedServer['users']) {
188
                $relationParams['users'] = $table;
189 8
            } elseif ($table == $this->config->selectedServer['usergroups']) {
190
                $relationParams['usergroups'] = $table;
191 8
            } elseif ($table == $this->config->selectedServer['navigationhiding']) {
192
                $relationParams['navigationhiding'] = $table;
193 8
            } elseif ($table == $this->config->selectedServer['savedsearches']) {
194
                $relationParams['savedsearches'] = $table;
195 8
            } elseif ($table == $this->config->selectedServer['central_columns']) {
196
                $relationParams['central_columns'] = $table;
197 8
            } elseif ($table == $this->config->selectedServer['designer_settings']) {
198
                $relationParams['designer_settings'] = $table;
199 8
            } elseif ($table == $this->config->selectedServer['export_templates']) {
200
                $relationParams['export_templates'] = $table;
201
            }
202
        }
203
204 32
        return $relationParams;
205
    }
206
207
    /**
208
     * Defines the relation parameters for the current user
209
     * just a copy of the functions used for relations ;-)
210
     * but added some stuff to check what will work
211
     *
212
     * @return array<string, bool|string|null> the relation parameters for the current user
213
     */
214 48
    private function checkRelationsParam(): array
215
    {
216 48
        $workToTable = [
217 48
            'relwork' => 'relation',
218 48
            'displaywork' => ['relation', 'table_info'],
219 48
            'bookmarkwork' => 'bookmarktable',
220 48
            'pdfwork' => ['table_coords', 'pdf_pages'],
221 48
            'commwork' => 'column_info',
222 48
            'mimework' => 'column_info',
223 48
            'historywork' => 'history',
224 48
            'recentwork' => 'recent',
225 48
            'favoritework' => 'favorite',
226 48
            'uiprefswork' => 'table_uiprefs',
227 48
            'trackingwork' => 'tracking',
228 48
            'userconfigwork' => 'userconfig',
229 48
            'menuswork' => ['users', 'usergroups'],
230 48
            'navwork' => 'navigationhiding',
231 48
            'savedsearcheswork' => 'savedsearches',
232 48
            'centralcolumnswork' => 'central_columns',
233 48
            'designersettingswork' => 'designer_settings',
234 48
            'exporttemplateswork' => 'export_templates',
235 48
        ];
236
237 48
        $relationParams = array_fill_keys(array_keys($workToTable), false);
238
239 48
        $relationParams['version'] = Version::VERSION;
240 48
        $relationParams['allworks'] = false;
241 48
        $relationParams['user'] = null;
242 48
        $relationParams['db'] = null;
243
244
        if (
245 48
            Current::$server === 0
246 44
            || $this->config->selectedServer['pmadb'] === ''
247 48
            || ! $this->dbi->selectDb($this->config->selectedServer['pmadb'], ConnectionType::ControlUser)
248
        ) {
249 8
            $this->config->selectedServer['pmadb'] = '';
250
251 8
            return $relationParams;
252
        }
253
254 40
        $relationParams['user'] = $this->config->selectedServer['user'];
255 40
        $relationParams['db'] = $this->config->selectedServer['pmadb'];
256
257 40
        $relationParamsFilled = $this->fillRelationParamsWithTableNames($relationParams);
258
259 40
        if ($relationParamsFilled === null) {
260 8
            return $relationParams;
261
        }
262
263 32
        $relationParams = $this->checkTableAccess($relationParamsFilled);
264
265 32
        $allWorks = true;
266 32
        foreach ($workToTable as $work => $table) {
267 32
            if ($relationParams[$work]) {
268
                continue;
269
            }
270
271 32
            if (is_string($table)) {
272 32
                if (isset($this->config->selectedServer[$table]) && $this->config->selectedServer[$table] !== false) {
273 32
                    $allWorks = false;
274 32
                    break;
275
                }
276
            } else {
277
                $oneNull = false;
278
                foreach ($table as $t) {
279
                    if (isset($this->config->selectedServer[$t]) && $this->config->selectedServer[$t] === false) {
280
                        $oneNull = true;
281
                        break;
282
                    }
283
                }
284
285
                if (! $oneNull) {
286
                    $allWorks = false;
287
                    break;
288
                }
289
            }
290
        }
291
292 32
        $relationParams['allworks'] = $allWorks;
293
294 32
        return $relationParams;
295
    }
296
297
    /**
298
     * Check if the table is accessible
299
     *
300
     * @param string $tableDbName The table or table.db
301
     */
302 28
    public function canAccessStorageTable(string $tableDbName): bool
303
    {
304 28
        $result = $this->dbi->tryQueryAsControlUser('SELECT NULL FROM ' . Util::backquote($tableDbName) . ' LIMIT 0');
305
306 28
        return $result !== false;
307
    }
308
309
    /**
310
     * Check whether column_info table input transformation
311
     * upgrade is required and try to upgrade silently
312
     */
313 4
    public function tryUpgradeTransformations(): bool
314
    {
315
        // From 4.3, new input oriented transformation feature was introduced.
316
        // Check whether column_info table has input transformation columns
317 4
        $newCols = ['input_transformation', 'input_transformation_options'];
318 4
        $query = 'SHOW COLUMNS FROM '
319 4
            . Util::backquote($this->config->selectedServer['pmadb'])
320 4
            . '.' . Util::backquote($this->config->selectedServer['column_info'])
321 4
            . ' WHERE Field IN (\'' . implode('\', \'', $newCols) . '\')';
322 4
        $result = $this->dbi->tryQueryAsControlUser($query);
323 4
        if ($result) {
324 4
            $rows = $result->numRows();
325 4
            unset($result);
326
            // input transformations are present
327
            // no need to upgrade
328 4
            if ($rows === 2) {
329
                return true;
330
331
                // try silent upgrade without disturbing the user
332
            }
333
334
            // read upgrade query file
335 4
            $query = @file_get_contents(SQL_DIR . 'upgrade_column_info_4_3_0+.sql');
336
            // replace database name from query to with set in config.inc.php
337
            // replace pma__column_info table name from query
338
            // to with set in config.inc.php
339 4
            $query = str_replace(
340 4
                ['`phpmyadmin`', '`pma__column_info`'],
341 4
                [
342 4
                    Util::backquote($this->config->selectedServer['pmadb']),
343 4
                    Util::backquote($this->config->selectedServer['column_info']),
344 4
                ],
345 4
                (string) $query,
346 4
            );
347 4
            $this->dbi->tryMultiQuery($query, ConnectionType::ControlUser);
348
            // skips result sets of query as we are not interested in it
349
            /** @infection-ignore-all */
350
            do {
351 4
                $hasResult = $this->dbi->nextResult(ConnectionType::ControlUser);
352 4
            } while ($hasResult !== false);
353
354 4
            $error = $this->dbi->getError(ConnectionType::ControlUser);
355
356
            // return true if no error exists otherwise false
357 4
            return $error === '';
358
        }
359
360
        // some failure, either in upgrading or something else
361
        // make some noise, time to wake up user.
362
        return false;
363
    }
364
365
    /**
366
     * Gets all Relations to foreign tables for a given table or
367
     * optionally a given column in a table
368
     *
369
     * @param string $db     the name of the db to check for
370
     * @param string $table  the name of the table to check for
371
     * @param string $column the name of the column to check for
372
     * @param string $source the source for foreign key information
373
     *
374
     * @return mixed[]    db,table,column
375
     */
376
    public function getForeigners(string $db, string $table, string $column = '', string $source = 'both'): array
377
    {
378
        $relationFeature = $this->getRelationParameters()->relationFeature;
379
        $foreign = [];
380
381
        if ($relationFeature !== null && ($source === 'both' || $source === 'internal')) {
382
            $relQuery = 'SELECT `master_field`, `foreign_db`, '
383
                . '`foreign_table`, `foreign_field`'
384
                . ' FROM ' . Util::backquote($relationFeature->database)
385
                . '.' . Util::backquote($relationFeature->relation)
386
                . ' WHERE `master_db` = ' . $this->dbi->quoteString($db)
387
                . ' AND `master_table` = ' . $this->dbi->quoteString($table);
388
            if ($column !== '') {
389
                $relQuery .= ' AND `master_field` = ' . $this->dbi->quoteString($column);
390
            }
391
392
            $foreign = $this->dbi->fetchResult($relQuery, 'master_field', null, ConnectionType::ControlUser);
393
        }
394
395
        if (($source === 'both' || $source === 'foreign') && $table !== '') {
396
            $tableObj = new Table($table, $db, $this->dbi);
397
            $showCreateTable = $tableObj->showCreate();
398
            if ($showCreateTable !== '') {
399
                $parser = new Parser($showCreateTable);
400
                $stmt = $parser->statements[0];
401
                $foreign['foreign_keys_data'] = [];
402
                if ($stmt instanceof CreateStatement) {
403
                    $foreign['foreign_keys_data'] = TableUtils::getForeignKeys($stmt);
404
                }
405
            }
406
        }
407
408
        /**
409
         * Emulating relations for some information_schema tables
410
         */
411
        $isInformationSchema = mb_strtolower($db) === 'information_schema';
412
        $isMysql = mb_strtolower($db) === 'mysql';
413
        if (($isInformationSchema || $isMysql) && ($source === 'internal' || $source === 'both')) {
414
            if ($isInformationSchema) {
415
                $internalRelations = InternalRelations::INFORMATION_SCHEMA;
416
            } else {
417
                $internalRelations = InternalRelations::MYSQL;
418
            }
419
420
            if (isset($internalRelations[$table])) {
421
                foreach ($internalRelations[$table] as $field => $relations) {
422
                    if (
423
                        ($column !== '' && $column != $field)
424
                        || (isset($foreign[$field])
425
                        && $foreign[$field] != '')
426
                    ) {
427
                        continue;
428
                    }
429
430
                    $foreign[$field] = $relations;
431
                }
432
            }
433
        }
434
435
        return $foreign;
436
    }
437
438
    /**
439
     * Gets the display field of a table
440
     *
441
     * @param string $db    the name of the db to check for
442
     * @param string $table the name of the table to check for
443
     *
444
     * @return string field name
445
     */
446 4
    public function getDisplayField(string $db, string $table): string
447
    {
448 4
        $displayFeature = $this->getRelationParameters()->displayFeature;
449
450
        /**
451
         * Try to fetch the display field from DB.
452
         */
453 4
        if ($displayFeature !== null) {
454
            $dispQuery = 'SELECT `display_field`'
455
                    . ' FROM ' . Util::backquote($displayFeature->database)
456
                    . '.' . Util::backquote($displayFeature->tableInfo)
457
                    . ' WHERE `db_name` = ' . $this->dbi->quoteString($db)
458
                    . ' AND `table_name` = ' . $this->dbi->quoteString($table);
459
460
            $row = $this->dbi->fetchSingleRow($dispQuery, DatabaseInterface::FETCH_ASSOC, ConnectionType::ControlUser);
461
            if (isset($row['display_field'])) {
462
                return $row['display_field'];
463
            }
464
        }
465
466
        /**
467
         * Emulating the display field for some information_schema tables.
468
         */
469 4
        if ($db === 'information_schema') {
470
            switch ($table) {
471 4
                case 'CHARACTER_SETS':
472 4
                    return 'DESCRIPTION';
473
474 4
                case 'TABLES':
475 4
                    return 'TABLE_COMMENT';
476
            }
477
        }
478
479
        /**
480
         * Pick first char field
481
         */
482 4
        $columns = $this->dbi->getColumnsFull($db, $table);
483 4
        foreach ($columns as $column) {
484
            if ($this->dbi->types->getTypeClass($column['DATA_TYPE']) === 'CHAR') {
485
                return $column['COLUMN_NAME'];
486
            }
487
        }
488
489 4
        return '';
490
    }
491
492
    /**
493
     * Gets the comments for all columns of a table or the db itself
494
     *
495
     * @param string $db    the name of the db to check for
496
     * @param string $table the name of the table to check for
497
     *
498
     * @return string[]    [column_name] = comment
499
     */
500 4
    public function getComments(string $db, string $table = ''): array
501
    {
502 4
        if ($table === '') {
503 4
            return [$this->getDbComment($db)];
504
        }
505
506 4
        $comments = [];
507
508
        // MySQL native column comments
509 4
        $columns = $this->dbi->getColumns($db, $table, true);
510 4
        foreach ($columns as $column) {
511 4
            if ($column->comment === '') {
512
                continue;
513
            }
514
515 4
            $comments[$column->field] = $column->comment;
516
        }
517
518 4
        return $comments;
519
    }
520
521
    /**
522
     * Gets the comment for a db
523
     *
524
     * @param string $db the name of the db to check for
525
     */
526 4
    public function getDbComment(string $db): string
527
    {
528 4
        $columnCommentsFeature = $this->getRelationParameters()->columnCommentsFeature;
529 4
        if ($columnCommentsFeature !== null) {
530
            // pmadb internal db comment
531
            $comQry = 'SELECT `comment`'
532
                    . ' FROM ' . Util::backquote($columnCommentsFeature->database)
533
                    . '.' . Util::backquote($columnCommentsFeature->columnInfo)
534
                    . ' WHERE db_name = ' . $this->dbi->quoteString($db, ConnectionType::ControlUser)
535
                    . ' AND table_name  = \'\''
536
                    . ' AND column_name = \'(db_comment)\'';
537
            $comRs = $this->dbi->tryQueryAsControlUser($comQry);
538
539
            if ($comRs && $comRs->numRows() > 0) {
540
                $row = $comRs->fetchAssoc();
541
542
                return (string) $row['comment'];
543
            }
544
        }
545
546 4
        return '';
547
    }
548
549
    /**
550
     * Set a database comment to a certain value.
551
     *
552
     * @param string $db      the name of the db
553
     * @param string $comment the value of the column
554
     */
555
    public function setDbComment(string $db, string $comment = ''): bool
556
    {
557
        $columnCommentsFeature = $this->getRelationParameters()->columnCommentsFeature;
558
        if ($columnCommentsFeature === null) {
559
            return false;
560
        }
561
562
        if ($comment !== '') {
563
            $updQuery = 'INSERT INTO '
564
                . Util::backquote($columnCommentsFeature->database) . '.'
565
                . Util::backquote($columnCommentsFeature->columnInfo)
566
                . ' (`db_name`, `table_name`, `column_name`, `comment`)'
567
                . ' VALUES ('
568
                . $this->dbi->quoteString($db, ConnectionType::ControlUser)
569
                . ", '', '(db_comment)', "
570
                . $this->dbi->quoteString($comment, ConnectionType::ControlUser)
571
                . ') '
572
                . ' ON DUPLICATE KEY UPDATE '
573
                . '`comment` = ' . $this->dbi->quoteString($comment, ConnectionType::ControlUser);
574
        } else {
575
            $updQuery = 'DELETE FROM '
576
                . Util::backquote($columnCommentsFeature->database) . '.'
577
                . Util::backquote($columnCommentsFeature->columnInfo)
578
                . ' WHERE `db_name`     = ' . $this->dbi->quoteString($db, ConnectionType::ControlUser)
579
                . '
580
                    AND `table_name`  = \'\'
581
                    AND `column_name` = \'(db_comment)\'';
582
        }
583
584
        return (bool) $this->dbi->queryAsControlUser($updQuery);
585
    }
586
587
    /**
588
     * Set a SQL history entry
589
     *
590
     * @param string $db       the name of the db
591
     * @param string $table    the name of the table
592
     * @param string $username the username
593
     * @param string $sqlquery the sql query
594
     */
595
    public function setHistory(string $db, string $table, string $username, string $sqlquery): void
596
    {
597
        $maxCharactersInDisplayedSQL = $this->config->settings['MaxCharactersInDisplayedSQL'];
598
        // Prevent to run this automatically on Footer class destroying in testsuite
599
        if (mb_strlen($sqlquery) > $maxCharactersInDisplayedSQL) {
600
            return;
601
        }
602
603
        $sqlHistoryFeature = $this->getRelationParameters()->sqlHistoryFeature;
604
605
        if (! isset($_SESSION['sql_history'])) {
606
            $_SESSION['sql_history'] = [];
607
        }
608
609
        $_SESSION['sql_history'][] = ['db' => $db, 'table' => $table, 'sqlquery' => $sqlquery];
610
611
        if (count($_SESSION['sql_history']) > $this->config->settings['QueryHistoryMax']) {
612
            // history should not exceed a maximum count
613
            array_shift($_SESSION['sql_history']);
614
        }
615
616
        if ($sqlHistoryFeature === null || ! $this->config->settings['QueryHistoryDB']) {
617
            return;
618
        }
619
620
        $this->dbi->queryAsControlUser(
621
            'INSERT INTO '
622
            . Util::backquote($sqlHistoryFeature->database) . '.'
623
            . Util::backquote($sqlHistoryFeature->history) . '
624
                  (`username`,
625
                    `db`,
626
                    `table`,
627
                    `timevalue`,
628
                    `sqlquery`)
629
            VALUES
630
                  (' . $this->dbi->quoteString($username, ConnectionType::ControlUser) . ',
631
                   ' . $this->dbi->quoteString($db, ConnectionType::ControlUser) . ',
632
                   ' . $this->dbi->quoteString($table, ConnectionType::ControlUser) . ',
633
                   NOW(),
634
                   ' . $this->dbi->quoteString($sqlquery, ConnectionType::ControlUser) . ')',
635
        );
636
637
        $this->purgeHistory($username);
638
    }
639
640
    /**
641
     * Gets a SQL history entry
642
     *
643
     * @param string $username the username
644
     *
645
     * @return mixed[]|bool list of history items
646
     */
647
    public function getHistory(string $username): array|bool
648
    {
649
        $sqlHistoryFeature = $this->getRelationParameters()->sqlHistoryFeature;
650
        if ($sqlHistoryFeature === null) {
651
            return false;
652
        }
653
654
        /**
655
         * if db-based history is disabled but there exists a session-based
656
         * history, use it
657
         */
658
        if (! $this->config->settings['QueryHistoryDB']) {
659
            if (isset($_SESSION['sql_history'])) {
660
                return array_reverse($_SESSION['sql_history']);
661
            }
662
663
            return false;
664
        }
665
666
        $histQuery = '
667
             SELECT `db`,
668
                    `table`,
669
                    `sqlquery`,
670
                    `timevalue`
671
               FROM ' . Util::backquote($sqlHistoryFeature->database)
672
                . '.' . Util::backquote($sqlHistoryFeature->history) . '
673
              WHERE `username` = ' . $this->dbi->quoteString($username) . '
674
           ORDER BY `id` DESC';
675
676
        return $this->dbi->fetchResult($histQuery, null, null, ConnectionType::ControlUser);
677
    }
678
679
    /**
680
     * purges SQL history
681
     *
682
     * deletes entries that exceeds $cfg['QueryHistoryMax'], oldest first, for the
683
     * given user
684
     *
685
     * @param string $username the username
686
     */
687
    public function purgeHistory(string $username): void
688
    {
689
        $sqlHistoryFeature = $this->getRelationParameters()->sqlHistoryFeature;
690
        if (! $this->config->settings['QueryHistoryDB'] || $sqlHistoryFeature === null) {
691
            return;
692
        }
693
694
        $searchQuery = '
695
            SELECT `timevalue`
696
            FROM ' . Util::backquote($sqlHistoryFeature->database)
697
                . '.' . Util::backquote($sqlHistoryFeature->history) . '
698
            WHERE `username` = ' . $this->dbi->quoteString($username) . '
699
            ORDER BY `timevalue` DESC
700
            LIMIT ' . $this->config->settings['QueryHistoryMax'] . ', 1';
701
702
        $maxTime = $this->dbi->fetchValue($searchQuery, 0, ConnectionType::ControlUser);
703
704
        if (! $maxTime) {
705
            return;
706
        }
707
708
        $this->dbi->queryAsControlUser(
709
            'DELETE FROM '
710
            . Util::backquote($sqlHistoryFeature->database) . '.'
711
            . Util::backquote($sqlHistoryFeature->history) . '
712
              WHERE `username` = ' . $this->dbi->quoteString($username, ConnectionType::ControlUser)
713
            . '
714
                AND `timevalue` <= \'' . $maxTime . '\'',
715
        );
716
    }
717
718
    /**
719
     * Prepares the dropdown for one mode
720
     *
721
     * @param mixed[] $foreign the keys and values for foreigns
722
     * @param string  $data    the current data of the dropdown
723
     * @param string  $mode    the needed mode
724
     *
725
     * @return string[] the <option value=""><option>s
726
     */
727
    public function buildForeignDropdown(array $foreign, string $data, string $mode): array
728
    {
729
        $reloptions = [];
730
731
        // id-only is a special mode used when no foreign display column
732
        // is available
733
        if ($mode === 'id-content' || $mode === 'id-only') {
734
            // sort for id-content
735
            if ($this->config->settings['NaturalOrder']) {
736
                uksort($foreign, strnatcasecmp(...));
0 ignored issues
show
Bug introduced by
The type strnatcasecmp was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
737
            } else {
738
                ksort($foreign);
739
            }
740
        } elseif ($mode === 'content-id') {
741
            // sort for content-id
742
            if ($this->config->settings['NaturalOrder']) {
743
                natcasesort($foreign);
744
            } else {
745
                asort($foreign);
746
            }
747
        }
748
749
        foreach ($foreign as $key => $value) {
750
            $key = (string) $key;
751
            $value = (string) $value;
752
753
            if (mb_check_encoding($key, 'utf-8') && ! preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x80-\x9F]/u', $key)) {
754
                $selected = $key === $data;
755
                // show as text if it's valid utf-8
756
                $key = htmlspecialchars($key);
757
            } else {
758
                $key = '0x' . bin2hex($key);
759
                if (str_contains($data, '0x')) {
760
                    $selected = $key === trim($data);
761
                } else {
762
                    $selected = $key === '0x' . $data;
763
                }
764
            }
765
766
            if (
767
                mb_check_encoding($value, 'utf-8')
768
                && ! preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x80-\x9F]/u', $value)
769
            ) {
770
                if (mb_strlen($value) <= $this->config->settings['LimitChars']) {
771
                    // show as text if it's valid utf-8
772
                    $value = htmlspecialchars($value);
773
                } else {
774
                    // show as truncated text if it's valid utf-8
775
                    $value = htmlspecialchars(
776
                        mb_substr(
777
                            $value,
778
                            0,
779
                            $this->config->settings['LimitChars'],
780
                        ) . '...',
781
                    );
782
                }
783
            } else {
784
                $value = '0x' . bin2hex($value);
785
            }
786
787
            $reloption = '<option value="' . $key . '"';
788
789
            if ($selected) {
790
                $reloption .= ' selected="selected"';
791
            }
792
793
            if ($mode === 'content-id') {
794
                $reloptions[] = $reloption . '>'
795
                    . $value . '&nbsp;-&nbsp;' . $key . '</option>';
796
            } elseif ($mode === 'id-content') {
797
                $reloptions[] = $reloption . '>'
798
                    . $key . '&nbsp;-&nbsp;' . $value . '</option>';
799
            } elseif ($mode === 'id-only') {
800
                $reloptions[] = $reloption . '>'
801
                    . $key . '</option>';
802
            }
803
        }
804
805
        return $reloptions;
806
    }
807
808
    /**
809
     * Outputs dropdown with values of foreign fields
810
     *
811
     * @param mixed[][] $dispRow        array of the displayed row
812
     * @param string    $foreignField   the foreign field
813
     * @param string    $foreignDisplay the foreign field to display
814
     * @param string    $data           the current data of the dropdown (field in row)
815
     * @param int|null  $max            maximum number of items in the dropdown
816
     *
817
     * @return string   the <option value=""><option>s
818
     */
819
    public function foreignDropdown(
820
        array $dispRow,
821
        string $foreignField,
822
        string $foreignDisplay,
823
        string $data,
824
        int|null $max = null,
825
    ): string {
826
        if ($max === null) {
827
            $max = $this->config->settings['ForeignKeyMaxLimit'];
828
        }
829
830
        $foreign = [];
831
832
        // collect the data
833
        foreach ($dispRow as $relrow) {
834
            $key = $relrow[$foreignField];
835
836
            // if the display field has been defined for this foreign table
837
            $value = $foreignDisplay !== '' ? $relrow[$foreignDisplay] : '';
838
839
            $foreign[$key] = $value;
840
        }
841
842
        // put the dropdown sections in correct order
843
        $bottom = [];
844
        if ($foreignDisplay !== '') {
845
            $top = $this->buildForeignDropdown($foreign, $data, $this->config->settings['ForeignKeyDropdownOrder'][0]);
846
847
            if (isset($this->config->settings['ForeignKeyDropdownOrder'][1])) {
848
                $bottom = $this->buildForeignDropdown(
849
                    $foreign,
850
                    $data,
851
                    $this->config->settings['ForeignKeyDropdownOrder'][1],
852
                );
853
            }
854
        } else {
855
            $top = $this->buildForeignDropdown($foreign, $data, 'id-only');
856
        }
857
858
        // beginning of dropdown
859
        $ret = '<option value="">&nbsp;</option>';
860
        $topCount = count($top);
861
        if ($max == -1 || $topCount < $max) {
862
            $ret .= implode('', $top);
863
            if ($foreignDisplay && $topCount > 0) {
864
                // this empty option is to visually mark the beginning of the
865
                // second series of values (bottom)
866
                $ret .= '<option value="">&nbsp;</option>';
867
            }
868
        }
869
870
        if ($foreignDisplay !== '') {
871
            $ret .= implode('', $bottom);
872
        }
873
874
        return $ret;
875
    }
876
877
    /**
878
     * Gets foreign keys in preparation for a drop-down selector
879
     *
880
     * @param mixed[]|bool $foreigners    array of the foreign keys
881
     * @param string       $field         the foreign field name
882
     * @param bool         $overrideTotal whether to override the total
883
     * @param string       $foreignFilter a possible filter
884
     * @param string       $foreignLimit  a possible LIMIT clause
885
     * @param bool         $getTotal      optional, whether to get total num of rows
886
     *                                    in $foreignData['the_total;]
887
     *                                    (has an effect of performance)
888
     *
889
     * @return array<string, mixed>    data about the foreign keys
890
     * @psalm-return array{
891
     *     foreign_link: bool,
892
     *     the_total: int|string|null,
893
     *     foreign_display: string,
894
     *     disp_row: list<non-empty-array>|null,
895
     *     foreign_field: mixed
896
     * }
897
     */
898
    public function getForeignData(
899
        array|bool $foreigners,
900
        string $field,
901
        bool $overrideTotal,
902
        string $foreignFilter,
903
        string $foreignLimit,
904
        bool $getTotal = false,
905
    ): array {
906
        // we always show the foreign field in the drop-down; if a display
907
        // field is defined, we show it besides the foreign field
908
        $foreignLink = false;
909
        $dispRow = $foreignDisplay = $theTotal = $foreignField = null;
910
        do {
911
            if ($foreigners === false || $foreigners === []) {
912
                break;
913
            }
914
915
            $foreigner = $this->searchColumnInForeigners($foreigners, $field);
916
            if ($foreigner == false) {
917
                break;
918
            }
919
920
            $foreignDb = $foreigner['foreign_db'];
921
            $foreignTable = $foreigner['foreign_table'];
922
            $foreignField = $foreigner['foreign_field'];
923
924
            // Count number of rows in the foreign table. Currently we do
925
            // not use a drop-down if more than ForeignKeyMaxLimit rows in the
926
            // foreign table,
927
            // for speed reasons and because we need a better interface for this.
928
            //
929
            // We could also do the SELECT anyway, with a LIMIT, and ensure that
930
            // the current value of the field is one of the choices.
931
932
            // Check if table has more rows than specified by ForeignKeyMaxLimit
933
            $moreThanLimit = $this->dbi->getTable($foreignDb, $foreignTable)
934
                ->checkIfMinRecordsExist($this->config->settings['ForeignKeyMaxLimit']);
935
936
            if ($overrideTotal || ! $moreThanLimit) {
937
                // foreign_display can be false if no display field defined:
938
                $foreignDisplay = $this->getDisplayField($foreignDb, $foreignTable);
939
940
                $fQueryMain = 'SELECT ' . Util::backquote($foreignField)
941
                    . (
942
                        $foreignDisplay === ''
943
                            ? ''
944
                            : ', ' . Util::backquote($foreignDisplay)
945
                    );
946
                $fQueryFrom = ' FROM ' . Util::backquote($foreignDb)
947
                    . '.' . Util::backquote($foreignTable);
948
                $fQueryFilter = $foreignFilter === '' ? '' : ' WHERE '
949
                    . Util::backquote($foreignField)
950
                    . ' LIKE ' . $this->dbi->quoteString(
951
                        '%' . $this->dbi->escapeMysqlWildcards($foreignFilter) . '%',
952
                    )
953
                    . (
954
                        $foreignDisplay === ''
955
                        ? ''
956
                        : ' OR ' . Util::backquote($foreignDisplay)
957
                        . ' LIKE ' . $this->dbi->quoteString(
958
                            '%' . $this->dbi->escapeMysqlWildcards($foreignFilter) . '%',
959
                        )
960
                    );
961
                $fQueryOrder = $foreignDisplay === '' ? '' : ' ORDER BY '
962
                    . Util::backquote($foreignTable) . '.'
963
                    . Util::backquote($foreignDisplay);
964
965
                $fQueryLimit = $foreignLimit;
966
967
                if ($foreignFilter !== '') {
968
                    $theTotal = $this->dbi->fetchValue('SELECT COUNT(*)' . $fQueryFrom . $fQueryFilter);
969
                    if ($theTotal === false) {
970
                        $theTotal = 0;
971
                    }
972
                }
973
974
                $disp = $this->dbi->tryQuery($fQueryMain . $fQueryFrom . $fQueryFilter . $fQueryOrder . $fQueryLimit);
975
                if ($disp && $disp->numRows() > 0) {
976
                    // If a resultset has been created, pre-cache it in the $disp_row
977
                    // array. This helps us from not needing to use mysql_data_seek by
978
                    // accessing a pre-cached PHP array. Usually those resultsets are
979
                    // not that big, so a performance hit should not be expected.
980
                    $dispRow = $disp->fetchAllAssoc();
981
                } else {
982
                    // Either no data in the foreign table or
983
                    // user does not have select permission to foreign table/field
984
                    // Show an input field with a 'Browse foreign values' link
985
                    $dispRow = null;
986
                    $foreignLink = true;
987
                }
988
            } else {
989
                $dispRow = null;
990
                $foreignLink = true;
991
            }
992
        } while (false);
993
994
        if ($getTotal && isset($foreignDb, $foreignTable)) {
995
            $theTotal = $this->dbi->getTable($foreignDb, $foreignTable)
996
                ->countRecords(true);
997
        }
998
999
        return [
1000
            'foreign_link' => $foreignLink,
1001
            'the_total' => $theTotal,
1002
            'foreign_display' => is_string($foreignDisplay) ? $foreignDisplay : '',
1003
            'disp_row' => $dispRow,
1004
            'foreign_field' => $foreignField,
1005
        ];
1006
    }
1007
1008
    /**
1009
     * Rename a field in relation tables
1010
     *
1011
     * usually called after a column in a table was renamed
1012
     *
1013
     * @param string $db      database name
1014
     * @param string $table   table name
1015
     * @param string $field   old field name
1016
     * @param string $newName new field name
1017
     */
1018
    public function renameField(string $db, string $table, string $field, string $newName): void
1019
    {
1020
        $relationParameters = $this->getRelationParameters();
1021
1022
        if ($relationParameters->displayFeature !== null) {
1023
            $tableQuery = 'UPDATE '
1024
                . Util::backquote($relationParameters->displayFeature->database) . '.'
1025
                . Util::backquote($relationParameters->displayFeature->tableInfo)
1026
                . '   SET display_field = ' . $this->dbi->quoteString($newName, ConnectionType::ControlUser)
1027
                . ' WHERE db_name       = ' . $this->dbi->quoteString($db, ConnectionType::ControlUser)
1028
                . '   AND table_name    = ' . $this->dbi->quoteString($table, ConnectionType::ControlUser)
1029
                . '   AND display_field = ' . $this->dbi->quoteString($field, ConnectionType::ControlUser);
1030
            $this->dbi->queryAsControlUser($tableQuery);
1031
        }
1032
1033
        if ($relationParameters->relationFeature === null) {
1034
            return;
1035
        }
1036
1037
        $tableQuery = 'UPDATE '
1038
            . Util::backquote($relationParameters->relationFeature->database) . '.'
1039
            . Util::backquote($relationParameters->relationFeature->relation)
1040
            . '   SET master_field = ' . $this->dbi->quoteString($newName, ConnectionType::ControlUser)
1041
            . ' WHERE master_db    = ' . $this->dbi->quoteString($db, ConnectionType::ControlUser)
1042
            . '   AND master_table = ' . $this->dbi->quoteString($table, ConnectionType::ControlUser)
1043
            . '   AND master_field = ' . $this->dbi->quoteString($field, ConnectionType::ControlUser);
1044
        $this->dbi->queryAsControlUser($tableQuery);
1045
1046
        $tableQuery = 'UPDATE '
1047
            . Util::backquote($relationParameters->relationFeature->database) . '.'
1048
            . Util::backquote($relationParameters->relationFeature->relation)
1049
            . '   SET foreign_field = ' . $this->dbi->quoteString($newName, ConnectionType::ControlUser)
1050
            . ' WHERE foreign_db    = ' . $this->dbi->quoteString($db, ConnectionType::ControlUser)
1051
            . '   AND foreign_table = ' . $this->dbi->quoteString($table, ConnectionType::ControlUser)
1052
            . '   AND foreign_field = ' . $this->dbi->quoteString($field, ConnectionType::ControlUser);
1053
        $this->dbi->queryAsControlUser($tableQuery);
1054
    }
1055
1056
    /**
1057
     * Performs SQL query used for renaming table.
1058
     *
1059
     * @param string $sourceDb    Source database name
1060
     * @param string $targetDb    Target database name
1061
     * @param string $sourceTable Source table name
1062
     * @param string $targetTable Target table name
1063
     * @param string $dbField     Name of database field
1064
     * @param string $tableField  Name of table field
1065
     */
1066 24
    public function renameSingleTable(
1067
        DatabaseName $configStorageDatabase,
1068
        TableName $configStorageTable,
1069
        string $sourceDb,
1070
        string $targetDb,
1071
        string $sourceTable,
1072
        string $targetTable,
1073
        string $dbField,
1074
        string $tableField,
1075
    ): void {
1076 24
        $query = 'UPDATE '
1077 24
            . Util::backquote($configStorageDatabase) . '.'
1078 24
            . Util::backquote($configStorageTable)
1079 24
            . ' SET '
1080 24
            . $dbField . ' = ' . $this->dbi->quoteString($targetDb, ConnectionType::ControlUser)
1081 24
            . ', '
1082 24
            . $tableField . ' = ' . $this->dbi->quoteString($targetTable, ConnectionType::ControlUser)
1083 24
            . ' WHERE '
1084 24
            . $dbField . '  = ' . $this->dbi->quoteString($sourceDb, ConnectionType::ControlUser)
1085 24
            . ' AND '
1086 24
            . $tableField . ' = ' . $this->dbi->quoteString($sourceTable, ConnectionType::ControlUser);
1087 24
        $this->dbi->queryAsControlUser($query);
1088
    }
1089
1090
    /**
1091
     * Rename a table in relation tables
1092
     *
1093
     * usually called after table has been moved
1094
     *
1095
     * @param string $sourceDb    Source database name
1096
     * @param string $targetDb    Target database name
1097
     * @param string $sourceTable Source table name
1098
     * @param string $targetTable Target table name
1099
     */
1100 28
    public function renameTable(string $sourceDb, string $targetDb, string $sourceTable, string $targetTable): void
1101
    {
1102 28
        $relationParameters = $this->getRelationParameters();
1103
1104
        // Move old entries from PMA-DBs to new table
1105 28
        if ($relationParameters->columnCommentsFeature !== null) {
1106 4
            $this->renameSingleTable(
1107 4
                $relationParameters->columnCommentsFeature->database,
1108 4
                $relationParameters->columnCommentsFeature->columnInfo,
1109 4
                $sourceDb,
1110 4
                $targetDb,
1111 4
                $sourceTable,
1112 4
                $targetTable,
1113 4
                'db_name',
1114 4
                'table_name',
1115 4
            );
1116
        }
1117
1118
        // updating bookmarks is not possible since only a single table is
1119
        // moved, and not the whole DB.
1120
1121 28
        if ($relationParameters->displayFeature !== null) {
1122 4
            $this->renameSingleTable(
1123 4
                $relationParameters->displayFeature->database,
1124 4
                $relationParameters->displayFeature->tableInfo,
1125 4
                $sourceDb,
1126 4
                $targetDb,
1127 4
                $sourceTable,
1128 4
                $targetTable,
1129 4
                'db_name',
1130 4
                'table_name',
1131 4
            );
1132
        }
1133
1134 28
        if ($relationParameters->relationFeature !== null) {
1135 4
            $this->renameSingleTable(
1136 4
                $relationParameters->relationFeature->database,
1137 4
                $relationParameters->relationFeature->relation,
1138 4
                $sourceDb,
1139 4
                $targetDb,
1140 4
                $sourceTable,
1141 4
                $targetTable,
1142 4
                'foreign_db',
1143 4
                'foreign_table',
1144 4
            );
1145
1146 4
            $this->renameSingleTable(
1147 4
                $relationParameters->relationFeature->database,
1148 4
                $relationParameters->relationFeature->relation,
1149 4
                $sourceDb,
1150 4
                $targetDb,
1151 4
                $sourceTable,
1152 4
                $targetTable,
1153 4
                'master_db',
1154 4
                'master_table',
1155 4
            );
1156
        }
1157
1158 28
        if ($relationParameters->pdfFeature !== null) {
1159 8
            if ($sourceDb === $targetDb) {
1160
                // rename within the database can be handled
1161 4
                $this->renameSingleTable(
1162 4
                    $relationParameters->pdfFeature->database,
1163 4
                    $relationParameters->pdfFeature->tableCoords,
1164 4
                    $sourceDb,
1165 4
                    $targetDb,
1166 4
                    $sourceTable,
1167 4
                    $targetTable,
1168 4
                    'db_name',
1169 4
                    'table_name',
1170 4
                );
1171
            } else {
1172
                // if the table is moved out of the database we can no longer keep the
1173
                // record for table coordinate
1174 4
                $removeQuery = 'DELETE FROM '
1175 4
                    . Util::backquote($relationParameters->pdfFeature->database) . '.'
1176 4
                    . Util::backquote($relationParameters->pdfFeature->tableCoords)
1177 4
                    . ' WHERE db_name  = ' . $this->dbi->quoteString($sourceDb, ConnectionType::ControlUser)
1178 4
                    . ' AND table_name = ' . $this->dbi->quoteString($sourceTable, ConnectionType::ControlUser);
1179 4
                $this->dbi->queryAsControlUser($removeQuery);
1180
            }
1181
        }
1182
1183 28
        if ($relationParameters->uiPreferencesFeature !== null) {
1184 4
            $this->renameSingleTable(
1185 4
                $relationParameters->uiPreferencesFeature->database,
1186 4
                $relationParameters->uiPreferencesFeature->tableUiPrefs,
1187 4
                $sourceDb,
1188 4
                $targetDb,
1189 4
                $sourceTable,
1190 4
                $targetTable,
1191 4
                'db_name',
1192 4
                'table_name',
1193 4
            );
1194
        }
1195
1196 28
        if ($relationParameters->navigationItemsHidingFeature === null) {
1197 24
            return;
1198
        }
1199
1200
        // update hidden items inside table
1201 4
        $this->renameSingleTable(
1202 4
            $relationParameters->navigationItemsHidingFeature->database,
1203 4
            $relationParameters->navigationItemsHidingFeature->navigationHiding,
1204 4
            $sourceDb,
1205 4
            $targetDb,
1206 4
            $sourceTable,
1207 4
            $targetTable,
1208 4
            'db_name',
1209 4
            'table_name',
1210 4
        );
1211
1212
        // update data for hidden table
1213 4
        $query = 'UPDATE '
1214 4
            . Util::backquote($relationParameters->navigationItemsHidingFeature->database) . '.'
1215 4
            . Util::backquote($relationParameters->navigationItemsHidingFeature->navigationHiding)
1216 4
            . ' SET db_name = ' . $this->dbi->quoteString($targetDb, ConnectionType::ControlUser)
1217 4
            . ','
1218 4
            . ' item_name = ' . $this->dbi->quoteString($targetTable, ConnectionType::ControlUser)
1219 4
            . ' WHERE db_name  = ' . $this->dbi->quoteString($sourceDb, ConnectionType::ControlUser)
1220 4
            . ' AND item_name = ' . $this->dbi->quoteString($sourceTable, ConnectionType::ControlUser)
1221 4
            . " AND item_type = 'table'";
1222 4
        $this->dbi->queryAsControlUser($query);
1223
    }
1224
1225
    /**
1226
     * Create a PDF page
1227
     *
1228
     * @param string|null $newpage name of the new PDF page
1229
     * @param string      $db      database name
1230
     */
1231
    public function createPage(string|null $newpage, PdfFeature $pdfFeature, string $db): int
1232
    {
1233
        $insQuery = 'INSERT INTO '
1234
            . Util::backquote($pdfFeature->database) . '.'
1235
            . Util::backquote($pdfFeature->pdfPages)
1236
            . ' (db_name, page_descr)'
1237
            . ' VALUES ('
1238
            . $this->dbi->quoteString($db, ConnectionType::ControlUser) . ', '
1239
            . $this->dbi->quoteString(
1240
                $newpage !== null && $newpage !== '' ? $newpage : __('no description'),
1241
                ConnectionType::ControlUser,
1242
            ) . ')';
1243
        $this->dbi->tryQueryAsControlUser($insQuery);
1244
1245
        return $this->dbi->insertId(ConnectionType::ControlUser);
1246
    }
1247
1248
    /**
1249
     * Get child table references for a table column.
1250
     * This works only if 'DisableIS' is false. An empty array is returned otherwise.
1251
     *
1252
     * @param string $db     name of master table db.
1253
     * @param string $table  name of master table.
1254
     * @param string $column name of master table column.
1255
     *
1256
     * @return mixed[]
1257
     */
1258
    public function getChildReferences(string $db, string $table, string $column = ''): array
1259
    {
1260
        if (! $this->config->selectedServer['DisableIS']) {
1261
            $relQuery = 'SELECT `column_name`, `table_name`,'
1262
                . ' `table_schema`, `referenced_column_name`'
1263
                . ' FROM `information_schema`.`key_column_usage`'
1264
                . ' WHERE `referenced_table_name` = '
1265
                . $this->dbi->quoteString($table)
1266
                . ' AND `referenced_table_schema` = '
1267
                . $this->dbi->quoteString($db);
1268
            if ($column !== '') {
1269
                $relQuery .= ' AND `referenced_column_name` = '
1270
                    . $this->dbi->quoteString($column);
1271
            }
1272
1273
            return $this->dbi->fetchResult(
1274
                $relQuery,
1275
                ['referenced_column_name', null],
1276
            );
1277
        }
1278
1279
        return [];
1280
    }
1281
1282
    /**
1283
     * Check child table references and foreign key for a table column.
1284
     *
1285
     * @param string       $db                  name of master table db.
1286
     * @param string       $table               name of master table.
1287
     * @param string       $column              name of master table column.
1288
     * @param mixed[]|null $foreignersFull      foreigners array for the whole table.
1289
     * @param mixed[]|null $childReferencesFull child references for the whole table.
1290
     *
1291
     * @return array<string, mixed> telling about references if foreign key.
1292
     * @psalm-return array{isEditable: bool, isForeignKey: bool, isReferenced: bool, references: string[]}
1293
     */
1294
    public function checkChildForeignReferences(
1295
        string $db,
1296
        string $table,
1297
        string $column,
1298
        array|null $foreignersFull = null,
1299
        array|null $childReferencesFull = null,
1300
    ): array {
1301
        $columnStatus = ['isEditable' => true, 'isReferenced' => false, 'isForeignKey' => false, 'references' => []];
1302
1303
        $foreigners = [];
1304
        if ($foreignersFull !== null) {
1305
            if (isset($foreignersFull[$column])) {
1306
                $foreigners[$column] = $foreignersFull[$column];
1307
            }
1308
1309
            if (isset($foreignersFull['foreign_keys_data'])) {
1310
                $foreigners['foreign_keys_data'] = $foreignersFull['foreign_keys_data'];
1311
            }
1312
        } else {
1313
            $foreigners = $this->getForeigners($db, $table, $column, 'foreign');
1314
        }
1315
1316
        $foreigner = $this->searchColumnInForeigners($foreigners, $column);
1317
1318
        $childReferences = [];
1319
        if ($childReferencesFull !== null) {
1320
            if (isset($childReferencesFull[$column])) {
1321
                $childReferences = $childReferencesFull[$column];
1322
            }
1323
        } else {
1324
            $childReferences = $this->getChildReferences($db, $table, $column);
1325
        }
1326
1327
        if (count($childReferences) > 0 || $foreigner) {
1328
            $columnStatus['isEditable'] = false;
1329
            if (count($childReferences) > 0) {
1330
                $columnStatus['isReferenced'] = true;
1331
                foreach ($childReferences as $columns) {
1332
                    $columnStatus['references'][] = Util::backquote($columns['table_schema'])
1333
                        . '.' . Util::backquote($columns['table_name']);
1334
                }
1335
            }
1336
1337
            if ($foreigner) {
1338
                $columnStatus['isForeignKey'] = true;
1339
            }
1340
        }
1341
1342
        return $columnStatus;
1343
    }
1344
1345
    /**
1346
     * Search a table column in foreign data.
1347
     *
1348
     * @param mixed[] $foreigners Table Foreign data
1349
     * @param string  $column     Column name
1350
     */
1351 8
    public function searchColumnInForeigners(array $foreigners, string $column): array|false
1352
    {
1353 8
        if (isset($foreigners[$column])) {
1354
            return $foreigners[$column];
1355
        }
1356
1357 8
        if (! isset($foreigners['foreign_keys_data'])) {
1358 4
            return false;
1359
        }
1360
1361 4
        $foreigner = [];
1362 4
        foreach ($foreigners['foreign_keys_data'] as $oneKey) {
1363 4
            $columnIndex = array_search($column, $oneKey['index_list']);
1364 4
            if ($columnIndex !== false) {
1365 4
                $foreigner['foreign_field'] = $oneKey['ref_index_list'][$columnIndex];
1366 4
                $foreigner['foreign_db'] = $oneKey['ref_db_name'] ?? Current::$database;
1367 4
                $foreigner['foreign_table'] = $oneKey['ref_table_name'];
1368 4
                $foreigner['constraint'] = $oneKey['constraint'];
1369 4
                $foreigner['on_update'] = $oneKey['on_update'] ?? 'RESTRICT';
1370 4
                $foreigner['on_delete'] = $oneKey['on_delete'] ?? 'RESTRICT';
1371
1372 4
                return $foreigner;
1373
            }
1374
        }
1375
1376
        return false;
1377
    }
1378
1379
    /**
1380
     * Returns default PMA table names and their create queries.
1381
     *
1382
     * @param array<string, string> $tableNameReplacements
1383
     *
1384
     * @return array<string, string> table name, create query
1385
     */
1386 16
    public function getCreateTableSqlQueries(array $tableNameReplacements): array
1387
    {
1388 16
        $pmaTables = [];
1389 16
        $createTablesFile = (string) file_get_contents(SQL_DIR . 'create_tables.sql');
1390
1391 16
        $queries = explode(';', $createTablesFile);
1392
1393 16
        foreach ($queries as $query) {
1394 16
            if (! preg_match('/CREATE TABLE IF NOT EXISTS `(.*)` \(/', $query, $table)) {
1395 16
                continue;
1396
            }
1397
1398 16
            $tableName = $table[1];
1399
1400
            // Replace the table name with another one
1401 16
            if (isset($tableNameReplacements[$tableName])) {
1402 8
                $query = str_replace($tableName, $tableNameReplacements[$tableName], $query);
1403
            }
1404
1405 16
            $pmaTables[$tableName] = $query . ';';
1406
        }
1407
1408 16
        return $pmaTables;
1409
    }
1410
1411
    /**
1412
     * Create a database to be used as configuration storage
1413
     */
1414 12
    public function createPmaDatabase(string $configurationStorageDbName): bool
1415
    {
1416 12
        $this->dbi->tryQuery(
1417 12
            'CREATE DATABASE IF NOT EXISTS ' . Util::backquote($configurationStorageDbName),
1418 12
            ConnectionType::ControlUser,
1419 12
        );
1420
1421 12
        $error = $this->dbi->getError(ConnectionType::ControlUser);
1422 12
        if ($error === '') {
1423
            // Re-build the cache to show the list of tables created or not
1424
            // This is the case when the DB could be created but no tables just after
1425
            // So just purge the cache and show the new configuration storage state
1426 4
            self::$cache = null;
1427 4
            $this->getRelationParameters();
1428
1429 4
            return true;
1430
        }
1431
1432 8
        $GLOBALS['message'] = $error;
1433
1434 8
        if ($GLOBALS['errno'] === 1044) {
1435 4
            $GLOBALS['message'] = sprintf(
1436 4
                __(
1437 4
                    'You do not have necessary privileges to create a database named'
1438 4
                    . ' \'%s\'. You may go to \'Operations\' tab of any'
1439 4
                    . ' database to set up the phpMyAdmin configuration storage there.',
1440 4
                ),
1441 4
                $configurationStorageDbName,
1442 4
            );
1443
        }
1444
1445 8
        return false;
1446
    }
1447
1448
    /**
1449
     * Creates PMA tables in the given db, updates if already exists.
1450
     *
1451
     * @param string $db     database
1452
     * @param bool   $create whether to create tables if they don't exist.
1453
     */
1454 48
    public function fixPmaTables(string $db, bool $create = true): void
1455
    {
1456 48
        if ($this->arePmadbTablesAllDisabled()) {
1457
            return;
1458
        }
1459
1460 48
        $tablesToFeatures = [
1461 48
            'pma__bookmark' => 'bookmarktable',
1462 48
            'pma__relation' => 'relation',
1463 48
            'pma__table_info' => 'table_info',
1464 48
            'pma__table_coords' => 'table_coords',
1465 48
            'pma__pdf_pages' => 'pdf_pages',
1466 48
            'pma__column_info' => 'column_info',
1467 48
            'pma__history' => 'history',
1468 48
            'pma__recent' => 'recent',
1469 48
            'pma__favorite' => 'favorite',
1470 48
            'pma__table_uiprefs' => 'table_uiprefs',
1471 48
            'pma__tracking' => 'tracking',
1472 48
            'pma__userconfig' => 'userconfig',
1473 48
            'pma__users' => 'users',
1474 48
            'pma__usergroups' => 'usergroups',
1475 48
            'pma__navigationhiding' => 'navigationhiding',
1476 48
            'pma__savedsearches' => 'savedsearches',
1477 48
            'pma__central_columns' => 'central_columns',
1478 48
            'pma__designer_settings' => 'designer_settings',
1479 48
            'pma__export_templates' => 'export_templates',
1480 48
        ];
1481
1482 48
        $existingTables = $this->dbi->getTables($db, ConnectionType::ControlUser);
1483
1484 48
        $tableNameReplacements = $this->getTableReplacementNames($tablesToFeatures);
1485
1486 48
        $createQueries = [];
1487 48
        if ($create) {
1488 12
            $createQueries = $this->getCreateTableSqlQueries($tableNameReplacements);
1489 12
            if (! $this->dbi->selectDb($db, ConnectionType::ControlUser)) {
1490
                $GLOBALS['message'] = $this->dbi->getError(ConnectionType::ControlUser);
1491
1492
                return;
1493
            }
1494
        }
1495
1496 48
        $foundOne = false;
1497 48
        foreach ($tablesToFeatures as $table => $feature) {
1498 48
            if (($this->config->selectedServer[$feature] ?? null) === false) {
1499
                // The feature is disabled by the user in config
1500 8
                continue;
1501
            }
1502
1503
            // Check if the table already exists
1504
            // use the possible replaced name first and fallback on the table name
1505
            // if no replacement exists
1506 48
            if (! in_array($tableNameReplacements[$table] ?? $table, $existingTables, true)) {
1507 48
                if (! $create) {
1508 36
                    continue;
1509
                }
1510
1511 12
                $this->dbi->tryQuery($createQueries[$table], ConnectionType::ControlUser);
1512
1513 12
                $error = $this->dbi->getError(ConnectionType::ControlUser);
1514 12
                if ($error !== '') {
1515 4
                    $GLOBALS['message'] = $error;
1516
1517 4
                    return;
1518
                }
1519
            }
1520
1521 32
            $foundOne = true;
1522
1523
            // Do not override a user defined value, only fill if empty
1524 32
            if (isset($this->config->selectedServer[$feature]) && $this->config->selectedServer[$feature] !== '') {
1525 12
                continue;
1526
            }
1527
1528
            // Fill it with the default table name
1529 24
            $this->config->selectedServer[$feature] = $table;
1530
        }
1531
1532 44
        if (! $foundOne) {
1533 16
            return;
1534
        }
1535
1536 32
        $this->config->selectedServer['pmadb'] = $db;
1537
1538
        // Unset the cache as new tables might have been added
1539 32
        self::$cache = null;
1540
        // Fill back the cache
1541 32
        $this->getRelationParameters();
1542
    }
1543
1544
    /**
1545
     * Gets the relations info and status, depending on the condition
1546
     *
1547
     * @param bool   $condition whether to look for foreigners or not
1548
     * @param string $db        database name
1549
     * @param string $table     table name
1550
     *
1551
     * @return mixed[]
1552
     */
1553
    public function getRelationsAndStatus(bool $condition, string $db, string $table): array
1554
    {
1555
        if ($condition) {
1556
            // Find which tables are related with the current one and write it in an array
1557
            return $this->getForeigners($db, $table);
1558
        }
1559
1560
        return [];
1561
    }
1562
1563
    /**
1564
     * Verifies that all pmadb features are disabled
1565
     */
1566 60
    public function arePmadbTablesAllDisabled(): bool
1567
    {
1568 60
        return ($this->config->selectedServer['bookmarktable'] ?? null) === false
1569 60
            && ($this->config->selectedServer['relation'] ?? null) === false
1570 60
            && ($this->config->selectedServer['table_info'] ?? null) === false
1571 60
            && ($this->config->selectedServer['table_coords'] ?? null) === false
1572 60
            && ($this->config->selectedServer['column_info'] ?? null) === false
1573 60
            && ($this->config->selectedServer['pdf_pages'] ?? null) === false
1574 60
            && ($this->config->selectedServer['history'] ?? null) === false
1575 60
            && ($this->config->selectedServer['recent'] ?? null) === false
1576 60
            && ($this->config->selectedServer['favorite'] ?? null) === false
1577 60
            && ($this->config->selectedServer['table_uiprefs'] ?? null) === false
1578 60
            && ($this->config->selectedServer['tracking'] ?? null) === false
1579 60
            && ($this->config->selectedServer['userconfig'] ?? null) === false
1580 60
            && ($this->config->selectedServer['users'] ?? null) === false
1581 60
            && ($this->config->selectedServer['usergroups'] ?? null) === false
1582 60
            && ($this->config->selectedServer['navigationhiding'] ?? null) === false
1583 60
            && ($this->config->selectedServer['savedsearches'] ?? null) === false
1584 60
            && ($this->config->selectedServer['central_columns'] ?? null) === false
1585 60
            && ($this->config->selectedServer['designer_settings'] ?? null) === false
1586 60
            && ($this->config->selectedServer['export_templates'] ?? null) === false;
1587
    }
1588
1589
    /**
1590
     * Verifies if all the pmadb tables are defined
1591
     */
1592 4
    public function arePmadbTablesDefined(): bool
1593
    {
1594 4
        return ! (empty($this->config->selectedServer['bookmarktable'])
1595 4
            || empty($this->config->selectedServer['relation'])
1596 4
            || empty($this->config->selectedServer['table_info'])
1597 4
            || empty($this->config->selectedServer['table_coords'])
1598 4
            || empty($this->config->selectedServer['column_info'])
1599 4
            || empty($this->config->selectedServer['pdf_pages'])
1600 4
            || empty($this->config->selectedServer['history'])
1601 4
            || empty($this->config->selectedServer['recent'])
1602 4
            || empty($this->config->selectedServer['favorite'])
1603 4
            || empty($this->config->selectedServer['table_uiprefs'])
1604 4
            || empty($this->config->selectedServer['tracking'])
1605 4
            || empty($this->config->selectedServer['userconfig'])
1606 4
            || empty($this->config->selectedServer['users'])
1607 4
            || empty($this->config->selectedServer['usergroups'])
1608 4
            || empty($this->config->selectedServer['navigationhiding'])
1609 4
            || empty($this->config->selectedServer['savedsearches'])
1610 4
            || empty($this->config->selectedServer['central_columns'])
1611 4
            || empty($this->config->selectedServer['designer_settings'])
1612 4
            || empty($this->config->selectedServer['export_templates']));
1613
    }
1614
1615
    /**
1616
     * Get tables for foreign key constraint
1617
     *
1618
     * @param string $foreignDb        Database name
1619
     * @param string $tblStorageEngine Table storage engine
1620
     *
1621
     * @return mixed[] Table names
1622
     */
1623
    public function getTables(string $foreignDb, string $tblStorageEngine): array
1624
    {
1625
        $tables = [];
1626
        $tablesRows = $this->dbi->query('SHOW TABLE STATUS FROM ' . Util::backquote($foreignDb));
1627
        while ($row = $tablesRows->fetchRow()) {
1628
            if (! isset($row[1]) || mb_strtoupper($row[1]) !== $tblStorageEngine) {
0 ignored issues
show
Bug introduced by
It seems like $row[1] can also be of type null; however, parameter $string of mb_strtoupper() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1628
            if (! isset($row[1]) || mb_strtoupper(/** @scrutinizer ignore-type */ $row[1]) !== $tblStorageEngine) {
Loading history...
1629
                continue;
1630
            }
1631
1632
            $tables[] = $row[0];
1633
        }
1634
1635
        if ($this->config->settings['NaturalOrder']) {
1636
            usort($tables, strnatcasecmp(...));
1637
        }
1638
1639
        return $tables;
1640
    }
1641
1642
    public function getConfigurationStorageDbName(): string
1643
    {
1644
        $cfgStorageDbName = $this->config->selectedServer['pmadb'] ?? '';
1645
1646
        // Use "phpmyadmin" as a default database name to check to keep the behavior consistent
1647
        return empty($cfgStorageDbName) ? 'phpmyadmin' : $cfgStorageDbName;
1648
    }
1649
1650
    /**
1651
     * This function checks and initializes the phpMyAdmin configuration
1652
     * storage state before it is used into session cache.
1653
     */
1654 28
    public function initRelationParamsCache(): void
1655
    {
1656 28
        $storageDbName = $this->config->selectedServer['pmadb'] ?? '';
1657
        // Use "phpmyadmin" as a default database name to check to keep the behavior consistent
1658 28
        $storageDbName = $storageDbName !== '' ? $storageDbName : 'phpmyadmin';
1659
1660
        // This will make users not having explicitly listed databases
1661
        // have config values filled by the default phpMyAdmin storage table name values
1662 28
        $this->fixPmaTables($storageDbName, false);
1663
1664
        // This global will be changed if fixPmaTables did find one valid table
1665
        // Empty means that until now no pmadb was found eligible
1666 28
        if ($this->config->selectedServer['pmadb'] !== '') {
1667 20
            return;
1668
        }
1669
1670 8
        $this->fixPmaTables(Current::$database, false);
1671
    }
1672
1673
    /**
1674
     * @param non-empty-array<string, string> $tablesToFeatures
0 ignored issues
show
Documentation Bug introduced by
The doc comment non-empty-array<string, string> at position 0 could not be parsed: Unknown type name 'non-empty-array' at position 0 in non-empty-array<string, string>.
Loading history...
1675
     *
1676
     * @return array<string, string>
1677
     */
1678 48
    private function getTableReplacementNames(array $tablesToFeatures): array
1679
    {
1680 48
        $tableNameReplacements = [];
1681
1682 48
        foreach ($tablesToFeatures as $table => $feature) {
1683 48
            if (empty($this->config->selectedServer[$feature]) || $this->config->selectedServer[$feature] === $table) {
1684 48
                continue;
1685
            }
1686
1687
            // Set the replacement to transform the default table name into a custom name
1688 12
            $tableNameReplacements[$table] = $this->config->selectedServer[$feature];
1689
        }
1690
1691 48
        return $tableNameReplacements;
1692
    }
1693
}
1694