3 * Tracking changes on databases, tables and views
6 declare(strict_types
=1);
8 namespace PhpMyAdmin\Tracking
;
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
;
29 use function array_values
;
32 use function mb_strstr
;
33 use function preg_quote
;
34 use function preg_replace
;
35 use function serialize
;
37 use function str_ends_with
;
41 * This class tracks changes on databases, tables and views.
43 * @todo use stristr instead of strstr
47 public const TRACKER_ENABLED_CACHE_KEY
= 'phpmyadmin.tracker.enabled';
50 * Cache to avoid quering tracking status multiple times.
54 protected static array $trackingCache = [];
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);
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) {
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;
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) {
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) {
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;
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(
160 string $trackingSet = '',
161 bool $isView = false,
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
) {
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
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);
216 $trackingFeature = $relation->getRelationParameters()->trackingFeature
;
217 if ($trackingFeature === null) {
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(
257 string $trackingSet = 'CREATE DATABASE,ALTER DATABASE,DROP DATABASE',
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'];
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) {
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(
317 $dbi = DatabaseInterface
::getInstance();
318 $relation = new Relation($dbi);
319 $trackingFeature = $relation->getRelationParameters()->trackingFeature
;
320 if ($trackingFeature === null) {
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
),
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) {
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) {
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 ?
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
;
436 if ($parser->statements
!== []) {
437 $statement = $parser->statements
[0];
438 $options = $statement->options?
->options
;
441 $result['type'] = 'DDL';
443 // Parse CREATE statement
444 if ($statement instanceof CreateStatement
) {
445 if ($options === null ||
$options === [] ||
! isset($options[6])) {
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
;
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])) {
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])) {
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'])) {
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
;
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*/')) {
554 if (! str_ends_with($query, ';')) {
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 === '') {
566 $dbi = DatabaseInterface
::getInstance();
567 $relation = new Relation($dbi);
568 $trackingFeature = $relation->getRelationParameters()->trackingFeature
;
569 if ($trackingFeature === null) {
573 if (! self
::isAnyTrackingInProgress($dbi, $trackingFeature, $dbname)) {
577 // Get some information about query
578 $result = self
::parseQuery($query);
580 // If we found a valid statement
581 if (! isset($result['identifier'])) {
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'])) {
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']) {
599 self
::createVersion($dbname, $result['tablename'], '1');
602 self
::createVersion($dbname, $result['tablename'], '1', '', true);
604 case 'CREATE DATABASE':
605 self
::createDatabaseVersion($dbname, '1', $query);
611 if ($version == -1) {
615 if (! self
::isTracked($dbname, $result['tablename'])) {
619 $saveTo = match ($result['type']) {
620 'DDL' => 'schema_sql',
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?\./',
634 // Add log information
635 $query = self
::getLogComment() . $query;
637 $relation = new Relation($dbi);
638 $trackingFeature = $relation->getRelationParameters()->trackingFeature
;
639 if ($trackingFeature === null) {
643 // Mark it as untouchable
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
)
662 // Save the tracking information only for
664 // 2. the table / view
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,
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;