Merge pull request #18642 from kamil-tekiela/Use-match
[phpmyadmin.git] / libraries / classes / Tracking / Tracker.php
blobff1230ef8b4dee325a9bef996d95b5877cc72142
1 <?php
2 /**
3 * Tracking changes on databases, tables and views
4 */
6 declare(strict_types=1);
8 namespace PhpMyAdmin\Tracking;
10 use PhpMyAdmin\Cache;
11 use PhpMyAdmin\Config;
12 use PhpMyAdmin\ConfigStorage\Features\TrackingFeature;
13 use PhpMyAdmin\ConfigStorage\Relation;
14 use PhpMyAdmin\DatabaseInterface;
15 use PhpMyAdmin\Dbal\Connection;
16 use PhpMyAdmin\Plugins;
17 use PhpMyAdmin\Plugins\Export\ExportSql;
18 use PhpMyAdmin\SqlParser\Parser;
19 use PhpMyAdmin\SqlParser\Statements\AlterStatement;
20 use PhpMyAdmin\SqlParser\Statements\CreateStatement;
21 use PhpMyAdmin\SqlParser\Statements\DeleteStatement;
22 use PhpMyAdmin\SqlParser\Statements\DropStatement;
23 use PhpMyAdmin\SqlParser\Statements\InsertStatement;
24 use PhpMyAdmin\SqlParser\Statements\RenameStatement;
25 use PhpMyAdmin\SqlParser\Statements\TruncateStatement;
26 use PhpMyAdmin\SqlParser\Statements\UpdateStatement;
27 use PhpMyAdmin\Util;
29 use function array_values;
30 use function count;
31 use function intval;
32 use function mb_strstr;
33 use function preg_quote;
34 use function preg_replace;
35 use function serialize;
36 use function sprintf;
37 use function str_ends_with;
38 use function trim;
40 /**
41 * This class tracks changes on databases, tables and views.
43 * @todo use stristr instead of strstr
45 class Tracker
47 public const TRACKER_ENABLED_CACHE_KEY = 'phpmyadmin.tracker.enabled';
49 /**
50 * Cache to avoid quering tracking status multiple times.
52 * @var mixed[]
54 protected static array $trackingCache = [];
56 /**
57 * Actually enables tracking. This needs to be done after all
58 * underlaying code is initialized.
60 public static function enable(): void
62 Cache::set(self::TRACKER_ENABLED_CACHE_KEY, true);
65 /**
66 * Gets the on/off value of the Tracker module, starts initialization.
68 public static function isActive(): bool
70 $trackingEnabled = Cache::get(self::TRACKER_ENABLED_CACHE_KEY, false);
71 if (! $trackingEnabled) {
72 return false;
75 /**
76 * We need to avoid attempt to track any queries from {@link Relation::getRelationParameters()}
78 Cache::set(self::TRACKER_ENABLED_CACHE_KEY, false);
79 $relation = new Relation(DatabaseInterface::getInstance());
80 $relationParameters = $relation->getRelationParameters();
81 /* Restore original state */
82 Cache::set(self::TRACKER_ENABLED_CACHE_KEY, true);
84 return $relationParameters->trackingFeature !== null;
87 /**
88 * Gets the tracking status of a table, is it active or disabled ?
90 * @param string $dbName name of database
91 * @param string $tableName name of table
93 public static function isTracked(string $dbName, string $tableName): bool
95 $trackingEnabled = Cache::get(self::TRACKER_ENABLED_CACHE_KEY, false);
96 if (! $trackingEnabled) {
97 return false;
100 if (isset(self::$trackingCache[$dbName][$tableName])) {
101 return self::$trackingCache[$dbName][$tableName];
105 * We need to avoid attempt to track any queries from {@link Relation::getRelationParameters()}
107 Cache::set(self::TRACKER_ENABLED_CACHE_KEY, false);
108 $dbi = DatabaseInterface::getInstance();
109 $relation = new Relation($dbi);
110 $trackingFeature = $relation->getRelationParameters()->trackingFeature;
111 /* Restore original state */
112 Cache::set(self::TRACKER_ENABLED_CACHE_KEY, true);
113 if ($trackingFeature === null) {
114 return false;
117 $sqlQuery = sprintf(
118 'SELECT tracking_active FROM %s.%s WHERE db_name = %s AND table_name = %s'
119 . ' ORDER BY version DESC LIMIT 1',
120 Util::backquote($trackingFeature->database),
121 Util::backquote($trackingFeature->tracking),
122 $dbi->quoteString($dbName, Connection::TYPE_CONTROL),
123 $dbi->quoteString($tableName, Connection::TYPE_CONTROL),
126 $result = $dbi->fetchValue($sqlQuery, 0, Connection::TYPE_CONTROL) == 1;
128 self::$trackingCache[$dbName][$tableName] = $result;
130 return $result;
134 * Returns the comment line for the log.
136 * @return string Comment, contains date and username
138 public static function getLogComment(): string
140 $date = Util::date('Y-m-d H:i:s');
141 $user = preg_replace('/\s+/', ' ', Config::getInstance()->selectedServer['user']);
143 return '# log ' . $date . ' ' . $user . "\n";
147 * Creates tracking version of a table / view
148 * (in other words: create a job to track future changes on the table).
150 * @param string $dbName name of database
151 * @param string $tableName name of table
152 * @param string $version version
153 * @param string $trackingSet set of tracking statements
154 * @param bool $isView if table is a view
156 public static function createVersion(
157 string $dbName,
158 string $tableName,
159 string $version,
160 string $trackingSet = '',
161 bool $isView = false,
162 ): bool {
163 $GLOBALS['export_type'] ??= null;
164 $dbi = DatabaseInterface::getInstance();
165 $relation = new Relation($dbi);
167 $config = Config::getInstance();
168 if ($trackingSet == '') {
169 $trackingSet = $config->selectedServer['tracking_default_statements'];
172 $exportSqlPlugin = Plugins::getPlugin('export', 'sql', [
173 'export_type' => (string) $GLOBALS['export_type'],
174 'single_table' => false,
176 if (! $exportSqlPlugin instanceof ExportSql) {
177 return false;
180 $exportSqlPlugin->useSqlBackquotes(true);
182 $date = Util::date('Y-m-d H:i:s');
184 // Get data definition snapshot of table
186 $columns = $dbi->getColumns($dbName, $tableName, true);
187 // int indices to reduce size
188 $columns = array_values($columns);
189 // remove Privileges to reduce size
190 /** @infection-ignore-all */
191 for ($i = 0, $nb = count($columns); $i < $nb; $i++) {
192 unset($columns[$i]['Privileges']);
195 $indexes = $dbi->getTableIndexes($dbName, $tableName);
197 $snapshot = ['COLUMNS' => $columns, 'INDEXES' => $indexes];
198 $snapshot = serialize($snapshot);
200 // Get DROP TABLE / DROP VIEW and CREATE TABLE SQL statements
201 $createSql = '';
203 if ($config->selectedServer['tracking_add_drop_table'] == true && ! $isView) {
204 $createSql .= self::getLogComment()
205 . 'DROP TABLE IF EXISTS ' . Util::backquote($tableName) . ";\n";
208 if ($config->selectedServer['tracking_add_drop_view'] == true && $isView) {
209 $createSql .= self::getLogComment()
210 . 'DROP VIEW IF EXISTS ' . Util::backquote($tableName) . ";\n";
213 $createSql .= self::getLogComment() . $exportSqlPlugin->getTableDef($dbName, $tableName);
215 // Save version
216 $trackingFeature = $relation->getRelationParameters()->trackingFeature;
217 if ($trackingFeature === null) {
218 return false;
221 $sqlQuery = sprintf(
222 '/*NOTRACK*/' . "\n" . 'INSERT INTO %s.%s (db_name, table_name, version,'
223 . ' date_created, date_updated, schema_snapshot, schema_sql, data_sql, tracking)'
224 . ' values (%s, %s, %s, %s, %s, %s, %s, %s, %s)',
225 Util::backquote($trackingFeature->database),
226 Util::backquote($trackingFeature->tracking),
227 $dbi->quoteString($dbName, Connection::TYPE_CONTROL),
228 $dbi->quoteString($tableName, Connection::TYPE_CONTROL),
229 $dbi->quoteString($version, Connection::TYPE_CONTROL),
230 $dbi->quoteString($date, Connection::TYPE_CONTROL),
231 $dbi->quoteString($date, Connection::TYPE_CONTROL),
232 $dbi->quoteString($snapshot, Connection::TYPE_CONTROL),
233 $dbi->quoteString($createSql, Connection::TYPE_CONTROL),
234 $dbi->quoteString("\n", Connection::TYPE_CONTROL),
235 $dbi->quoteString($trackingSet, Connection::TYPE_CONTROL),
238 $dbi->queryAsControlUser($sqlQuery);
240 // Deactivate previous version
241 return self::deactivateTracking($dbName, $tableName, (string) ((int) $version - 1));
245 * Creates tracking version of a database
246 * (in other words: create a job to track future changes on the database).
248 * @param string $dbName name of database
249 * @param string $version version
250 * @param string $query query
251 * @param string $trackingSet set of tracking statements
253 public static function createDatabaseVersion(
254 string $dbName,
255 string $version,
256 string $query,
257 string $trackingSet = 'CREATE DATABASE,ALTER DATABASE,DROP DATABASE',
258 ): bool {
259 $dbi = DatabaseInterface::getInstance();
260 $relation = new Relation($dbi);
262 $date = Util::date('Y-m-d H:i:s');
264 $config = Config::getInstance();
265 if ($trackingSet == '') {
266 $trackingSet = $config->selectedServer['tracking_default_statements'];
269 $createSql = '';
271 if ($config->selectedServer['tracking_add_drop_database'] == true) {
272 $createSql .= self::getLogComment() . 'DROP DATABASE IF EXISTS ' . Util::backquote($dbName) . ";\n";
275 $createSql .= self::getLogComment() . $query;
277 $trackingFeature = $relation->getRelationParameters()->trackingFeature;
278 if ($trackingFeature === null) {
279 return false;
282 // Save version
283 $sqlQuery = sprintf(
284 '/*NOTRACK*/' . "\n" . 'INSERT INTO %s.%s (db_name, table_name, version,'
285 . ' date_created, date_updated, schema_snapshot, schema_sql, data_sql, tracking)'
286 . ' values (%s, %s, %s, %s, %s, %s, %s, %s, %s)',
287 Util::backquote($trackingFeature->database),
288 Util::backquote($trackingFeature->tracking),
289 $dbi->quoteString($dbName, Connection::TYPE_CONTROL),
290 $dbi->quoteString('', Connection::TYPE_CONTROL),
291 $dbi->quoteString($version, Connection::TYPE_CONTROL),
292 $dbi->quoteString($date, Connection::TYPE_CONTROL),
293 $dbi->quoteString($date, Connection::TYPE_CONTROL),
294 $dbi->quoteString('', Connection::TYPE_CONTROL),
295 $dbi->quoteString($createSql, Connection::TYPE_CONTROL),
296 $dbi->quoteString("\n", Connection::TYPE_CONTROL),
297 $dbi->quoteString($trackingSet, Connection::TYPE_CONTROL),
300 return (bool) $dbi->queryAsControlUser($sqlQuery);
304 * Changes tracking of a table.
306 * @param string $dbName name of database
307 * @param string $tableName name of table
308 * @param string $version version
309 * @param int $newState the new state of tracking
311 private static function changeTracking(
312 string $dbName,
313 string $tableName,
314 string $version,
315 int $newState,
316 ): bool {
317 $dbi = DatabaseInterface::getInstance();
318 $relation = new Relation($dbi);
319 $trackingFeature = $relation->getRelationParameters()->trackingFeature;
320 if ($trackingFeature === null) {
321 return false;
324 $sqlQuery = sprintf(
325 'UPDATE %s.%s SET `tracking_active` = %d'
326 . ' WHERE `db_name` = %s AND `table_name` = %s AND `version` = %s',
327 Util::backquote($trackingFeature->database),
328 Util::backquote($trackingFeature->tracking),
329 $newState,
330 $dbi->quoteString($dbName, Connection::TYPE_CONTROL),
331 $dbi->quoteString($tableName, Connection::TYPE_CONTROL),
332 $dbi->quoteString($version, Connection::TYPE_CONTROL),
335 return (bool) $dbi->queryAsControlUser($sqlQuery);
339 * Activates tracking of a table.
341 * @param string $dbname name of database
342 * @param string $tablename name of table
343 * @param string $version version
345 public static function activateTracking(string $dbname, string $tablename, string $version): bool
347 return self::changeTracking($dbname, $tablename, $version, 1);
351 * Deactivates tracking of a table.
353 * @param string $dbname name of database
354 * @param string $tablename name of table
355 * @param string $version version
357 public static function deactivateTracking(string $dbname, string $tablename, string $version): bool
359 return self::changeTracking($dbname, $tablename, $version, 0);
363 * Gets the newest version of a tracking job
364 * (in other words: gets the HEAD version).
366 * @param string $dbname name of database
367 * @param string $tablename name of table
368 * @param string $statement tracked statement
370 * @return int (-1 if no version exists | > 0 if a version exists)
372 private static function getVersion(string $dbname, string $tablename, string|null $statement = null): int
374 $dbi = DatabaseInterface::getInstance();
375 $relation = new Relation($dbi);
376 $trackingFeature = $relation->getRelationParameters()->trackingFeature;
377 if ($trackingFeature === null) {
378 return -1;
381 $sqlQuery = sprintf(
382 'SELECT MAX(version) FROM %s.%s WHERE `db_name` = %s AND `table_name` = %s',
383 Util::backquote($trackingFeature->database),
384 Util::backquote($trackingFeature->tracking),
385 $dbi->quoteString($dbname, Connection::TYPE_CONTROL),
386 $dbi->quoteString($tablename, Connection::TYPE_CONTROL),
389 if ($statement != '') {
390 $sqlQuery .= " AND FIND_IN_SET('" . $statement . "',tracking) > 0";
393 $result = $dbi->tryQueryAsControlUser($sqlQuery);
395 if ($result === false) {
396 return -1;
399 $row = $result->fetchRow();
401 return intval($row[0] ?? -1);
405 * Parses a query. Gets
406 * - statement identifier (UPDATE, ALTER TABLE, ...)
407 * - type of statement, is it part of DDL or DML ?
408 * - tablename
410 * @param string $query query
412 * @return mixed[] containing identifier, type and tablename.
414 * @todo: using PMA SQL Parser when possible
415 * @todo: support multi-table/view drops
417 public static function parseQuery(string $query): array
419 // Usage of PMA_SQP does not work here
421 // require_once("libraries/sqlparser.lib.php");
422 // $parsed_sql = PMA_SQP_parse($query);
423 // $sql_info = PMA_SQP_analyze($parsed_sql);
425 $parser = new Parser($query);
427 $tokens = $parser->list->tokens;
429 // Parse USE statement, need it for SQL dump imports
430 if ($tokens[0]->value === 'USE') {
431 $GLOBALS['db'] = $tokens[2]->value;
434 $result = [];
436 if ($parser->statements !== []) {
437 $statement = $parser->statements[0];
438 $options = $statement->options?->options;
440 // DDL statements
441 $result['type'] = 'DDL';
443 // Parse CREATE statement
444 if ($statement instanceof CreateStatement) {
445 if ($options === null || $options === [] || ! isset($options[6])) {
446 return $result;
449 if ($options[6] === 'VIEW' || $options[6] === 'TABLE') {
450 $result['identifier'] = 'CREATE ' . $options[6];
451 $result['tablename'] = $statement->name?->table;
452 } elseif ($options[6] === 'DATABASE') {
453 $result['identifier'] = 'CREATE DATABASE';
454 $result['tablename'] = '';
456 // In case of CREATE DATABASE, database field of the CreateStatement is the name of the database
457 $GLOBALS['db'] = $statement->name?->database;
458 } elseif (
459 $options[6] === 'INDEX'
460 || $options[6] === 'UNIQUE INDEX'
461 || $options[6] === 'FULLTEXT INDEX'
462 || $options[6] === 'SPATIAL INDEX'
464 $result['identifier'] = 'CREATE INDEX';
466 // In case of CREATE INDEX, we have to get the table name from body of the statement
467 $result['tablename'] = $statement->body[3]->value === '.' ? $statement->body[4]->value
468 : $statement->body[2]->value;
470 } elseif ($statement instanceof AlterStatement) { // Parse ALTER statement
471 if ($options === null || $options === [] || ! isset($options[3])) {
472 return $result;
475 if ($options[3] === 'VIEW' || $options[3] === 'TABLE') {
476 $result['identifier'] = 'ALTER ' . $options[3];
477 $result['tablename'] = $statement->table->table;
478 } elseif ($options[3] === 'DATABASE') {
479 $result['identifier'] = 'ALTER DATABASE';
480 $result['tablename'] = '';
482 $GLOBALS['db'] = $statement->table->table;
484 } elseif ($statement instanceof DropStatement) { // Parse DROP statement
485 if ($options === null || $options === [] || ! isset($options[1])) {
486 return $result;
489 if ($options[1] === 'VIEW' || $options[1] === 'TABLE') {
490 $result['identifier'] = 'DROP ' . $options[1];
491 $result['tablename'] = $statement->fields[0]->table;
492 } elseif ($options[1] === 'DATABASE') {
493 $result['identifier'] = 'DROP DATABASE';
494 $result['tablename'] = '';
496 $GLOBALS['db'] = $statement->fields[0]->table;
497 } elseif ($options[1] === 'INDEX') {
498 $result['identifier'] = 'DROP INDEX';
499 $result['tablename'] = $statement->table->table;
501 } elseif ($statement instanceof RenameStatement) { // Parse RENAME statement
502 $result['identifier'] = 'RENAME TABLE';
503 $result['tablename'] = $statement->renames[0]->old->table;
504 $result['tablename_after_rename'] = $statement->renames[0]->new->table;
507 if (isset($result['identifier'])) {
508 return $result;
511 // DML statements
512 $result['type'] = 'DML';
514 // Parse UPDATE statement
515 if ($statement instanceof UpdateStatement) {
516 $result['identifier'] = 'UPDATE';
517 $result['tablename'] = $statement->tables[0]->table;
520 // Parse INSERT INTO statement
521 if ($statement instanceof InsertStatement) {
522 $result['identifier'] = 'INSERT';
523 $result['tablename'] = $statement->into->dest->table;
526 // Parse DELETE statement
527 if ($statement instanceof DeleteStatement) {
528 $result['identifier'] = 'DELETE';
529 $result['tablename'] = $statement->from[0]->table;
532 // Parse TRUNCATE statement
533 if ($statement instanceof TruncateStatement) {
534 $result['identifier'] = 'TRUNCATE';
535 $result['tablename'] = $statement->table->table;
539 return $result;
543 * Analyzes a given SQL statement and saves tracking data.
545 * @param string $query a SQL query
547 public static function handleQuery(string $query): void
549 // If query is marked as untouchable, leave
550 if (mb_strstr($query, '/*NOTRACK*/')) {
551 return;
554 if (! str_ends_with($query, ';')) {
555 $query .= ";\n";
558 // Get database name
559 $dbname = trim($GLOBALS['db'] ?? '', '`');
560 // $dbname can be empty, for example when coming from Synchronize
561 // and this is a query for the remote server
562 if ($dbname === '') {
563 return;
566 $dbi = DatabaseInterface::getInstance();
567 $relation = new Relation($dbi);
568 $trackingFeature = $relation->getRelationParameters()->trackingFeature;
569 if ($trackingFeature === null) {
570 return;
573 if (! self::isAnyTrackingInProgress($dbi, $trackingFeature, $dbname)) {
574 return;
577 // Get some information about query
578 $result = self::parseQuery($query);
580 // If we found a valid statement
581 if (! isset($result['identifier'])) {
582 return;
585 // The table name was not found, see issue: #16837 as an example
586 // Also checks if the value is not null
587 if (! isset($result['tablename'])) {
588 return;
591 $version = self::getVersion($dbname, $result['tablename'], $result['identifier']);
593 // If version not exists and auto-creation is enabled
594 if (Config::getInstance()->selectedServer['tracking_version_auto_create'] == true && $version == -1) {
595 // Create the version
597 switch ($result['identifier']) {
598 case 'CREATE TABLE':
599 self::createVersion($dbname, $result['tablename'], '1');
600 break;
601 case 'CREATE VIEW':
602 self::createVersion($dbname, $result['tablename'], '1', '', true);
603 break;
604 case 'CREATE DATABASE':
605 self::createDatabaseVersion($dbname, '1', $query);
606 break;
610 // If version exists
611 if ($version == -1) {
612 return;
615 if (! self::isTracked($dbname, $result['tablename'])) {
616 return;
619 $saveTo = match ($result['type']) {
620 'DDL' => 'schema_sql',
621 'DML' => 'data_sql',
622 default => '',
625 $date = Util::date('Y-m-d H:i:s');
627 // Cut off `dbname`. from query
628 $query = preg_replace(
629 '/`' . preg_quote($dbname, '/') . '`\s?\./',
631 $query,
634 // Add log information
635 $query = self::getLogComment() . $query;
637 $relation = new Relation($dbi);
638 $trackingFeature = $relation->getRelationParameters()->trackingFeature;
639 if ($trackingFeature === null) {
640 return;
643 // Mark it as untouchable
644 $sqlQuery = sprintf(
645 '/*NOTRACK*/' . "\n" . 'UPDATE %s.%s SET %s = CONCAT(%s, %s), `date_updated` = %s',
646 Util::backquote($trackingFeature->database),
647 Util::backquote($trackingFeature->tracking),
648 Util::backquote($saveTo),
649 Util::backquote($saveTo),
650 $dbi->quoteString("\n" . $query, Connection::TYPE_CONTROL),
651 $dbi->quoteString($date, Connection::TYPE_CONTROL),
654 // If table was renamed we have to change
655 // the tablename attribute in pma_tracking too
656 if ($result['identifier'] === 'RENAME TABLE') {
657 $sqlQuery .= ', `table_name` = '
658 . $dbi->quoteString($result['tablename_after_rename'], Connection::TYPE_CONTROL)
659 . ' ';
662 // Save the tracking information only for
663 // 1. the database
664 // 2. the table / view
665 // 3. the statements
666 // we want to track
667 $sqlQuery .= sprintf(
668 " WHERE FIND_IN_SET('" . $result['identifier'] . "',tracking) > 0" .
669 ' AND `db_name` = %s ' .
670 ' AND `table_name` = %s ' .
671 ' AND `version` = %s ',
672 $dbi->quoteString($dbname, Connection::TYPE_CONTROL),
673 $dbi->quoteString($result['tablename'], Connection::TYPE_CONTROL),
674 $dbi->quoteString((string) $version, Connection::TYPE_CONTROL),
677 $dbi->queryAsControlUser($sqlQuery);
680 private static function isAnyTrackingInProgress(
681 DatabaseInterface $dbi,
682 TrackingFeature $trackingFeature,
683 string $dbname,
684 ): bool {
685 $sqlQuery = sprintf(
686 '/*NOTRACK*/ SELECT 1 FROM %s.%s WHERE tracking_active = 1 AND db_name = %s LIMIT 1',
687 Util::backquote($trackingFeature->database),
688 Util::backquote($trackingFeature->tracking),
689 $dbi->quoteString($dbname, Connection::TYPE_CONTROL),
692 return $dbi->queryAsControlUser($sqlQuery)->fetchValue() !== false;