Files
carpseeds.pl/wp-content/plugins/akeebabackupwp/app/Solo/engine/Dump/Native/Mysql.php
2024-07-15 11:28:08 +02:00

2219 lines
66 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Akeeba Engine
*
* @package akeebaengine
* @copyright Copyright (c)2006-2022 Nicholas K. Dionysopoulos / Akeeba Ltd
* @license GNU General Public License version 3, or later
*/
namespace Akeeba\Engine\Dump\Native;
defined('AKEEBAENGINE') || die();
use Akeeba\Engine\Driver\QueryException;
use Akeeba\Engine\Dump\Base;
use Akeeba\Engine\Factory;
use Akeeba\Engine\Platform;
use Exception;
use RuntimeException;
/**
* A generic MySQL database dump class.
* Now supports views; merge, in-memory, federated, blackhole, etc tables
* Configuration parameters:
* host <string> MySQL database server host name or IP address
* port <string> MySQL database server port (optional)
* username <string> MySQL user name, for authentication
* password <string> MySQL password, for authentication
* database <string> MySQL database
* dumpFile <string> Absolute path to dump file; must be writable (optional; if left blank it is
* automatically calculated)
*/
class Mysql extends Base
{
/**
* The primary key structure of the currently backed up table. The keys contained are:
* - table The name of the table being backed up
* - field The name of the primary key field
* - value The last value of the PK field
*
* @var array
*/
protected $table_autoincrement = [
'table' => null,
'field' => null,
'value' => null,
];
private $columnListColumnType = [];
private $columnListSelectColumn = '*';
private $lastTableColumnType = null;
private $lastTableSelectColumn = null;
/**
* Implements the constructor of the class
*
* @return void
*/
public function __construct()
{
parent::__construct();
Factory::getLog()->debug(__CLASS__ . " :: New instance");
}
/**
* Replaces the table names in the CREATE query with their abstract form. Optionally updates dependencies.
*
* @param string $tableName The table name the CREATE query is for
* @param string $tableSql The CREATE query itself
* @param bool $withDependecies Should I update dependencies?
*
* @return array [$dependencies, $modifiedSQLQuery] - Dependency information for the table (if $withDependencies)
* and the new CREATE query with all table names replaced with abstract versions.
*
* @throws Exception When we cannot get the DB object
*/
public function replaceTableNamesWithAbstracts($tableName, $tableSql, $withDependecies = false)
{
// Initialization
$dependencies = [];
$tableNameMap = $this->table_name_map;
$db = $this->getDB();
if (!array_key_exists($tableName, $tableNameMap))
{
$tableNameMap[$tableName] = $this->getAbstract($tableName);
}
foreach ($tableNameMap as $fullName => $abstractName)
{
$quotedFullName = $db->quoteName($fullName);
$quotedAbstractName = $db->quoteName($abstractName);
$pos = strpos($tableSql, $quotedFullName);
$numReplacements = 0;
if ($pos !== false)
{
$numReplacements = 1;
// Do the replacement
$tableSql = str_replace($quotedFullName, $quotedAbstractName, $tableSql);
}
else
{
$offset = 0;
$fullNameLength = strlen($fullName);
$quotedAbstractNameLength = strlen($quotedAbstractName);
/**
* We need to detect the edges of table names. If they are enclosed in backticks it's pretty clear. If they are
* not, e.g. in the definitions of TRIGGERs, we need to base our detection on the valid characters for
* unquoted MySQL table names per https://dev.mysql.com/doc/refman/5.7/en/identifiers.html
*/
[$bareCharRegex, $regexFlags] = $this->getMySQLIdentifierCharacterRegEx();
$fullCharRegex = "/$bareCharRegex/$regexFlags";
while (true)
{
$pos = strpos($tableSql, $fullName, $offset);
if ($pos === false)
{
break;
}
$previousChar = ($pos > 0) ? substr($tableSql, $pos - 1, 1) : '';
$nextChar = ($pos < (strlen($tableSql) - $fullNameLength)) ? substr($tableSql, $pos + $fullNameLength, 1) : '';
$prevIsTableChar = $previousChar === '' ? false : preg_match($fullCharRegex, $previousChar);
$nextIsTableChar = $nextChar === '' ? false : preg_match($fullCharRegex, $nextChar);
if ($prevIsTableChar || $nextIsTableChar)
{
$offset = $pos + 1;
continue;
}
$before = ($pos > 0) ? substr($tableSql, 0, $pos) : '';
$after = ($pos < (strlen($tableSql) - $fullNameLength)) ? substr($tableSql, $pos + $fullNameLength) : '';
$numReplacements++;
$tableSql = $before . $quotedAbstractName . $after;
$offset = $pos + $quotedAbstractNameLength;
}
}
if ($withDependecies && $numReplacements && ($fullName != $tableName))
{
// Add a reference hit
$this->dependencies[$fullName][] = $tableName;
// Add the dependency to this table's metadata
$dependencies[] = $fullName;
}
}
return [$dependencies, $tableSql];
}
/**
* Creates a drop query from a CREATE query
*
* @param string $query The CREATE query to process
*
* @return string The DROP statement
*/
protected function createDrop($query)
{
$db = $this->getDB();
// Initialize
$dropQuery = '';
// Parse CREATE TABLE commands
if (substr($query, 0, 12) == 'CREATE TABLE')
{
// Try to get the table name
$restOfQuery = trim(substr($query, 12, strlen($query) - 12)); // Rest of query, after CREATE TABLE
// Is there a backtick?
if (substr($restOfQuery, 0, 1) == '`')
{
// There is... Good, we'll just find the matching backtick
$pos = strpos($restOfQuery, '`', 1);
$tableName = substr($restOfQuery, 1, $pos - 1);
}
else
{
// Nope, let's assume the table name ends in the next blank character
$pos = strpos($restOfQuery, ' ', 1);
$tableName = substr($restOfQuery, 0, $pos);
}
unset($restOfQuery);
// Try to drop the table anyway
$dropQuery = 'DROP TABLE IF EXISTS ' . $db->nameQuote($tableName) . ';';
}
// Parse CREATE VIEW commands
elseif ((substr($query, 0, 7) == 'CREATE ') && (strpos($query, ' VIEW ') !== false))
{
// Try to get the view name
$view_pos = strpos($query, ' VIEW ');
$restOfQuery = trim(substr($query, $view_pos + 6)); // Rest of query, after VIEW string
// Is there a backtick?
if (substr($restOfQuery, 0, 1) == '`')
{
// There is... Good, we'll just find the matching backtick
$pos = strpos($restOfQuery, '`', 1);
$tableName = substr($restOfQuery, 1, $pos - 1);
}
else
{
// Nope, let's assume the table name ends in the next blank character
$pos = strpos($restOfQuery, ' ', 1);
$tableName = substr($restOfQuery, 0, $pos);
}
unset($restOfQuery);
$dropQuery = 'DROP VIEW IF EXISTS ' . $db->nameQuote($tableName) . ';';
}
// CREATE PROCEDURE pre-processing
elseif ((substr($query, 0, 7) == 'CREATE ') && (strpos($query, 'PROCEDURE ') !== false))
{
// Try to get the procedure name
$entity_keyword = ' PROCEDURE ';
$entity_pos = strpos($query, $entity_keyword);
$restOfQuery = trim(substr($query, $entity_pos + strlen($entity_keyword))); // Rest of query, after entity key string
// Is there a backtick?
if (substr($restOfQuery, 0, 1) == '`')
{
// There is... Good, we'll just find the matching backtick
$pos = strpos($restOfQuery, '`', 1);
$entity_name = substr($restOfQuery, 1, $pos - 1);
}
else
{
// Nope, let's assume the entity name ends in the next blank character
$pos = strpos($restOfQuery, ' ', 1);
$entity_name = substr($restOfQuery, 0, $pos);
}
unset($restOfQuery);
$dropQuery = 'DROP' . $entity_keyword . 'IF EXISTS `' . $entity_name . '`;';
}
// CREATE FUNCTION pre-processing
elseif ((substr($query, 0, 7) == 'CREATE ') && (strpos($query, 'FUNCTION ') !== false))
{
// Try to get the procedure name
$entity_keyword = ' FUNCTION ';
$entity_pos = strpos($query, $entity_keyword);
$restOfQuery = trim(substr($query, $entity_pos + strlen($entity_keyword))); // Rest of query, after entity key string
// Is there a backtick?
if (substr($restOfQuery, 0, 1) == '`')
{
// There is... Good, we'll just find the matching backtick
$pos = strpos($restOfQuery, '`', 1);
$entity_name = substr($restOfQuery, 1, $pos - 1);
}
else
{
// Nope, let's assume the entity name ends in the next blank character
$pos = strpos($restOfQuery, ' ', 1);
$entity_name = substr($restOfQuery, 0, $pos);
}
unset($restOfQuery);
// Try to drop the entity anyway
$dropQuery = 'DROP' . $entity_keyword . 'IF EXISTS `' . $entity_name . '`;';
}
// CREATE TRIGGER pre-processing
elseif ((substr($query, 0, 7) == 'CREATE ') && (strpos($query, 'TRIGGER ') !== false))
{
// Try to get the procedure name
$entity_keyword = ' TRIGGER ';
$entity_pos = strpos($query, $entity_keyword);
$restOfQuery = trim(substr($query, $entity_pos + strlen($entity_keyword))); // Rest of query, after entity key string
// Is there a backtick?
if (substr($restOfQuery, 0, 1) == '`')
{
// There is... Good, we'll just find the matching backtick
$pos = strpos($restOfQuery, '`', 1);
$entity_name = substr($restOfQuery, 1, $pos - 1);
}
else
{
// Nope, let's assume the entity name ends in the next blank character
$pos = strpos($restOfQuery, ' ', 1);
$entity_name = substr($restOfQuery, 0, $pos);
}
unset($restOfQuery);
// Try to drop the entity anyway
$dropQuery = 'DROP' . $entity_keyword . 'IF EXISTS `' . $entity_name . '`;';
}
return $dropQuery;
}
/**
* Applies the SQL compatibility setting
*
* @return void
*/
protected function enforceSQLCompatibility()
{
$db = $this->getDB();
// Try to enforce SQL_BIG_SELECTS option
try
{
$db->setQuery('SET sql_big_selects=1');
$db->query();
}
catch (Exception $e)
{
// Do nothing; some versions of MySQL don't allow you to use the BIG_SELECTS option.
}
$db->resetErrors();
}
/**
* Return a list of columns and their data types.
*
* @param string $tableAbstract
*
* @return array An array of table columns and their data types.
*/
protected function getColumnTypes($tableAbstract)
{
if ($this->lastTableColumnType == $tableAbstract)
{
return $this->columnListColumnType;
}
$this->lastTableColumnType = $tableAbstract;
try
{
$db = $this->getDB();
$db->setQuery('SHOW COLUMNS FROM ' . $db->qn($tableAbstract));
$tableCols = $db->loadAssocList();
}
catch (Exception $e)
{
return $this->columnListColumnType;
}
foreach ($tableCols as $col)
{
$typeParts = explode('(', $col['Type'], 2);
$this->columnListColumnType[$col['Field']] = strtoupper($typeParts[0]);
}
return $this->columnListColumnType;
}
// =============================================================================
// Dependency processing - the Twilight Zone starts here
// =============================================================================
/**
* Return the current database name by querying the database connection object (e.g. SELECT DATABASE() in MySQL)
*
* @return string
*/
protected function getDatabaseNameFromConnection()
{
$db = $this->getDB();
try
{
$ret = $db->setQuery('SELECT DATABASE()')->loadResult();
}
catch (Exception $e)
{
return '';
}
return empty($ret) ? '' : $ret;
}
/**
* Get the default database dump batch size from the configuration
*
* @return int
*/
protected function getDefaultBatchSize()
{
static $batchSize = null;
if (is_null($batchSize))
{
$configuration = Factory::getConfiguration();
$batchSize = intval($configuration->get('engine.dump.common.batchsize', 1000));
if ($batchSize <= 0)
{
$batchSize = 1000;
}
}
return $batchSize;
}
/**
* Get a regular expression and its options for valid characters of an unquoted MySQL identifier.
*
* This is used wherever we need to detect an arbitrary, unquoted MySQL identifier per
* https://dev.mysql.com/doc/refman/5.7/en/identifiers.html
*
* Normally, we can use a pretty simple regular expression that makes use of the \X property (extended grapheme
* cluster) to describe the supported characters outside the 0-9, a-Z, A-Z, dollar and underscore ASCII ranges.
*
* HOWEVER! We discovered that Ubuntu 18.04 ships with a version of PCRE which does not support the \X property
* in character classes (the stuff between brackets). In this case we have to fall back to a long-winded regex
* that explicitly adds the \u0080 - \uFFFF range as an alternative.
*
* Also what if Unicode support is not compiled in PCRE? In this case we will fall back to a much simpler regex
* which only supports the ASCII subset of the allowed characters. In this case your database dump will be wrong
* if you use table names with non-ASCII characters.
*
* Since the detection is horribly slow we cache its results in an internal static variable.
*
* @return array In the format [$regex, $flags]
* @since 7.0.0
*/
protected function getMySQLIdentifierCharacterRegEx()
{
static $validCharRegEx = null;
static $unicodeFlag = null;
if (is_null($validCharRegEx) || is_null($unicodeFlag))
{
$brokenPCRE = @preg_match('/[0-9a-zA-Z$_\X]/u', 's') === false;
$noUnicode = @preg_match('/\p{L}/u', 'σ') !== 1;
$unicodeFlag = $noUnicode ? '' : 'u';
$validCharRegEx = $noUnicode ? '[0-9a-zA-Z$_]' : ($brokenPCRE ? '[0-9a-zA-Z$_]|[\x{0080}-\x{FFFF}]' : '[0-9a-zA-Z$_\X]');
}
return [$validCharRegEx, $unicodeFlag];
}
/**
* Get the optimal row batch size for a given table based on the available memory
*
* @param string $tableAbstract The abstract table name, e.g. #__foobar
* @param int $defaultBatchSize The default row batch size in the application configuration
*
* @return int
*/
protected function getOptimalBatchSize($tableAbstract, $defaultBatchSize)
{
$db = $this->getDB();
try
{
$info = $db->setQuery('SHOW TABLE STATUS LIKE ' . $db->q($tableAbstract))->loadAssoc();
}
catch (Exception $e)
{
return $defaultBatchSize;
}
if (!isset($info['Avg_row_length']) || empty($info['Avg_row_length']))
{
return $defaultBatchSize;
}
// That's the average row size as reported by MySQL.
$avgRow = str_replace([',', '.'], ['', ''], $info['Avg_row_length']);
// The memory available for manipulating data is less than the free memory
$memoryLimit = $this->getMemoryLimit();
$memoryLimit = empty($memoryLimit) ? 33554432 : $memoryLimit;
$usedMemory = memory_get_usage();
$memoryLeft = 0.75 * ($memoryLimit - $usedMemory);
// The 3.25 factor is empirical and leans on the safe side.
$maxRows = (int) ($memoryLeft / (3.25 * $avgRow));
return max(1, min($maxRows, $defaultBatchSize));
}
/**
* Gets the row count for table $tableAbstract. Also updates the $this->maxRange variable.
*
* @param string $tableAbstract The abstract name of the table (works with canonical names too, though)
*
* @return void
*
* @throws QueryException
*/
protected function getRowCount($tableAbstract)
{
$db = $this->getDB();
$sql = $db->getQuery(true)
->select('COUNT(*)')
->from($db->nameQuote($tableAbstract));
$errno = 0;
$error = '';
try
{
$db->setQuery($sql);
$this->maxRange = $db->loadResult();
if (is_null($this->maxRange))
{
$errno = $db->getErrorNum();
$error = $db->getErrorMsg(false);
}
}
catch (Exception $e)
{
$this->maxRange = null;
$errno = $e->getCode();
$error = $e->getMessage();
}
if (is_null($this->maxRange))
{
Factory::getLog()->warning("Cannot get number of rows of $tableAbstract. MySQL error $errno: $error");
return;
}
Factory::getLog()->debug("Rows on " . $tableAbstract . " : " . $this->maxRange);
}
/**
* Return a list of columns to use in the SELECT query for dumping table data.
*
* This is used to filter out all generated rows.
*
* @param string $tableAbstract
*
* @return string|array An array of table columns or the string literal '*' to quickly select all columns.
*
* @see https://dev.mysql.com/doc/refman/5.7/en/create-table-generated-columns.html
*/
protected function getSelectColumns($tableAbstract)
{
if ($this->lastTableSelectColumn == $tableAbstract)
{
return $this->columnListSelectColumn;
}
$this->lastTableSelectColumn = $tableAbstract;
try
{
$db = $this->getDB();
$db->setQuery('SHOW COLUMNS FROM ' . $db->qn($tableAbstract));
$tableCols = $db->loadAssocList();
}
catch (Exception $e)
{
return $this->columnListSelectColumn;
}
$totalColumns = is_array($tableCols) || $tableCols instanceof \Countable ? count($tableCols) : 0;
$this->columnListSelectColumn = [];
$hasInvisibleColumns = false;
foreach ($tableCols as $col)
{
// Skip over generated columns
$attribs = array_map('strtoupper', empty($col['Extra']) ? [] : explode(' ', $col['Extra']));
if (in_array('GENERATED', $attribs))
{
continue;
}
if (in_array('INVISIBLE', $attribs))
{
$hasInvisibleColumns = true;
}
$this->columnListSelectColumn[] = $col['Field'];
}
if (!$hasInvisibleColumns && ($totalColumns == count($this->columnListSelectColumn)))
{
$this->columnListSelectColumn = '*';
}
return $this->columnListSelectColumn;
}
/**
* Scans the database for tables to be backed up and sorts them according to
* their dependencies on one another. Updates $this->dependencies.
*
* @return void
*/
protected function getTablesToBackup()
{
// Makes the MySQL connection compatible with our class
$this->enforceSQLCompatibility();
$configuration = Factory::getConfiguration();
$notracking = $configuration->get('engine.dump.native.nodependencies', 0);
// First, get a map of table names <--> abstract names
$this->get_tables_mapping();
if ($notracking)
{
// Do not process table & view dependencies
$this->get_tables_data_without_dependencies();
}
// Process table & view dependencies (default)
else
{
// Find the type and CREATE command of each table/view in the database
$this->get_tables_data();
// Process dependencies and rearrange tables respecting them
$this->process_dependencies();
// Remove dependencies array
$this->dependencies = [];
}
}
/**
* Gets the CREATE TABLE command for a given table/view/procedure/function/trigger
*
* @param string $table_abstract The abstracted name of the entity
* @param string $table_name The name of the table
* @param string $type The type of the entity to scan. If it's found to differ, the correct type is
* returned.
* @param array $dependencies The dependencies of this table
*
* @return string|null The CREATE command
*/
protected function get_create($table_abstract, $table_name, &$type, &$dependencies)
{
$configuration = Factory::getConfiguration();
$notracking = $configuration->get('engine.dump.native.nodependencies', 0);
$db = $this->getDB();
switch ($type)
{
case 'table':
case 'merge':
case 'view':
default:
$sql = "SHOW CREATE TABLE `$table_abstract`";
break;
case 'procedure':
$sql = "SHOW CREATE PROCEDURE `$table_abstract`";
break;
case 'function':
$sql = "SHOW CREATE FUNCTION `$table_abstract`";
break;
case 'trigger':
$sql = "SHOW CREATE TRIGGER `$table_abstract`";
break;
}
$db->setQuery($sql);
try
{
$temp = $db->loadRowList();
}
catch (Exception $e)
{
// If the query failed we don't have the necessary SHOW privilege. Log the error and fake an empty reply.
$entityType = ($type == 'merge') ? 'table' : $type;
$msg = $e->getMessage();
Factory::getLog()->warning("Cannot get the structure of $entityType $table_abstract. Database returned error $msg running $sql Please check your database privileges. Your database backup may be incomplete.");
$db->resetErrors();
$temp = [
['', '', ''],
];
}
if (in_array($type, ['procedure', 'function', 'trigger']))
{
$table_sql = $temp[0][2];
// MySQL adds the database name into everything. We have to remove it.
$dbName = $db->qn($this->database) . '.`';
$table_sql = str_replace($dbName, '`', $table_sql);
// These can contain comment lines, starting with a double dash. Remove them.
$table_sql = trim($table_sql);
/**
* Remove the definer from the CREATE PROCEDURE/TRIGGER/FUNCTION. For example, MySQL returns this:
* CREATE DEFINER=`myuser`@`localhost` PROCEDURE `abc_myProcedure`() ...
* If you're restoring on a different machine the definer will probably be invalid, therefore we need to
* remove it from the (portable) output.
*
* Remember, $table_sql may be multiline. Therefore we need to process only the first line and append any
* further lines to the CREATE statement.
*/
$table_sql = trim($table_sql);
$lines = explode("\n", $table_sql);
$firstLine = array_shift($lines);
$pattern = '/^CREATE(.*?) ' . strtoupper($type) . ' (.*)/i';
$result = preg_match($pattern, $firstLine, $matches);
$table_sql = 'CREATE ' . strtoupper($type) . ' ' . $matches[2] . "\n" . implode("\n", $lines);
$table_sql = trim($table_sql);
}
else
{
$table_sql = $temp[0][1];
}
unset($temp);
// Smart table type detection
if (in_array($type, ['table', 'merge', 'view']))
{
// Check for CREATE VIEW
$pattern = '/^CREATE(.*?) VIEW (.*)/i';
$result = preg_match($pattern, $table_sql, $matches);
if ($result === 1)
{
// This is a view.
$type = 'view';
/**
* Newer MySQL versions add the definer and other information in the CREATE VIEW output, e.g.
* CREATE ALGORITHM=UNDEFINED DEFINER=`muyser`@`localhost` SQL SECURITY DEFINER VIEW `abc_myview` AS ...
* We need to remove that to prevent restoration troubles.
*/
$table_sql = 'CREATE VIEW ' . $matches[2];
}
else
{
// This is a table.
$type = 'table';
// # Fix 3.2.1: USING BTREE / USING HASH in indices causes issues migrating from MySQL 5.1+ hosts to
// MySQL 5.0 hosts
if ($configuration->get('engine.dump.native.nobtree', 1))
{
$table_sql = str_replace(' USING BTREE', ' ', $table_sql);
$table_sql = str_replace(' USING HASH', ' ', $table_sql);
}
// Translate TYPE= to ENGINE=
$table_sql = str_replace('TYPE=', 'ENGINE=', $table_sql);
/**
* Remove the TABLESPACE option.
*
* The format of the TABLESPACE table option is:
* TABLESPACE tablespace_name [STORAGE {DISK|MEMORY}]
* where tablespace_name can be a quoted or unquoted identifier.
*/
[$validCharRegEx, $unicodeFlag] = $this->getMySQLIdentifierCharacterRegEx();
$tablespaceName = "((($validCharRegEx){1,})|(`.*`))";
$suffix = 'STORAGE\s{1,}(DISK|MEMORY)';
$regex = "#TABLESPACE\s{1,}$tablespaceName\s{0,}($suffix){0,1}#i" . $unicodeFlag;
$table_sql = preg_replace($regex, '', $table_sql);
// Remove table options {DATA|INDEX} DIRECTORY
$regex = "#(DATA|INDEX)\s{1,}DIRECTORY\s*=?\s*'.*'#i";
$table_sql = preg_replace($regex, '', $table_sql);
// Remove table options ROW_FORMAT=whatever
$regex = "#ROW_FORMAT\s*=\s*[A-Z]{1,}#i";
$table_sql = preg_replace($regex, '', $table_sql);
// Abstract the names of table constraints and indices
$regex = "#(CONSTRAINT|KEY|INDEX)\s{1,}`{$this->prefix}#i";
$table_sql = preg_replace($regex, '$1 `#__', $table_sql);
}
// Is it a VIEW but we don't have SHOW VIEW privileges?
if (empty($table_sql))
{
$type = 'view';
}
}
/**
* Replace table name and names of referenced tables with their abstracted forms and populate dependency tables
* at the same time.
*/
// On DB only backup we don't want any replacing to take place, do we?
if (!Factory::getEngineParamsProvider()->getScriptingParameter('db.abstractnames', 1))
{
$old_table_sql = $table_sql;
}
/**
* Replace the table names in the CREATE command with the abstract versions.
*
* Moreover, it updates the dependency tracking information.
*
* We have to quote the table name. If we don't we'll get wrong results. Imagine that you have a column whose
* name starts with the string literal of the table name itself.
*
* Example: table `poll`, column `poll_id` would become #__poll, #__poll_id
*
* By quoting before we make sure this won't happen.
*/
[$dependencies, $table_sql] = $this->replaceTableNamesWithAbstracts($table_name, $table_sql, !$notracking);
// On DB only backup we don't want any replacing to take place, do we?
if (!Factory::getEngineParamsProvider()->getScriptingParameter('db.abstractnames', 1))
{
$table_sql = $old_table_sql;
}
// Add final semicolon and newline character
$table_sql .= ";\n";
/**
* Views, procedures, functions and triggers may contain the database name followed by the table name, always
* quoted e.g. `db`.`table_name` We need to replace all these instances with just the table name. The only
* reliable way to do that is to look for "`db`.`" and replace it with "`"
*/
if (in_array($type, ['view', 'procedure', 'function', 'trigger']))
{
$dbName = $db->qn($this->getDatabaseName());
$dummyQuote = $db->qn('foo');
$findWhat = $dbName . '.' . substr($dummyQuote, 0, 1);
$replaceWith = substr($dummyQuote, 0, 1);
$table_sql = str_replace($findWhat, $replaceWith, $table_sql);
}
// Post-process CREATE VIEW
if ($type == 'view')
{
$pos_view = strpos($table_sql, ' VIEW ');
if ($pos_view > 7)
{
// Only post process if there are view properties between the CREATE and VIEW keywords
$propstring = substr($table_sql, 7, $pos_view - 7); // Properties string
// Fetch the ALGORITHM={UNDEFINED | MERGE | TEMPTABLE} keyword
$algostring = '';
$algo_start = strpos($propstring, 'ALGORITHM=');
if ($algo_start !== false)
{
$algo_end = strpos($propstring, ' ', $algo_start);
$algostring = substr($propstring, $algo_start, $algo_end - $algo_start + 1);
}
// Create our modified create statement
$table_sql = 'CREATE OR REPLACE ' . $algostring . substr($table_sql, $pos_view);
}
}
elseif ($type == 'procedure')
{
$pos_entity = stripos($table_sql, ' PROCEDURE ');
if ($pos_entity !== false)
{
$table_sql = 'CREATE' . substr($table_sql, $pos_entity);
}
}
elseif ($type == 'function')
{
$pos_entity = stripos($table_sql, ' FUNCTION ');
if ($pos_entity !== false)
{
$table_sql = 'CREATE' . substr($table_sql, $pos_entity);
}
}
elseif ($type == 'trigger')
{
$pos_entity = stripos($table_sql, ' TRIGGER ');
if ($pos_entity !== false)
{
$table_sql = 'CREATE' . substr($table_sql, $pos_entity);
}
}
return $table_sql;
}
/**
* Populates the _tables array with the metadata of each table and generates
* dependency information for views and merge tables. Updates $this->tables_data.
*
* @return void
*/
protected function get_tables_data()
{
Factory::getLog()->debug(__CLASS__ . " :: Starting CREATE TABLE and dependency scanning");
// Get a database connection
$db = $this->getDB();
Factory::getLog()->debug(__CLASS__ . " :: Got database connection");
// Reset internal tables
$this->tables_data = [];
$this->dependencies = [];
// Get a list of tables where their engine type is shown
$sql = 'SHOW TABLES';
$db->setQuery($sql);
$metadata_list = $db->loadRowList();
Factory::getLog()->debug(__CLASS__ . " :: Got SHOW TABLES");
// Get filters and filter root
$registry = Factory::getConfiguration();
$root = $registry->get('volatile.database.root', '[SITEDB]');
$filters = Factory::getFilters();
foreach ($metadata_list as $table_metadata)
{
// Skip over tables not included in the backup set
if (!array_key_exists($table_metadata[0], $this->table_name_map))
{
continue;
}
// Basic information
$table_name = $table_metadata[0];
$table_abstract = $this->table_name_map[$table_metadata[0]];
$new_entry = [
'type' => 'table',
'dump_records' => true,
];
// Get the CREATE command
$dependencies = [];
$new_entry['create'] = $this->get_create($table_abstract, $table_name, $new_entry['type'], $dependencies);
$new_entry['dependencies'] = $dependencies;
if ($new_entry['type'] == 'view')
{
$new_entry['dump_records'] = false;
}
else
{
$new_entry['dump_records'] = true;
}
// Scan for the table engine.
$engine = null; // So that we detect VIEWs correctly
if ($new_entry['type'] == 'table')
{
$engine = 'MyISAM'; // So that even with MySQL 4 hosts we don't screw this up
$engine_keys = ['ENGINE=', 'TYPE='];
foreach ($engine_keys as $engine_key)
{
$start_pos = strrpos($new_entry['create'], $engine_key);
if ($start_pos !== false)
{
// Advance the start position just after the position of the ENGINE keyword
$start_pos += strlen($engine_key);
// Try to locate the space after the engine type
$end_pos = stripos($new_entry['create'], ' ', $start_pos);
if ($end_pos === false)
{
// Uh... maybe it ends with ENGINE=EngineType;
$end_pos = stripos($new_entry['create'], ';', $start_pos);
}
if ($end_pos !== false)
{
// Grab the string
$engine = substr($new_entry['create'], $start_pos, $end_pos - $start_pos);
if (empty($engine))
{
Factory::getLog()->debug("*** DEBUG *** $table_name - engine $engine");
Factory::getLog()->debug($new_entry['create']);
Factory::getLog()->debug("start $start_pos - end $end_pos");
}
}
}
}
$engine = strtoupper($engine);
}
switch ($engine)
{
/*
// Views -- They are detected based on their CREATE statement
case null:
$new_entry['type'] = 'view';
$new_entry['dump_records'] = false;
break;
*/
// Merge tables
case 'MRG_MYISAM':
$new_entry['type'] = 'merge';
$new_entry['dump_records'] = false;
break;
// Tables whose data we do not back up (memory, federated and can-have-no-data tables)
case 'MEMORY':
case 'EXAMPLE':
case 'BLACKHOLE':
case 'FEDERATED':
$new_entry['dump_records'] = false;
break;
// Normal tables and VIEWs
default:
break;
}
// Table Data Filter - skip dumping table contents of filtered out tables
if ($filters->isFiltered($table_abstract, $root, 'dbobject', 'content'))
{
$new_entry['dump_records'] = false;
}
$this->tables_data[$table_name] = $new_entry;
}
Factory::getLog()->debug(__CLASS__ . " :: Got table list");
// If we have MySQL > 5.0 add stored procedures, stored functions and triggers
$enable_entities = $registry->get('engine.dump.native.advanced_entitites', true);
if ($enable_entities)
{
Factory::getLog()->debug(__CLASS__ . " :: Listing MySQL entities");
// Get a list of procedures
$sql = 'SHOW PROCEDURE STATUS WHERE `Db`=' . $db->quote($this->database);
$db->setQuery($sql);
try
{
$metadata_list = $db->loadRowList();
}
catch (Exception $e)
{
$metadata_list = null;
}
if (is_array($metadata_list))
{
if (count($metadata_list))
{
foreach ($metadata_list as $entity_metadata)
{
// Skip over entities not included in the backup set
if (!array_key_exists($entity_metadata[1], $this->table_name_map))
{
continue;
}
// Basic information
$entity_name = $entity_metadata[1];
$entity_abstract = $this->table_name_map[$entity_metadata[1]];
$new_entry = [
'type' => 'procedure',
'dump_records' => false,
];
// There's no point trying to add a non-procedure entity
if ($entity_metadata[2] != 'PROCEDURE')
{
continue;
}
$dependencies = [];
$new_entry['create'] = $this->get_create($entity_abstract, $entity_name, $new_entry['type'], $dependencies);
$new_entry['dependencies'] = $dependencies;
$this->tables_data[$entity_name] = $new_entry;
}
}
} // foreach
// Get a list of functions
$sql = 'SHOW FUNCTION STATUS WHERE `Db`=' . $db->quote($this->database);
$db->setQuery($sql);
try
{
$metadata_list = $db->loadRowList();
}
catch (Exception $e)
{
$metadata_list = null;
}
if (is_array($metadata_list))
{
if (count($metadata_list))
{
foreach ($metadata_list as $entity_metadata)
{
// Skip over entities not included in the backup set
if (!array_key_exists($entity_metadata[1], $this->table_name_map))
{
continue;
}
// Basic information
$entity_name = $entity_metadata[1];
$entity_abstract = $this->table_name_map[$entity_metadata[1]];
$new_entry = [
'type' => 'function',
'dump_records' => false,
];
// There's no point trying to add a non-function entity
if ($entity_metadata[2] != 'FUNCTION')
{
continue;
}
$dependencies = [];
$new_entry['create'] = $this->get_create($entity_abstract, $entity_name, $new_entry['type'], $dependencies);
$new_entry['dependencies'] = $dependencies;
$this->tables_data[$entity_name] = $new_entry;
}
}
} // foreach
// Get a list of triggers
$sql = 'SHOW TRIGGERS';
$db->setQuery($sql);
try
{
$metadata_list = $db->loadRowList();
}
catch (Exception $e)
{
$metadata_list = null;
}
if (is_array($metadata_list))
{
if (count($metadata_list))
{
foreach ($metadata_list as $entity_metadata)
{
// Skip over entities not included in the backup set
if (!array_key_exists($entity_metadata[0], $this->table_name_map))
{
continue;
}
// Basic information
$entity_name = $entity_metadata[0];
$entity_abstract = $this->table_name_map[$entity_metadata[0]];
$new_entry = [
'type' => 'trigger',
'dump_records' => false,
];
$dependencies = [];
$new_entry['create'] = $this->get_create($entity_abstract, $entity_name, $new_entry['type'], $dependencies);
$new_entry['dependencies'] = $dependencies;
$this->tables_data[$entity_name] = $new_entry;
}
}
} // foreach
Factory::getLog()->debug(__CLASS__ . " :: Got MySQL entities list");
}
/**
* // Only store unique values
* if(count($dependencies) > 0)
* $dependencies = array_unique($dependencies);
* /**/
}
/**
* Populates the _tables array with the metadata of each table.
* Updates $this->tables_data and $this->tables.
*
* @return void
*/
protected function get_tables_data_without_dependencies()
{
Factory::getLog()->debug(__CLASS__ . " :: Pushing table data (without dependency tracking)");
// Reset internal tables
$this->tables_data = [];
$this->dependencies = [];
// Get filters and filter root
$registry = Factory::getConfiguration();
$root = $registry->get('volatile.database.root', '[SITEDB]');
$filters = Factory::getFilters();
foreach ($this->table_name_map as $table_name => $table_abstract)
{
$new_entry = [
'type' => 'table',
'dump_records' => true,
];
// Table Data Filter - skip dumping table contents of filtered out tables
if ($filters->isFiltered($table_abstract, $root, 'dbobject', 'content'))
{
$new_entry['dump_records'] = false;
}
$this->tables_data[$table_name] = $new_entry;
$this->tables[] = $table_name;
} // foreach
Factory::getLog()->debug(__CLASS__ . " :: Got table list");
}
/**
* Generates a mapping between table names as they're stored in the database
* and their abstract representation. Updates $this->table_name_map
*
* @return void
*/
protected function get_tables_mapping()
{
// Get a database connection
Factory::getLog()->debug(__CLASS__ . " :: Finding tables to include in the backup set");
$db = $this->getDB();
// Reset internal tables
$this->table_name_map = [];
// Get the list of all database tables
$sql = "SHOW TABLES";
$db->setQuery($sql);
$all_tables = $db->loadResultArray();
$registry = Factory::getConfiguration();
$root = $registry->get('volatile.database.root', '[SITEDB]');
// If we have filters, make sure the tables pass the filtering
$filters = Factory::getFilters();
foreach ($all_tables as $table_name)
{
if (substr($table_name, 0, 3) == '#__')
{
Factory::getLog()->warning(__CLASS__ . " :: Table $table_name has a prefix of #__. This would cause restoration errors; table skipped.");
continue;
}
if ((strpos($table_name, "\r") !== false) || (strpos($table_name, "\n") !== false))
{
$table_name = str_replace(["\r", "\n"], ['\\r', '\\n'], $table_name);
Factory::getLog()->warning(__CLASS__ . " :: [SECURITY] Table $table_name includes newline characters. Skipping table to protect you against possible MySQL vulnerability CVE-2017-3600 (“Bad Dump”).");
continue;
}
$table_abstract = $this->getAbstract($table_name);
if (substr($table_abstract, 0, 4) != 'bak_') // Skip backup tables
{
// Apply exclusion filters
if (!$filters->isFiltered($table_abstract, $root, 'dbobject', 'all'))
{
Factory::getLog()->info(__CLASS__ . " :: Adding $table_name (internal name $table_abstract)");
$this->table_name_map[$table_name] = $table_abstract;
}
else
{
Factory::getLog()->info(__CLASS__ . " :: Skipping $table_name (internal name $table_abstract)");
}
}
else
{
Factory::getLog()->info(__CLASS__ . " :: Backup table $table_name automatically skipped.");
}
}
// If we have MySQL > 5.0 add the list of stored procedures, stored functions
// and triggers, but only if user has allows that and the target compatibility is
// not MySQL 4! Also, if dependency tracking is disabled, we won't dump triggers,
// functions and procedures.
$enable_entities = $registry->get('engine.dump.native.advanced_entitites', true);
$notracking = $registry->get('engine.dump.native.nodependencies', 0);
if (!$enable_entities)
{
Factory::getLog()->debug(__CLASS__ . " :: NOT listing stored PROCEDUREs, FUNCTIONs and TRIGGERs (you told me not to)");
}
elseif ($notracking != 0)
{
Factory::getLog()->debug(__CLASS__ . " :: NOT listing stored PROCEDUREs, FUNCTIONs and TRIGGERs (you have disabled dependency tracking, therefore I can't handle advanced entities)");
}
if ($enable_entities && ($notracking == 0))
{
// Cache the database name if this is the main site's database
// 1. Stored procedures
Factory::getLog()->debug(__CLASS__ . " :: Listing stored PROCEDUREs");
$sql = "SHOW PROCEDURE STATUS WHERE `Db`=" . $db->quote($this->database);
$db->setQuery($sql);
try
{
$all_entries = $db->loadResultArray(1);
}
catch (Exception $e)
{
$all_entries = [];
}
// If we have filters, make sure the tables pass the filtering
if (is_array($all_entries))
{
if (count($all_entries))
{
foreach ($all_entries as $entity_name)
{
if ((strpos($entity_name, "\r") !== false) || (strpos($entity_name, "\n") !== false))
{
$entity_name = str_replace(["\r", "\n"], ['\\r', '\\n'], $entity_name);
Factory::getLog()->warning(__CLASS__ . " :: [SECURITY] Procedure $entity_name includes newline characters. Skipping table to protect you against possible MySQL vulnerability CVE-2017-3600 (“Bad Dump”).");
continue;
}
$entity_abstract = $this->getAbstract($entity_name);
if (!(substr($entity_abstract, 0, 4) == 'bak_')) // Skip backup entities
{
if (!$filters->isFiltered($entity_abstract, $root, 'dbobject', 'all'))
{
$this->table_name_map[$entity_name] = $entity_abstract;
}
}
}
}
}
// 2. Stored functions
Factory::getLog()->debug(__CLASS__ . " :: Listing stored FUNCTIONs");
$sql = "SHOW FUNCTION STATUS WHERE `Db`=" . $db->quote($this->database);
$db->setQuery($sql);
try
{
$all_entries = $db->loadResultArray(1);
}
catch (Exception $e)
{
$all_entries = [];
}
// If we have filters, make sure the tables pass the filtering
if (is_array($all_entries))
{
if (count($all_entries))
{
foreach ($all_entries as $entity_name)
{
if ((strpos($entity_name, "\r") !== false) || (strpos($entity_name, "\n") !== false))
{
$entity_name = str_replace(["\r", "\n"], ['\\r', '\\n'], $entity_name);
Factory::getLog()->warning(__CLASS__ . " :: [SECURITY] Function $entity_name includes newline characters. Skipping table to protect you against possible MySQL vulnerability CVE-2017-3600 (“Bad Dump”).");
continue;
}
$entity_abstract = $this->getAbstract($entity_name);
if (!(substr($entity_abstract, 0, 4) == 'bak_')) // Skip backup entities
{
// Apply exclusion filters if set
if (!$filters->isFiltered($entity_abstract, $root, 'dbobject', 'all'))
{
$this->table_name_map[$entity_name] = $entity_abstract;
}
}
}
}
}
// 3. Triggers
Factory::getLog()->debug(__CLASS__ . " :: Listing stored TRIGGERs");
$sql = "SHOW TRIGGERS";
$db->setQuery($sql);
try
{
$all_entries = $db->loadResultArray();
}
catch (Exception $e)
{
$all_entries = [];
}
// If we have filters, make sure the tables pass the filtering
if (is_array($all_entries))
{
if (count($all_entries))
{
foreach ($all_entries as $entity_name)
{
if ((strpos($entity_name, "\r") !== false) || (strpos($entity_name, "\n") !== false))
{
$entity_name = str_replace(["\r", "\n"], ['\\r', '\\n'], $entity_name);
Factory::getLog()->warning(__CLASS__ . " :: [SECURITY] Trigger $entity_name includes newline characters. Skipping table to protect you against possible MySQL vulnerability CVE-2017-3600 (“Bad Dump”).");
continue;
}
$entity_abstract = $this->getAbstract($entity_name);
if (!(substr($entity_abstract, 0, 4) == 'bak_')) // Skip backup entities
{
// Apply exclusion filters if set
if (!$filters->isFiltered($entity_abstract, $root, 'dbobject', 'all'))
{
$this->table_name_map[$entity_name] = $entity_abstract;
}
}
}
}
}
} // if MySQL 5
/**
* Store all abstract entity names (tables, views, triggers etc etc ) into a volatile variable, so we can fetch
* it later when creating the databases.json file
*/
ksort($this->table_name_map);
$registry->set('volatile.database.table_names', array_values($this->table_name_map));
/**
* IMPORTANT -- DO NOT REMOVE
*
* We now need to reverse sort the table_name_map. This is of paramount importance in how the
* replaceTableNamesWithAbstracts method works. Consider the following case:
* foo_test_2 => #__test_2
* foo_test_20 => #__test_20
* If foo_test_2 comes before foo_test_2 (alpha sort) the CREATE command of foo_test_20 will end up as
* CREATE TABLE ``#__test_2`0` (...)
* instead of the correct
* CREATE TABLE `#__test_20` (...)
* That's because the first table replacement done there will be foo_test_2 => `#__test_2`. Ouch.
*
* By doing a reverse alpha sort on the keys we ENSURE that the longer table names which may be a superset of
* another table's name will always end up first on the list.
*
* In our example the first replacement made is foo_test_20 => `#__test_20`. When we reach the next possible
* replacement (foo_test_2) we no longer have the concrete table name foo_test_2 therefore we won't accidentally
* break the CREATE command.
*
* Of course the same replacement problem exists within VIEWs, TRIGGERs, PROCEDUREs and FUNCTIONs. Again, the
* reverse alpha sort by concrete table name solves this issue elegantly.
*/
krsort($this->table_name_map);
}
/**
* Process all table dependencies
*
* @return void
*/
protected function process_dependencies()
{
if ((is_array($this->table_name_map) || $this->table_name_map instanceof \Countable ? count($this->table_name_map) : 0) > 0)
{
foreach ($this->table_name_map as $table_name => $table_abstract)
{
$this->push_table($table_name);
}
}
Factory::getLog()->debug(__CLASS__ . " :: Processed dependencies");
}
/**
* Pushes a table in the _tables stack, making sure it will appear after
* its dependencies and other tables/views depending on it will eventually
* appear after it. It's a complicated chicken-and-egg problem. Just make
* sure you don't have any bloody circular references!!
*
* @param string $table_name Canonical name of the table to push
* @param array $stack When called recursive, other views/tables previously processed in order to detect
* *ahem* dependency loops...
*
* @return void
*/
protected function push_table($table_name, $stack = [], $currentRecursionDepth = 0)
{
// Load information
$table_data = $this->tables_data[$table_name];
if (array_key_exists('dependencies', $table_data))
{
$referenced = $table_data['dependencies'];
}
else
{
$referenced = [];
}
unset($table_data);
// Try to find the minimum insert position, so as to appear after the last referenced table
$insertpos = false;
if (is_array($referenced) || $referenced instanceof \Countable ? count($referenced) : 0)
{
foreach ($referenced as $referenced_table)
{
if (is_array($this->tables) || $this->tables instanceof \Countable ? count($this->tables) : 0)
{
$newpos = array_search($referenced_table, $this->tables);
if ($newpos !== false)
{
if ($insertpos === false)
{
$insertpos = $newpos;
}
else
{
$insertpos = max($insertpos, $newpos);
}
}
}
}
}
// Add to the _tables array
if ((is_array($this->tables) || $this->tables instanceof \Countable ? count($this->tables) : 0) && ($insertpos !== false))
{
array_splice($this->tables, $insertpos + 1, 0, $table_name);
}
else
{
$this->tables[] = $table_name;
}
// Here's what... Some other table/view might depend on us, so we must appear
// before it (actually, it must appear after us). So, we scan for such
// tables/views and relocate them
if (is_array($this->dependencies) || $this->dependencies instanceof \Countable ? count($this->dependencies) : 0)
{
if (array_key_exists($table_name, $this->dependencies))
{
foreach ($this->dependencies[$table_name] as $depended_table)
{
// First, make sure that either there is no stack, or the
// depended table doesn't belong it. In any other case, we
// were fooled to follow an endless dependency loop and we
// will simply bail out and let the user sort things out.
if (count($stack) > 0)
{
if (in_array($depended_table, $stack))
{
continue;
}
}
$my_position = array_search($table_name, $this->tables);
$remove_position = array_search($depended_table, $this->tables);
if (($remove_position !== false) && ($remove_position < $my_position))
{
$stack[] = $table_name;
array_splice($this->tables, $remove_position, 1);
// Where should I put the other table/view now? Don't tell me.
// I have to recurse...
if ($currentRecursionDepth < 19)
{
$this->push_table($depended_table, $stack, ++$currentRecursionDepth);
}
else
{
// We're hitting a circular dependency. We'll add the removed $depended_table
// in the penultimate position of the table and cross our virtual fingers...
array_splice($this->tables, (is_array($this->tables) || $this->tables instanceof \Countable ? count($this->tables) : 0) - 1, 0, $depended_table);
}
}
}
}
}
}
/**
* Try to find an auto_increment field for the table being currently backed up and populate the
* $this->table_autoincrement table. Updates $this->table_autoincrement.
*
* @return void
*/
protected function setAutoIncrementInfo()
{
$this->table_autoincrement = [
'table' => $this->nextTable,
'field' => null,
'value' => null,
];
$db = $this->getDB();
$query = 'SHOW COLUMNS FROM ' . $db->qn($this->nextTable) . ' WHERE ' . $db->qn('Extra') . ' = ' .
$db->q('auto_increment') . ' AND ' . $db->qn('Null') . ' = ' . $db->q('NO');
$keyInfo = $db->setQuery($query)->loadAssocList();
if (!empty($keyInfo))
{
$row = array_shift($keyInfo);
$this->table_autoincrement['field'] = $row['Field'];
}
}
/**
* Performs one more step of dumping database data
*
* @return void
*
* @throws QueryException
* @throws Exception
*/
protected function stepDatabaseDump()
{
// Initialize local variables
$db = $this->getDB();
if (!is_object($db) || ($db === false))
{
throw new RuntimeException(__CLASS__ . '::_run() Could not connect to database?!');
}
$outData = ''; // Used for outputting INSERT INTO commands
$this->enforceSQLCompatibility(); // Apply MySQL compatibility option
// Touch SQL dump file
$nada = "";
$this->writeline($nada);
// Get this table's information
$tableName = $this->nextTable;
$this->setStep($tableName);
$this->setSubstep('');
$tableAbstract = trim($this->table_name_map[$tableName]);
$dump_records = $this->tables_data[$tableName]['dump_records'];
// Restore any previously information about the largest query we had to run
$this->largest_query = Factory::getConfiguration()->get('volatile.database.largest_query', 0);
// If it is the first run, find number of rows and get the CREATE TABLE command
if ($this->nextRange == 0)
{
$outCreate = '';
if (is_array($this->tables_data[$tableName]))
{
if (array_key_exists('create', $this->tables_data[$tableName]))
{
$outCreate = $this->tables_data[$tableName]['create'];
}
}
if (empty($outCreate) && !empty($tableName))
{
// The CREATE command wasn't cached. Time to create it. The $type and $dependencies
// variables will be thrown away.
$type = $this->tables_data[$tableName]['type'] ?? 'table';
$dependencies = [];
$outCreate = $this->get_create($tableAbstract, $tableName, $type, $dependencies);
}
// Create drop statements if required (the key is defined by the scripting engine)
if (Factory::getEngineParamsProvider()->getScriptingParameter('db.dropstatements', 0))
{
if (array_key_exists('create', $this->tables_data[$tableName]))
{
$dropStatement = $this->createDrop($this->tables_data[$tableName]['create']);
}
else
{
$type = 'table';
$createStatement = $this->get_create($tableAbstract, $tableName, $type, $dependencies);
$dropStatement = $this->createDrop($createStatement);
}
if (!empty($dropStatement))
{
$dropStatement .= "\n";
if (!$this->writeDump($dropStatement, true))
{
return;
}
}
}
/**
* If we have a PROCEDURE, FUNCTION or TRIGGER and we are doing a SQL export meant to be run directly by
* MySQL (the scripting db.delimiterstatements flag is set to 1) we need to surround the CREATE statement
* with DELIMITER $$ commands.
*/
if (
(Factory::getEngineParamsProvider()->getScriptingParameter('db.delimiterstatements', 0) == 1)
&& in_array($this->tables_data[$tableName]['type'], ['trigger', 'function', 'procedure'])
)
{
$outCreate = rtrim($outCreate, ";\n");
$outCreate = "DELIMITER $$\n$outCreate$$\nDELIMITER ;\n";
}
// Write the CREATE command after any DROP command which might be necessary.
if (!$this->writeDump($outCreate, true))
{
return;
}
if ($dump_records)
{
// We are dumping data from a table, get the row count
$this->getRowCount($tableAbstract);
// If we can't get the row count we cannot back up this table's data
if (is_null($this->maxRange))
{
$dump_records = false;
}
}
else
{
/**
* Do NOT move this line to the if-block below. We need to only log this message on tables which are
* filtered, not on tables we simply cannot get the row count information for!
*/
Factory::getLog()->info("Skipping dumping data of " . $tableAbstract);
}
// The table is either filtered or we cannot get the row count. Either way we should not dump any data.
if (!$dump_records)
{
$this->maxRange = 0;
$this->nextRange = 1;
$outData = '';
$numRows = 0;
$dump_records = false;
}
// Output any data preamble commands, e.g. SET IDENTITY_INSERT for SQL Server
if ($dump_records && Factory::getEngineParamsProvider()->getScriptingParameter('db.dropstatements', 0))
{
Factory::getLog()->debug("Writing data dump preamble for " . $tableAbstract);
$preamble = $this->getDataDumpPreamble($tableAbstract, $tableName, $this->maxRange);
if (!empty($preamble))
{
if (!$this->writeDump($preamble, true))
{
return;
}
}
}
// Get the table's auto increment information
if ($dump_records)
{
$this->setAutoIncrementInfo();
}
}
// Load the active database root
$configuration = Factory::getConfiguration();
$dbRoot = $configuration->get('volatile.database.root', '[SITEDB]');
// Get the default and the current (optimal) batch size
$defaultBatchSize = $this->getDefaultBatchSize();
$batchSize = $configuration->get('volatile.database.batchsize', $defaultBatchSize);
// Check if we have more work to do on this table
if (($this->nextRange < $this->maxRange))
{
$timer = Factory::getTimer();
// Get the number of rows left to dump from the current table
$columns = $this->getSelectColumns($tableAbstract);
$columnTypes = $this->getColumnTypes($tableAbstract);
$columnsForQuery = is_array($columns) ? array_map([$db, 'qn'], $columns) : $columns;
$sql = $db->getQuery(true)
->select($columnsForQuery)
->from($db->nameQuote($tableAbstract));
if (!is_null($this->table_autoincrement['field']))
{
$sql->order($db->qn($this->table_autoincrement['field']) . ' ASC');
}
if ($this->nextRange == 0)
{
// Get the optimal batch size for this table and save it to the volatile data
$batchSize = $this->getOptimalBatchSize($tableAbstract, $defaultBatchSize);
$configuration->set('volatile.database.batchsize', $batchSize);
// First run, get a cursor to all records
$db->setQuery($sql, 0, $batchSize);
Factory::getLog()->info("Beginning dump of " . $tableAbstract);
Factory::getLog()->debug("Up to $batchSize records will be read at once.");
}
else
{
// Subsequent runs, get a cursor to the rest of the records
$this->setSubstep($this->nextRange . ' / ' . $this->maxRange);
// If we have an auto_increment value and the table has over $batchsize records use the indexed select instead of a plain limit
if (!is_null($this->table_autoincrement['field']) && !is_null($this->table_autoincrement['value']))
{
Factory::getLog()
->info("Continuing dump of " . $tableAbstract . " from record #{$this->nextRange} using auto_increment column {$this->table_autoincrement['field']} and value {$this->table_autoincrement['value']}");
$sql->where($db->qn($this->table_autoincrement['field']) . ' > ' . $db->q($this->table_autoincrement['value']));
$db->setQuery($sql, 0, $batchSize);
}
else
{
Factory::getLog()
->info("Continuing dump of " . $tableAbstract . " from record #{$this->nextRange}");
$db->setQuery($sql, $this->nextRange, $batchSize);
}
}
$this->query = '';
$numRows = 0;
$use_abstract = Factory::getEngineParamsProvider()->getScriptingParameter('db.abstractnames', 1);
$filters = Factory::getFilters();
$mustFilter = $filters->hasFilterType('dbobject', 'children');
try
{
$cursor = $db->query();
}
catch (Exception $exc)
{
// Issue a warning about the failure to dump data
$errno = $exc->getCode();
$error = $exc->getMessage();
Factory::getLog()->warning("Failed dumping $tableAbstract from record #{$this->nextRange}. MySQL error $errno: $error");
// Reset the database driver's state (we will try to dump other tables anyway)
$db->resetErrors();
$cursor = null;
// Mark this table as done since we are unable to dump it.
$this->nextRange = $this->maxRange;
}
$statsTableAbstract = Platform::getInstance()->tableNameStats;
while (is_array($myRow = $db->fetchAssoc()) && ($numRows < ($this->maxRange - $this->nextRange)))
{
if ($this->createNewPartIfRequired() == false)
{
/**
* When createNewPartIfRequired returns false it means that we have began adding a SQL part to the
* backup archive but it hasn't finished. If we don't return here, the code below will keep adding
* data to that dump file. Yes, despite being closed. When you call writeDump the file is reopened.
* As a result of writing data of length Y, the file that had a size X now has a size of X + Y. This
* means that the loop in BaseArchiver which tries to add it to the archive will never see its End
* Of File since we are trying to resume the backup from *beyond* the file position that was
* recorded as the file size. The archive can detect a file shrinking but not a file growing!
* Therefore we hit an infinite loop a.k.a. runaway backup.
*/
return;
}
$numRows++;
$numOfFields = is_array($myRow) || $myRow instanceof \Countable ? count($myRow) : 0;
// On MS SQL Server there's always a RowNumber pseudocolumn added at the end, screwing up the backup (GRRRR!)
if ($db->getDriverType() == 'mssql')
{
$numOfFields--;
}
// If row-level filtering is enabled, please run the filtering
if ($mustFilter)
{
$isFiltered = $filters->isFiltered(
[
'table' => $tableAbstract,
'row' => $myRow,
],
$dbRoot,
'dbobject',
'children'
);
if ($isFiltered)
{
// Update the auto_increment value to avoid edge cases when the batch size is one
if (!is_null($this->table_autoincrement['field']) && isset($myRow[$this->table_autoincrement['field']]))
{
$this->table_autoincrement['value'] = $myRow[$this->table_autoincrement['field']];
}
continue;
}
}
if (
(!$this->extendedInserts) || // Add header on simple INSERTs, or...
($this->extendedInserts && empty($this->query)) //...on extended INSERTs if there are no other data, yet
)
{
$newQuery = true;
$fieldList = $this->getFieldListSQL($columns);
if ($numOfFields > 0)
{
$this->query = "INSERT INTO " . $db->nameQuote((!$use_abstract ? $tableName : $tableAbstract)) . " {$fieldList} VALUES \n";
}
}
else
{
// On other cases, just mark that we should add a comma and start a new VALUES entry
$newQuery = false;
}
$outData = '(';
// Step through each of the row's values
$fieldID = 0;
// Used in running backup fix
$isCurrentBackupEntry = false;
// Fix 1.2a - NULL values were being skipped
if ($numOfFields > 0)
{
foreach ($myRow as $fieldName => $value)
{
// The ID of the field, used to determine placement of commas
$fieldID++;
if ($fieldID > $numOfFields)
{
// This is required for SQL Server backups, do NOT remove!
continue;
}
// Fix 2.0: Mark currently running backup as successful in the DB snapshot
if ($tableAbstract == $statsTableAbstract)
{
if ($fieldID == 1)
{
// Compare the ID to the currently running
$statistics = Factory::getStatistics();
$isCurrentBackupEntry = ($value == $statistics->getId());
}
elseif ($fieldID == 6)
{
// Treat the status field
$value = $isCurrentBackupEntry ? 'complete' : $value;
}
}
// Post-process the value
if (is_null($value))
{
$outData .= "NULL"; // Cope with null values
}
else
{
// Accommodate for runtime magic quotes
if (function_exists('get_magic_quotes_runtime'))
{
$value = @get_magic_quotes_runtime() ? stripslashes($value) : $value;
}
switch ($columnTypes[$fieldName] ?? '')
{
// Hex encode spatial data
case 'GEOMETRY':
case 'POINT':
case 'LINESTRING':
case 'POLYGON':
case 'MULTIPOINT':
case 'MULTILINESTRING':
case 'MULTIPOLYGON':
case 'GEOMETRYCOLLECTION':
$hexEncoded = bin2hex($value);
$value = "x'$hexEncoded'";
break;
default:
$value = $db->quote($value);
break;
}
if ($this->postProcessValues)
{
$value = $this->postProcessQuotedValue($value);
}
$outData .= $value;
}
if ($fieldID < $numOfFields)
{
$outData .= ', ';
}
}
}
$outData .= ')';
if ($numOfFields)
{
// If it's an existing query and we have extended inserts
if ($this->extendedInserts && !$newQuery)
{
// Check the existing query size
$query_length = strlen($this->query);
$data_length = strlen($outData);
if (($query_length + $data_length) > $this->packetSize)
{
// We are about to exceed the packet size. Write the data so far.
$this->query .= ";\n";
if (!$this->writeDump($this->query, true))
{
return;
}
// Then, start a new query
$fieldList = $this->getFieldListSQL($columns);
$this->query = '';
$this->query = "INSERT INTO " . $db->nameQuote((!$use_abstract ? $tableName : $tableAbstract)) . " {$fieldList} VALUES \n";
$this->query .= $outData;
}
else
{
// We have room for more data. Append $outData to the query.
$this->query .= ",\n";
$this->query .= $outData;
}
}
// If it's a brand new insert statement in an extended INSERTs set
elseif ($this->extendedInserts && $newQuery)
{
// Append the data to the INSERT statement
$this->query .= $outData;
// Let's see the size of the dumped data...
$query_length = strlen($this->query);
if ($query_length >= $this->packetSize)
{
// This was a BIG query. Write the data to disk.
$this->query .= ";\n";
if (!$this->writeDump($this->query, true))
{
return;
}
// Then, start a new query
$this->query = '';
}
}
// It's a normal (not extended) INSERT statement
else
{
// Append the data to the INSERT statement
$this->query .= $outData;
// Write the data to disk.
$this->query .= ";\n";
if (!$this->writeDump($this->query, true))
{
return;
}
// Then, start a new query
$this->query = '';
}
}
$outData = '';
// Update the auto_increment value to avoid edge cases when the batch size is one
if (!is_null($this->table_autoincrement['field']))
{
$this->table_autoincrement['value'] = $myRow[$this->table_autoincrement['field']];
}
unset($myRow);
// Check for imminent timeout
if ($timer->getTimeLeft() <= 0)
{
Factory::getLog()
->debug("Breaking dump of $tableAbstract after $numRows rows; will continue on next step");
break;
}
}
$db->freeResult($cursor);
// Advance the _nextRange pointer
$this->nextRange += ($numRows != 0) ? $numRows : 1;
$this->setStep($tableName);
$this->setSubstep($this->nextRange . ' / ' . $this->maxRange);
}
// Finalize any pending query
// WARNING! If we do not do that now, the query will be emptied in the next operation and all
// accumulated data will go away...
if (!empty($this->query))
{
$this->query .= ";\n";
if (!$this->writeDump($this->query, true))
{
return;
}
$this->query = '';
}
// Check for end of table dump (so that it happens inside the same operation)
if (!($this->nextRange < $this->maxRange))
{
// Tell the user we are done with the table
Factory::getLog()->debug("Done dumping " . $tableAbstract);
// Output any data preamble commands, e.g. SET IDENTITY_INSERT for SQL Server
if ($dump_records && Factory::getEngineParamsProvider()->getScriptingParameter('db.dropstatements', 0))
{
Factory::getLog()->debug("Writing data dump epilogue for " . $tableAbstract);
$epilogue = $this->getDataDumpEpilogue($tableAbstract, $tableName, $this->maxRange);
if (!empty($epilogue))
{
if (!$this->writeDump($epilogue, true))
{
return;
}
}
}
if ((is_array($this->tables) || $this->tables instanceof \Countable ? count($this->tables) : 0) == 0)
{
// We have finished dumping the database!
Factory::getLog()->info("End of database detected; flushing the dump buffers...");
$this->writeDump(null);
Factory::getLog()->info("Database has been successfully dumped to SQL file(s)");
$this->setState(self::STATE_POSTRUN);
$this->setStep('');
$this->setSubstep('');
$this->nextTable = '';
$this->nextRange = 0;
/**
* At the end of the database dump, if any query was longer than 1Mb, let's put a warning file in the
* installation folder, but ONLY if the backup is not a SQL-only backup (which has no backup archive).
*/
$isSQLOnly = $configuration->get('akeeba.basic.backup_type') == 'dbonly';
if (!$isSQLOnly && ($this->largest_query >= 1024 * 1024))
{
$archive = Factory::getArchiverEngine();
$archive->addFileVirtual('large_tables_detected', $this->installerSettings->installerroot, $this->largest_query);
}
}
elseif ((is_array($this->tables) || $this->tables instanceof \Countable ? count($this->tables) : 0) != 0)
{
// Switch tables
$this->nextTable = array_shift($this->tables);
$this->nextRange = 0;
$this->setStep($this->nextTable);
$this->setSubstep('');
}
}
}
}