1450 lines
42 KiB
PHP
1450 lines
42 KiB
PHP
|
<?php namespace ProcessWire;
|
|||
|
|
|||
|
/**
|
|||
|
* #pw-summary ProcessWire Database Backup and Restore
|
|||
|
* #pw-summary-initialization It’s not typically necessary to call these initialization methods unless doing manual initialization.
|
|||
|
* #pw-var $backup
|
|||
|
* #pw-instantiate $backup = $database->backups();
|
|||
|
* #pw-order-groups actions,reporting,initialization,advanced
|
|||
|
* #pw-body =
|
|||
|
* This class intentionally does not have any external dependencies (other than PDO)
|
|||
|
* so that it can be included by outside tools for restoring/exporting, with the main
|
|||
|
* example of that being the ProcessWire installer.
|
|||
|
*
|
|||
|
* The recommended way to access these backup methods is via the `$database` API variable
|
|||
|
* method `$database->backups()`, which returns a `WireDatabaseBackup` instance, however
|
|||
|
* you can also initialize the class manually if you prefer, like this:
|
|||
|
* ~~~~~
|
|||
|
* // determine where backups will go (should NOT be web accessible)
|
|||
|
* $backupPath = $config->paths->assets . 'backups/';
|
|||
|
*
|
|||
|
* // create a new WireDatabaseBackup instance
|
|||
|
* $backup = new WireDatabaseBackup($backupPath);
|
|||
|
*
|
|||
|
* // Option 1: set the already-connected DB connection
|
|||
|
* $backup->setDatabase($this->database);
|
|||
|
*
|
|||
|
* // Option 2: OR provide a Config object that contains the DB connection info
|
|||
|
* $backup->setDatabaseConfig($this->config);
|
|||
|
*
|
|||
|
* ~~~~~
|
|||
|
* ### Backup the database
|
|||
|
* ~~~~~
|
|||
|
* $file = $backup->backup();
|
|||
|
* if($file) {
|
|||
|
* echo "Backed up to: $file";
|
|||
|
* } else {
|
|||
|
* echo "Backup failed: " . implode("<br>", $backup->errors());
|
|||
|
* }
|
|||
|
* ~~~~~
|
|||
|
*
|
|||
|
* ### Restore a database
|
|||
|
* ~~~~~
|
|||
|
* $success = $backup->restore($file);
|
|||
|
* if($success) {
|
|||
|
* echo "Restored database from file: $file";
|
|||
|
* } else {
|
|||
|
* echo "Restore failed: " . implode("<br>", $backup->errors());
|
|||
|
* }
|
|||
|
* ~~~~~
|
|||
|
* #pw-body
|
|||
|
*
|
|||
|
* ProcessWire 3.x, Copyright 2023 by Ryan Cramer
|
|||
|
* https://processwire.com
|
|||
|
*
|
|||
|
*
|
|||
|
*/
|
|||
|
|
|||
|
class WireDatabaseBackup {
|
|||
|
|
|||
|
const fileHeader = '--- WireDatabaseBackup';
|
|||
|
const fileFooter = '--- /WireDatabaseBackup';
|
|||
|
|
|||
|
/**
|
|||
|
* ProcessWire instance, when applicable
|
|||
|
*
|
|||
|
* @var ProcessWire
|
|||
|
*
|
|||
|
*/
|
|||
|
protected $wire = null;
|
|||
|
|
|||
|
/**
|
|||
|
* Options available for the $options argument to backup() method
|
|||
|
*
|
|||
|
* @var array
|
|||
|
*
|
|||
|
*/
|
|||
|
protected $backupOptions = array(
|
|||
|
|
|||
|
// filename for backup: default is to make a dated filename, but this can also be used (basename only, no path)
|
|||
|
'filename' => '',
|
|||
|
|
|||
|
// optional description of this backup
|
|||
|
'description' => '',
|
|||
|
|
|||
|
// if specified, export will only include these tables
|
|||
|
'tables' => array(),
|
|||
|
|
|||
|
// username to associate with the backup file (string), optional
|
|||
|
'user' => '',
|
|||
|
|
|||
|
// exclude creating or inserting into these tables
|
|||
|
'excludeTables' => array(),
|
|||
|
|
|||
|
// exclude creating these tables, but still export data (not supported by mysqldump)
|
|||
|
'excludeCreateTables' => array(),
|
|||
|
|
|||
|
// exclude exporting data, but still create tables (not supported by mysqldump)
|
|||
|
'excludeExportTables' => array(),
|
|||
|
|
|||
|
// SQL conditions for export of individual tables (table => array(SQL conditions))
|
|||
|
// The 'table' portion (index) may also be a full PCRE regexp, must start with '/' to be recognized as regex
|
|||
|
'whereSQL' => array(),
|
|||
|
|
|||
|
// max number of seconds allowed for execution
|
|||
|
'maxSeconds' => 1200,
|
|||
|
|
|||
|
// use DROP TABLES statements before CREATE TABLE statements?
|
|||
|
'allowDrop' => true,
|
|||
|
|
|||
|
// use UPDATE ON DUPLICATE KEY so that INSERT statements can UPDATE when rows already present (all tables)
|
|||
|
'allowUpdate' => false,
|
|||
|
|
|||
|
// table names that will use UPDATE ON DUPLICATE KEY (does NOT require allowUpdate=true)
|
|||
|
'allowUpdateTables' => array(),
|
|||
|
|
|||
|
// find and replace in row data during backup (not supported by exec/mysql method)
|
|||
|
'findReplace' => array(
|
|||
|
// Example: 'databass' => 'database'
|
|||
|
),
|
|||
|
|
|||
|
// find and replace in create table statements (not supported by exec/mysqldump)
|
|||
|
'findReplaceCreateTable' => array(
|
|||
|
// Example: 'DEFAULT CHARSET=latin1;' => 'DEFAULT CHARSET=utf8;',
|
|||
|
),
|
|||
|
|
|||
|
// additional SQL queries to append at the bottom
|
|||
|
'extraSQL' => array(
|
|||
|
// Example: UPDATE pages SET CREATED=NOW
|
|||
|
),
|
|||
|
|
|||
|
// EXEC MODE IS CURRRENTLY EXPERIMENTAL AND NOT RECOMMEND FOR USE YET
|
|||
|
// if true, we will try to use mysqldump (exec) first. if false, we won't attempt mysqldump.
|
|||
|
'exec' => false,
|
|||
|
|
|||
|
// exec command to use for mysqldump (when in use)
|
|||
|
'execCommand' => '[dbPath]mysqldump
|
|||
|
--complete-insert=TRUE
|
|||
|
--add-locks=FALSE
|
|||
|
--disable-keys=FALSE
|
|||
|
--extended-insert=FALSE
|
|||
|
--default-character-set=utf8
|
|||
|
--comments=FALSE
|
|||
|
--compact
|
|||
|
--skip-disable-keys
|
|||
|
--skip-add-locks
|
|||
|
--add-drop-table=TRUE
|
|||
|
--result-file=[dbFile]
|
|||
|
--port=[dbPort]
|
|||
|
-u[dbUser]
|
|||
|
-p[dbPass]
|
|||
|
-h[dbHost]
|
|||
|
[dbName]
|
|||
|
[tables]'
|
|||
|
);
|
|||
|
|
|||
|
/**
|
|||
|
* Options available for the $options argument to restore() method
|
|||
|
*
|
|||
|
* @var array
|
|||
|
*
|
|||
|
*/
|
|||
|
protected $restoreOptions = array(
|
|||
|
|
|||
|
// table names to restore (empty=all)
|
|||
|
'tables' => array(),
|
|||
|
|
|||
|
// allow DROP TABLE statements?
|
|||
|
'allowDrop' => true,
|
|||
|
|
|||
|
// DROP ALL tables before restore? (requires that 'allowDrop' must also be true)
|
|||
|
'dropAll' => false,
|
|||
|
|
|||
|
// halt execution when an error occurs?
|
|||
|
'haltOnError' => false,
|
|||
|
|
|||
|
// max number of seconds allowed for execution
|
|||
|
'maxSeconds' => 1200,
|
|||
|
|
|||
|
// find and replace in row data (not supported by exec/mysql method)
|
|||
|
'findReplace' => array(
|
|||
|
// Example: 'databass' => 'database'
|
|||
|
),
|
|||
|
|
|||
|
// find and replace in create table statements (not supported by exec/mysql)
|
|||
|
'findReplaceCreateTable' => array(
|
|||
|
// Example: 'DEFAULT CHARSET=latin1;' => 'DEFAULT CHARSET=utf8;',
|
|||
|
),
|
|||
|
|
|||
|
// EXEC MODE IS CURRRENTLY EXPERIMENTAL AND NOT RECOMMEND FOR USE YET
|
|||
|
// if true, we will try to use mysql via exec first (faster). if false, we won't attempt that.
|
|||
|
'exec' => false,
|
|||
|
|
|||
|
// command to use for mysql exec
|
|||
|
'execCommand' => '[dbPath]mysql
|
|||
|
--port=[dbPort]
|
|||
|
-u[dbUser]
|
|||
|
-p[dbPass]
|
|||
|
-h[dbHost]
|
|||
|
[dbName] < [dbFile]',
|
|||
|
);
|
|||
|
|
|||
|
/**
|
|||
|
* @var null|\PDO
|
|||
|
*
|
|||
|
*/
|
|||
|
protected $database = null;
|
|||
|
|
|||
|
/**
|
|||
|
* @var array
|
|||
|
*
|
|||
|
*/
|
|||
|
protected $databaseConfig = array(
|
|||
|
'dbUser' => '',
|
|||
|
'dbPass' => '', // optional (if password is blank)
|
|||
|
'dbHost' => '',
|
|||
|
'dbPort' => '',
|
|||
|
'dbName' => '',
|
|||
|
'dbPath' => '', // optional mysql/mysqldump path on file system
|
|||
|
'dbSocket' => '',
|
|||
|
'dbCharset' => 'utf8',
|
|||
|
);
|
|||
|
|
|||
|
/**
|
|||
|
* Array of text indicating details about what methods were used (primarily for debugging)
|
|||
|
*
|
|||
|
* @var array
|
|||
|
*
|
|||
|
*/
|
|||
|
protected $notes = array();
|
|||
|
|
|||
|
/**
|
|||
|
* Array of text error messages
|
|||
|
*
|
|||
|
* @var array
|
|||
|
*
|
|||
|
*/
|
|||
|
protected $errors = array();
|
|||
|
|
|||
|
/**
|
|||
|
* Database files path
|
|||
|
*
|
|||
|
* @var string|null
|
|||
|
*
|
|||
|
*/
|
|||
|
protected $path = null;
|
|||
|
|
|||
|
/**
|
|||
|
* Cache for getAllTables()
|
|||
|
*
|
|||
|
* @var array
|
|||
|
*
|
|||
|
*/
|
|||
|
protected $tables = array();
|
|||
|
|
|||
|
/**
|
|||
|
* Cache for getAllTables()
|
|||
|
*
|
|||
|
* @var array
|
|||
|
*
|
|||
|
*/
|
|||
|
protected $counts = array();
|
|||
|
|
|||
|
/**
|
|||
|
* Construct
|
|||
|
*
|
|||
|
* You should follow-up the construct call with one or both of the following:
|
|||
|
*
|
|||
|
* - $backups->setDatabase(PDO|WireDatabasePDO);
|
|||
|
* - $backups->setDatabaseConfig(array|object);
|
|||
|
*
|
|||
|
* #pw-group-initialization
|
|||
|
*
|
|||
|
* @param string $path Path where database files are stored
|
|||
|
* @throws \Exception
|
|||
|
*
|
|||
|
*/
|
|||
|
public function __construct($path = '') {
|
|||
|
if(strlen($path)) $this->setPath($path);
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Set the current ProcessWire instance
|
|||
|
*
|
|||
|
* #pw-internal
|
|||
|
*
|
|||
|
* @param ProcessWire $wire
|
|||
|
*
|
|||
|
*/
|
|||
|
public function setWire($wire) {
|
|||
|
if(is_object($wire) && $wire->className() == 'ProcessWire') $this->wire = $wire;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Set the database configuration information
|
|||
|
*
|
|||
|
* #pw-group-initialization
|
|||
|
*
|
|||
|
* @param array|Config|object $config Containing these properties:
|
|||
|
* - dbUser
|
|||
|
* - dbHost
|
|||
|
* - dbPort
|
|||
|
* - dbName
|
|||
|
* - dbPass
|
|||
|
* - dbPath (optional)
|
|||
|
* - dbCharset (optional)
|
|||
|
* @return $this
|
|||
|
* @throws \Exception if missing required config settings
|
|||
|
*
|
|||
|
*/
|
|||
|
public function setDatabaseConfig($config) {
|
|||
|
|
|||
|
foreach($this->databaseConfig as $key => $_value) {
|
|||
|
if(is_object($config) && isset($config->$key)) $value = $config->$key;
|
|||
|
else if(is_array($config) && isset($config[$key])) $value = $config[$key];
|
|||
|
else $value = '';
|
|||
|
if(empty($value) && !empty($_value)) $value = $_value; // i.e. dbCharset
|
|||
|
if($key == 'dbPath' && $value) {
|
|||
|
$value = rtrim($value, '/') . '/';
|
|||
|
if(!is_dir($value)) $value = '';
|
|||
|
}
|
|||
|
$this->databaseConfig[$key] = $value;
|
|||
|
}
|
|||
|
|
|||
|
$missing = array();
|
|||
|
$optional = array('dbPass', 'dbPath', 'dbSocket', 'dbPort');
|
|||
|
foreach($this->databaseConfig as $key => $value) {
|
|||
|
if(empty($value) && !in_array($key, $optional)) $missing[] = $key;
|
|||
|
}
|
|||
|
|
|||
|
if(count($missing)) {
|
|||
|
throw new \Exception("Missing required config for: " . implode(', ', $missing));
|
|||
|
}
|
|||
|
|
|||
|
// $charset = $this->databaseConfig['dbCharset'];
|
|||
|
// $this->backupOptions['findReplaceCreateTable']['DEFAULT CHARSET=latin1;'] = "DEFAULT CHARSET=$charset;";
|
|||
|
|
|||
|
return $this;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Set the PDO database connection
|
|||
|
*
|
|||
|
* #pw-group-initialization
|
|||
|
*
|
|||
|
* @param \PDO|WireDatabasePDO $database
|
|||
|
* @throws \PDOException on invalid connection
|
|||
|
*
|
|||
|
*/
|
|||
|
public function setDatabase($database) {
|
|||
|
$query = $database->prepare('SELECT DATABASE()');
|
|||
|
$query->execute();
|
|||
|
list($dbName) = $query->fetch(\PDO::FETCH_NUM);
|
|||
|
if($dbName) $this->databaseConfig['dbName'] = $dbName;
|
|||
|
$this->database = $database;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Get current database connection, initiating the connection if not yet active
|
|||
|
*
|
|||
|
* #pw-advanced
|
|||
|
*
|
|||
|
* @return \PDO
|
|||
|
* @throws \Exception
|
|||
|
*
|
|||
|
*/
|
|||
|
public function getDatabase() {
|
|||
|
|
|||
|
if($this->database) return $this->database;
|
|||
|
|
|||
|
$config = $this->databaseConfig;
|
|||
|
if(empty($config['dbUser'])) throw new \Exception("Please call setDatabaseConfig(config) to supply config information so we can connect.");
|
|||
|
|
|||
|
if($config['dbSocket']) {
|
|||
|
$dsn = "mysql:unix_socket=$config[dbSocket];dbname=$config[dbName];";
|
|||
|
} else {
|
|||
|
$dsn = "mysql:dbname=$config[dbName];host=$config[dbHost]";
|
|||
|
if($config['dbPort']) $dsn .= ";port=$config[dbPort]";
|
|||
|
}
|
|||
|
|
|||
|
$options = array(
|
|||
|
\PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES '$config[dbCharset]'",
|
|||
|
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION
|
|||
|
);
|
|||
|
|
|||
|
$database = new \PDO($dsn, $config['dbUser'], $config['dbPass'], $options);
|
|||
|
$this->setDatabase($database);
|
|||
|
return $database;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Add an error and return last error
|
|||
|
*
|
|||
|
* #pw-group-reporting
|
|||
|
*
|
|||
|
* @param string $str If omitted, no error is added
|
|||
|
* @return string
|
|||
|
*
|
|||
|
*/
|
|||
|
public function error($str = '') {
|
|||
|
if(strlen($str)) $this->errors[] = $str; // append error message
|
|||
|
return count($this->errors) ? end($this->errors) : ''; // return last error
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Return all error messages that occurred
|
|||
|
*
|
|||
|
* #pw-group-reporting
|
|||
|
*
|
|||
|
* @param bool $reset Specify true to clear out existing errors or omit just to return error messages
|
|||
|
* @return array
|
|||
|
*
|
|||
|
*/
|
|||
|
public function errors($reset = false) {
|
|||
|
$errors = $this->errors;
|
|||
|
if($reset) $this->errors = array();
|
|||
|
return $errors;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Record a note
|
|||
|
*
|
|||
|
* #pw-group-reporting
|
|||
|
*
|
|||
|
* @param $key
|
|||
|
* @param $value
|
|||
|
*
|
|||
|
*/
|
|||
|
protected function note($key, $value) {
|
|||
|
if(!empty($this->notes[$key])) $this->notes[$key] .= ", $value";
|
|||
|
else $this->notes[$key] = $value;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Get all notes
|
|||
|
*
|
|||
|
* #pw-group-reporting
|
|||
|
*
|
|||
|
* @param bool $reset
|
|||
|
* @return array
|
|||
|
*
|
|||
|
*/
|
|||
|
public function notes($reset = false) {
|
|||
|
$notes = $this->notes;
|
|||
|
if($reset) $this->notes = array();
|
|||
|
return $notes;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Set path where database files are stored
|
|||
|
*
|
|||
|
* #pw-group-initialization
|
|||
|
*
|
|||
|
* @param string $path
|
|||
|
* @return $this
|
|||
|
* @throws \Exception if path has a problem
|
|||
|
*
|
|||
|
*/
|
|||
|
public function setPath($path) {
|
|||
|
$path = $this->sanitizePath($path);
|
|||
|
if(!is_dir($path)) throw new \Exception("Path doesn't exist: $path");
|
|||
|
if(!is_writable($path)) throw new \Exception("Path isn't writable: $path");
|
|||
|
$this->path = $path;
|
|||
|
return $this;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Get path where database files are stored
|
|||
|
*
|
|||
|
* #pw-group-reporting
|
|||
|
*
|
|||
|
* @return string
|
|||
|
*
|
|||
|
*/
|
|||
|
public function getPath() {
|
|||
|
return $this->path;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Return array of all backup files
|
|||
|
*
|
|||
|
* To get additional info on any of them, call getFileInfo($basename) method
|
|||
|
*
|
|||
|
* #pw-group-reporting
|
|||
|
*
|
|||
|
* @param bool $getObjects Get SplFileInfo objects rather than basenames? (3.0.214+)
|
|||
|
* @return array|\SplFileInfo[] Array of strings (basenames), or array of SplFileInfo objects (when requested)
|
|||
|
*
|
|||
|
*/
|
|||
|
public function getFiles($getObjects = false) {
|
|||
|
$dir = new \DirectoryIterator($this->path);
|
|||
|
$files = array();
|
|||
|
foreach($dir as $file) {
|
|||
|
if($file->isDot() || $file->isDir()) continue;
|
|||
|
$key = $file->getMTime();
|
|||
|
while(isset($files[$key])) $key++;
|
|||
|
$files[$key] = ($getObjects ? $file : $file->getBasename());
|
|||
|
}
|
|||
|
krsort($files); // sort by date, newest to oldest
|
|||
|
return array_values($files);
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Get information about a backup file
|
|||
|
*
|
|||
|
* #pw-group-reporting
|
|||
|
*
|
|||
|
* @param string $filename
|
|||
|
* @return array Returns associative array of information on success, empty array on failure
|
|||
|
*
|
|||
|
*/
|
|||
|
public function getFileInfo($filename) {
|
|||
|
|
|||
|
// all possible info (null values become integers when populated)
|
|||
|
$info = array(
|
|||
|
'description' => '',
|
|||
|
'valid' => false,
|
|||
|
'time' => '', // ISO-8601
|
|||
|
'mtime' => null, // timestamp
|
|||
|
'user' => '',
|
|||
|
'size' => null,
|
|||
|
'basename' => '',
|
|||
|
'pathname' => '',
|
|||
|
'dbName' => '',
|
|||
|
'tables' => array(),
|
|||
|
'excludeTables' => array(),
|
|||
|
'excludeCreateTables' => array(),
|
|||
|
'excludeExportTables' => array(),
|
|||
|
'numTables' => null,
|
|||
|
'numCreateTables' => null,
|
|||
|
'numInserts' => null,
|
|||
|
'numSeconds' => null,
|
|||
|
);
|
|||
|
|
|||
|
$filename = $this->sanitizeFilename($filename);
|
|||
|
if(!file_exists($filename)) return array();
|
|||
|
|
|||
|
$fp = fopen($filename, 'r');
|
|||
|
|
|||
|
if($fp === false) {
|
|||
|
$this->error('Unable to open file for reading: ' . basename($filename));
|
|||
|
return array();
|
|||
|
}
|
|||
|
|
|||
|
$line = fgets($fp);
|
|||
|
if(strpos($line, self::fileHeader) === 0 || strpos($line, "# " . self::fileHeader) === 0) {
|
|||
|
$pos = strpos($line, '{');
|
|||
|
if($pos !== false) {
|
|||
|
$json = substr($line, $pos);
|
|||
|
$info2 = json_decode($json, true);
|
|||
|
if(!$info2) $info2 = array();
|
|||
|
foreach($info2 as $key => $value) $info[$key] = $value;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
$bytes = strlen(self::fileFooter) + 255; // some extra bytes in case something gets added at the end
|
|||
|
fseek($fp, $bytes * -1, SEEK_END);
|
|||
|
$foot = fread($fp, $bytes);
|
|||
|
$info['valid'] = strpos($foot, self::fileFooter) !== false;
|
|||
|
fclose($fp);
|
|||
|
|
|||
|
// footer summary
|
|||
|
$pos = strpos($foot, self::fileFooter);
|
|||
|
if($pos !== false) $pos += strlen(self::fileFooter);
|
|||
|
if($info['valid'] && $pos !== false) {
|
|||
|
$json = substr($foot, $pos);
|
|||
|
$summary = json_decode($json, true);
|
|||
|
if(is_array($summary)) $info = array_merge($info, $summary);
|
|||
|
}
|
|||
|
|
|||
|
$info['size'] = filesize($filename);
|
|||
|
$info['mtime'] = filemtime($filename);
|
|||
|
$info['pathname'] = $filename;
|
|||
|
$info['basename'] = basename($filename);
|
|||
|
|
|||
|
return $info;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Get array of all table names
|
|||
|
*
|
|||
|
* #pw-group-reporting
|
|||
|
*
|
|||
|
* @param bool $count If true, returns array indexed by name with count of records as value
|
|||
|
* @param bool $cache Allow use of cache?
|
|||
|
* @return array
|
|||
|
*
|
|||
|
*/
|
|||
|
public function getAllTables($count = false, $cache = true) {
|
|||
|
|
|||
|
if($cache) {
|
|||
|
if($count && count($this->counts)) return $this->counts;
|
|||
|
if(count($this->tables)) return $this->tables;
|
|||
|
} else {
|
|||
|
$this->tables = array();
|
|||
|
$this->counts = array();
|
|||
|
}
|
|||
|
|
|||
|
$query = $this->database->prepare('SHOW TABLES');
|
|||
|
$query->execute();
|
|||
|
/** @noinspection PhpAssignmentInConditionInspection */
|
|||
|
while($row = $query->fetch(\PDO::FETCH_NUM)) $this->tables[$row[0]] = $row[0];
|
|||
|
$query->closeCursor();
|
|||
|
|
|||
|
if($count) {
|
|||
|
foreach($this->tables as $table) {
|
|||
|
$query = $this->database->prepare("SELECT COUNT(*) FROM `$table`");
|
|||
|
$query->execute();
|
|||
|
$row = $query->fetch(\PDO::FETCH_NUM);
|
|||
|
$this->counts[$table] = (int) $row[0];
|
|||
|
}
|
|||
|
$query->closeCursor();
|
|||
|
return $this->counts;
|
|||
|
|
|||
|
} else {
|
|||
|
return $this->tables;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
|||
|
|
|||
|
/**
|
|||
|
* Perform a database export/dump
|
|||
|
*
|
|||
|
* #pw-group-actions
|
|||
|
*
|
|||
|
* @param array $options Options to modify default behavior:
|
|||
|
* - `filename` (string): filename for backup: default is to make a dated filename, but this can also be used (basename only, no path)
|
|||
|
* - `description` (string): optional description of this backup
|
|||
|
* - `tables` (array): if specified, export will only include these tables
|
|||
|
* - `user` (string): username to associate with the backup file (string), optional
|
|||
|
* - `excludeTables` (array): exclude creating or inserting into these tables
|
|||
|
* - `excludeCreateTables` (array): exclude creating these tables, but still export data
|
|||
|
* - `excludeExportTables` (array): exclude exporting data, but still create tables
|
|||
|
* - `whereSQL` (array): SQL conditions for export of individual tables [table => [SQL conditions]]. The `table` portion (index) may also be a full PCRE regexp, must start with `/` to be recognized as regex.
|
|||
|
* - `maxSeconds` (int): max number of seconds allowed for execution (default=1200)
|
|||
|
* - `allowDrop` (bool): use DROP TABLES statements before CREATE TABLE statements? (default=true)
|
|||
|
* - `allowUpdate` (bool): use UPDATE ON DUPLICATE KEY so that INSERT statements can UPDATE when rows already present (all tables). (default=false)
|
|||
|
* - `allowUpdateTables` (array): table names that will use UPDATE ON DUPLICATE KEY (does NOT require allowUpdate=true)
|
|||
|
* - `findReplace` (array): find and replace in row data during backup. Example: ['databass' => 'database']
|
|||
|
* - `findReplaceCreateTable` (array): find and replace in create table statements
|
|||
|
* Example: ['DEFAULT CHARSET=latin1;' => 'DEFAULT CHARSET=utf8;']
|
|||
|
* - `extraSQL` (array): additional SQL queries to append at the bottom. Example: ['UPDATE pages SET created=NOW()']
|
|||
|
* @return string Full path and filename of database export file, or false on failure.
|
|||
|
* @throws \Exception on fatal error
|
|||
|
* @see WireDatabaseBackup::restore()
|
|||
|
*
|
|||
|
*/
|
|||
|
public function backup(array $options = array()) {
|
|||
|
|
|||
|
if(!$this->path) throw new \Exception("Please call setPath('/backup/files/path/') first");
|
|||
|
$this->errors(true);
|
|||
|
$options = array_merge($this->backupOptions, $options);
|
|||
|
|
|||
|
if(empty($options['filename'])) {
|
|||
|
// generate unique filename
|
|||
|
$tail = ((count($options['tables']) || count($options['excludeTables']) || count($options['excludeExportTables'])) ? '-part' : '');
|
|||
|
$n = 0;
|
|||
|
do {
|
|||
|
$options['filename'] = $this->databaseConfig['dbName'] . '_' . date('Y-m-d_H-i-s') . $tail . ($n ? "-$n" : "") . ".sql";
|
|||
|
$n++;
|
|||
|
} while(file_exists($this->path . $options['filename']));
|
|||
|
} else {
|
|||
|
$options['filename'] = basename($options['filename']);
|
|||
|
}
|
|||
|
|
|||
|
set_time_limit($options['maxSeconds']);
|
|||
|
$file = false;
|
|||
|
|
|||
|
if($this->supportsExec($options)) {
|
|||
|
$file = $this->backupExec($this->path . $options['filename'], $options);
|
|||
|
$this->note('method', 'exec_mysqldump');
|
|||
|
}
|
|||
|
|
|||
|
if(!$file) {
|
|||
|
$file = $this->backupPDO($this->path . $options['filename'], $options);
|
|||
|
$this->note('method', 'pdo');
|
|||
|
}
|
|||
|
|
|||
|
$success = false;
|
|||
|
if($file && file_exists($file)) {
|
|||
|
if(!filesize($file)) {
|
|||
|
$this->unlink($file);
|
|||
|
} else {
|
|||
|
$success = true;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
return $success ? $file : false;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Unlink file using PW if available or PHP if not
|
|||
|
*
|
|||
|
* @param string $file
|
|||
|
* @return bool
|
|||
|
* @throws WireException
|
|||
|
*
|
|||
|
*/
|
|||
|
protected function unlink($file) {
|
|||
|
if(!is_file($file)) return false;
|
|||
|
if($this->wire) {
|
|||
|
return $this->wire->files->unlink($file, true);
|
|||
|
} else {
|
|||
|
return unlink($file);
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Set backup options
|
|||
|
*
|
|||
|
* #pw-internal
|
|||
|
*
|
|||
|
* @param array $options
|
|||
|
* @return $this
|
|||
|
*
|
|||
|
*/
|
|||
|
public function setBackupOptions(array $options) {
|
|||
|
$this->backupOptions = array_merge($this->backupOptions, $options);
|
|||
|
return $this;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Start a new backup file, adding our info header to the top
|
|||
|
*
|
|||
|
* @param string $file
|
|||
|
* @param array $options
|
|||
|
* @return bool
|
|||
|
*
|
|||
|
*/
|
|||
|
protected function backupStartFile($file, array $options) {
|
|||
|
|
|||
|
$fp = fopen($file, 'w+');
|
|||
|
|
|||
|
if(!$fp) {
|
|||
|
$this->error("Unable to write header to file: $file");
|
|||
|
return false;
|
|||
|
}
|
|||
|
|
|||
|
$info = array(
|
|||
|
'time' => date('Y-m-d H:i:s'),
|
|||
|
'user' => $options['user'],
|
|||
|
'dbName' => $this->databaseConfig['dbName'],
|
|||
|
'description' => $options['description'],
|
|||
|
'tables' => $options['tables'],
|
|||
|
'excludeTables' => $options['excludeTables'],
|
|||
|
'excludeCreateTables' => $options['excludeCreateTables'],
|
|||
|
'excludeExportTables' => $options['excludeExportTables'],
|
|||
|
);
|
|||
|
|
|||
|
$json = json_encode($info);
|
|||
|
$json = str_replace(array("\r", "\n"), " ", $json);
|
|||
|
|
|||
|
fwrite($fp, "# " . self::fileHeader . " $json\n");
|
|||
|
fclose($fp);
|
|||
|
if($this->wire) $this->wire->files->chmod($file);
|
|||
|
return true;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* End a new backup file, adding our footer to the bottom
|
|||
|
*
|
|||
|
* @param string|resource $file
|
|||
|
* @param array $summary
|
|||
|
* @param array $options
|
|||
|
* @return bool
|
|||
|
*
|
|||
|
*/
|
|||
|
protected function backupEndFile($file, array $summary = array(), array $options = array()) {
|
|||
|
|
|||
|
$fp = is_resource($file) ? $file : fopen($file, 'a+');
|
|||
|
|
|||
|
if(!$fp) {
|
|||
|
$this->error("Unable to write footer to file: $file");
|
|||
|
return false;
|
|||
|
}
|
|||
|
|
|||
|
foreach($options['extraSQL'] as $sql) {
|
|||
|
fwrite($fp, "\n" . rtrim($sql, '; ') . ";\n");
|
|||
|
}
|
|||
|
|
|||
|
$footer = "# " . self::fileFooter;
|
|||
|
if(count($summary)) {
|
|||
|
$json = json_encode($summary);
|
|||
|
$json = str_replace(array("\r", "\n"), " ", $json);
|
|||
|
$footer .= " $json";
|
|||
|
}
|
|||
|
|
|||
|
fwrite($fp, "\n$footer");
|
|||
|
fclose($fp);
|
|||
|
return true;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Create a mysql dump file using PDO
|
|||
|
*
|
|||
|
* @param string $file Path + filename to create
|
|||
|
* @param array $options
|
|||
|
* @return string|bool Returns the created file on success or false on error
|
|||
|
*
|
|||
|
*/
|
|||
|
protected function backupPDO($file, array $options = array()) {
|
|||
|
|
|||
|
$database = $this->getDatabase();
|
|||
|
$options = array_merge($this->backupOptions, $options);
|
|||
|
if(!$this->backupStartFile($file, $options)) return false;
|
|||
|
$startTime = time();
|
|||
|
$fp = fopen($file, "a+");
|
|||
|
$tables = $this->getAllTables();
|
|||
|
$numCreateTables = 0;
|
|||
|
$numTables = 0;
|
|||
|
$numInserts = 0;
|
|||
|
$hasReplace = count($options['findReplace']);
|
|||
|
|
|||
|
foreach($tables as $table) {
|
|||
|
|
|||
|
if(in_array($table, $options['excludeTables'])) continue;
|
|||
|
if(count($options['tables']) && !in_array($table, $options['tables'])) continue;
|
|||
|
|
|||
|
if(in_array($table, $options['excludeCreateTables'])) {
|
|||
|
// skip
|
|||
|
} else {
|
|||
|
if($options['allowDrop']) fwrite($fp, "\nDROP TABLE IF EXISTS `$table`;");
|
|||
|
$query = $database->prepare("SHOW CREATE TABLE `$table`");
|
|||
|
$query->execute();
|
|||
|
$row = $query->fetch(\PDO::FETCH_NUM);
|
|||
|
$createTable = $row[1];
|
|||
|
foreach($options['findReplaceCreateTable'] as $find => $replace) {
|
|||
|
$createTable = str_replace($find, $replace, $createTable);
|
|||
|
}
|
|||
|
$numCreateTables++;
|
|||
|
fwrite($fp, "\n$createTable;\n");
|
|||
|
}
|
|||
|
|
|||
|
if(in_array($table, $options['excludeExportTables'])) continue;
|
|||
|
$numTables++;
|
|||
|
$columns = array();
|
|||
|
$query = $database->prepare("SHOW COLUMNS FROM `$table`");
|
|||
|
$query->execute();
|
|||
|
/** @noinspection PhpAssignmentInConditionInspection */
|
|||
|
while($row = $query->fetch(\PDO::FETCH_NUM)) $columns[] = $row[0];
|
|||
|
$query->closeCursor();
|
|||
|
$columnsStr = '`' . implode('`, `', $columns) . '`';
|
|||
|
|
|||
|
$sql = "SELECT $columnsStr FROM `$table` ";
|
|||
|
|
|||
|
$conditions = array();
|
|||
|
foreach($options['whereSQL'] as $_table => $_conditions) {
|
|||
|
if($_table === $table || ($_table[0] == '/' && preg_match($_table, $table))) $conditions = array_merge($conditions, $_conditions);
|
|||
|
}
|
|||
|
if(count($conditions)) {
|
|||
|
$sql .= "WHERE ";
|
|||
|
foreach(array_values($conditions) as $n => $condition) {
|
|||
|
if($n) $sql .= "AND ";
|
|||
|
$sql .= "($condition) ";
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
$query = $database->prepare($sql);
|
|||
|
$this->executeQuery($query);
|
|||
|
|
|||
|
/** @noinspection PhpAssignmentInConditionInspection */
|
|||
|
while($row = $query->fetch(\PDO::FETCH_NUM)) {
|
|||
|
$numInserts++;
|
|||
|
$out = "\nINSERT INTO `$table` ($columnsStr) VALUES(";
|
|||
|
foreach($row as $value) {
|
|||
|
if(is_null($value)) {
|
|||
|
$value = 'NULL';
|
|||
|
} else {
|
|||
|
if($hasReplace) foreach($options['findReplace'] as $find => $replace) {
|
|||
|
if(strpos($value, $find)) $value = str_replace($find, $replace, $value);
|
|||
|
}
|
|||
|
$value = $database->quote($value);
|
|||
|
}
|
|||
|
$out .= "$value, ";
|
|||
|
}
|
|||
|
$out = rtrim($out, ", ") . ") ";
|
|||
|
if($options['allowUpdate']) {
|
|||
|
$out .= "ON DUPLICATE KEY UPDATE ";
|
|||
|
foreach($columns as $c) $out .= "`$c`=VALUES(`$c`), ";
|
|||
|
}
|
|||
|
|
|||
|
$out = rtrim($out, ", ") . ";";
|
|||
|
fwrite($fp, $out);
|
|||
|
}
|
|||
|
|
|||
|
$query->closeCursor();
|
|||
|
fwrite($fp, "\n");
|
|||
|
}
|
|||
|
|
|||
|
$summary = array(
|
|||
|
'numTables' => $numTables,
|
|||
|
'numCreateTables' => $numCreateTables,
|
|||
|
'numInserts' => $numInserts,
|
|||
|
'numSeconds' => time() - $startTime,
|
|||
|
);
|
|||
|
$this->backupEndFile($fp, $summary, $options); // this does the fclose
|
|||
|
|
|||
|
return file_exists($file) ? $file : false;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Create a mysql dump file using exec(mysqldump)
|
|||
|
*
|
|||
|
* @param string $file Path + filename to create
|
|||
|
* @param array $options
|
|||
|
* @return string|bool Returns the created file on success or false on error
|
|||
|
*
|
|||
|
* @todo add backupStartFile/backupEndFile support
|
|||
|
*
|
|||
|
*/
|
|||
|
protected function backupExec($file, array $options) {
|
|||
|
|
|||
|
$cmd = $options['execCommand'];
|
|||
|
$cmd = str_replace(array("\n", "\t"), ' ', $cmd);
|
|||
|
$cmd = str_replace('[tables]', implode(' ', $options['tables']), $cmd);
|
|||
|
|
|||
|
foreach($options['excludeTables'] as $table) {
|
|||
|
$cmd .= " --ignore-table=$table";
|
|||
|
}
|
|||
|
|
|||
|
if(strpos($cmd, '[dbFile]')) {
|
|||
|
$cmd = str_replace('[dbFile]', $file, $cmd);
|
|||
|
} else {
|
|||
|
$cmd .= " > $file";
|
|||
|
}
|
|||
|
|
|||
|
foreach($this->databaseConfig as $key => $value) {
|
|||
|
$cmd = str_replace("[$key]", $value, $cmd);
|
|||
|
}
|
|||
|
|
|||
|
exec($cmd);
|
|||
|
|
|||
|
if(file_exists($file)) {
|
|||
|
if(filesize($file) > 0) return $file;
|
|||
|
$this->unlink($file);
|
|||
|
}
|
|||
|
|
|||
|
return false;
|
|||
|
}
|
|||
|
|
|||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
|||
|
|
|||
|
|
|||
|
/**
|
|||
|
* Restore/import a MySQL database dump file
|
|||
|
*
|
|||
|
* This method is designed to restore dump files created by the backup() method of this
|
|||
|
* class, however it *may* also work with dump files created from other sources like
|
|||
|
* mysqldump or PhpMyAdmin.
|
|||
|
*
|
|||
|
* #pw-group-actions
|
|||
|
*
|
|||
|
* @param string $filename Filename to restore, optionally including path (if no path, then path set to construct is assumed)
|
|||
|
* @param array $options Options to modify default behavior:
|
|||
|
* - `tables` (array): table names to restore (empty=all)
|
|||
|
* - `allowDrop` (bool): allow DROP TABLE statements (default=true)
|
|||
|
* - `dropAll` (bool): DROP ALL tables before restore? The allowDrop optional must also be true. (default=false)
|
|||
|
* - `haltOnError` (bool): halt execution when an error occurs? (default=false)
|
|||
|
* - `maxSeconds` (int): max number of seconds allowed for execution (default=1200)
|
|||
|
* - `findReplace` (array): find and replace in row data. Example: ['databass' => 'database']
|
|||
|
* - `findReplaceCreateTable` (array): find and replace in create table statements.
|
|||
|
* Example: ['DEFAULT CHARSET=utf8;' => 'DEFAULT CHARSET=utf8mb4;']
|
|||
|
* @return true on success, false on failure. Call the errors() method to retrieve errors.
|
|||
|
* @throws \Exception on fatal error
|
|||
|
* @see WireDatabaseBackup::backup()
|
|||
|
*
|
|||
|
*/
|
|||
|
public function restore($filename, array $options = array()) {
|
|||
|
|
|||
|
$filename = $this->sanitizeFilename($filename);
|
|||
|
if(!file_exists($filename)) throw new \Exception("Restore file does not exist: $filename");
|
|||
|
$options = array_merge($this->restoreOptions, $options);
|
|||
|
set_time_limit($options['maxSeconds']);
|
|||
|
$success = false;
|
|||
|
|
|||
|
$this->errors(true);
|
|||
|
$this->notes(true);
|
|||
|
|
|||
|
if($this->supportsExec($options)) {
|
|||
|
$this->note('method', 'exec_mysql');
|
|||
|
$success = $this->restoreExec($filename, $options);
|
|||
|
if(!$success) $this->error("Exec mysql failed, attempting PDO...");
|
|||
|
}
|
|||
|
|
|||
|
if(!$success) {
|
|||
|
$this->note('method', 'pdo');
|
|||
|
$success = $this->restorePDO($filename, $options);
|
|||
|
}
|
|||
|
|
|||
|
return $success;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Set restore options
|
|||
|
*
|
|||
|
* #pw-internal
|
|||
|
*
|
|||
|
* @param array $options
|
|||
|
* @return $this
|
|||
|
*
|
|||
|
*/
|
|||
|
public function setRestoreOptions(array $options) {
|
|||
|
$this->restoreOptions = array_merge($this->restoreOptions, $options);
|
|||
|
return $this;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Import a database SQL file using PDO
|
|||
|
*
|
|||
|
* @param string $filename Filename to restore (must be SQL file exported by this class)
|
|||
|
* @param array $options See $restoreOptions
|
|||
|
* @return bool true on success, false on failure. Call the errors() method to retrieve errors.
|
|||
|
*
|
|||
|
*/
|
|||
|
protected function restorePDO($filename, array $options = array()) {
|
|||
|
|
|||
|
$fp = fopen($filename, "rb");
|
|||
|
if($fp === false) {
|
|||
|
$this->error("Unable to open: $filename");
|
|||
|
return false;
|
|||
|
}
|
|||
|
$numInserts = 0;
|
|||
|
$numTables = 0;
|
|||
|
$numQueries = 0;
|
|||
|
|
|||
|
if($options['allowDrop'] === true && $options['dropAll'] === true) {
|
|||
|
$this->dropAllTables();
|
|||
|
}
|
|||
|
|
|||
|
$tables = array(); // selective tables to restore, optional
|
|||
|
foreach($options['tables'] as $table) $tables[$table] = $table;
|
|||
|
if(!count($tables)) $tables = null;
|
|||
|
|
|||
|
while(!feof($fp)) {
|
|||
|
|
|||
|
$line = trim(fgets($fp));
|
|||
|
if(!$this->restoreUseLine($line)) continue;
|
|||
|
|
|||
|
if(preg_match('/^(INSERT|CREATE|DROP)\s+(?:INTO|TABLE IF EXISTS|TABLE IF NOT EXISTS|TABLE)\s+`?([^\s`]+)/i', $line, $matches)) {
|
|||
|
$command = strtoupper($matches[1]);
|
|||
|
$table = $matches[2];
|
|||
|
} else {
|
|||
|
$command = '';
|
|||
|
$table = '';
|
|||
|
}
|
|||
|
|
|||
|
if($command === 'CREATE') {
|
|||
|
if(!$options['allowDrop'] && stripos($line, 'CREATE TABLE IF NOT EXISTS') === false) {
|
|||
|
$line = str_ireplace('CREATE TABLE', 'CREATE TABLE IF NOT EXISTS', $line);
|
|||
|
}
|
|||
|
} else if($command === 'DROP') {
|
|||
|
if(!$options['allowDrop']) continue;
|
|||
|
|
|||
|
} else if($command === 'INSERT' && $tables) {
|
|||
|
if(!isset($tables[$table])) continue; // skip tables not selected for import
|
|||
|
}
|
|||
|
|
|||
|
while(substr($line, -1) != ';' && !feof($fp)) {
|
|||
|
// get the rest of the lines in the query (if multi-line)
|
|||
|
$_line = trim(fgets($fp));
|
|||
|
if($this->restoreUseLine($_line)) $line .= $_line;
|
|||
|
}
|
|||
|
|
|||
|
$replacements = $command === 'CREATE' ? $options['findReplaceCreateTable'] : $options['findReplace'];
|
|||
|
if(count($replacements)) foreach($replacements as $find => $replace) {
|
|||
|
if(strpos($line, $find) === false) continue;
|
|||
|
$line = str_replace($find, $replace, $line);
|
|||
|
}
|
|||
|
|
|||
|
try {
|
|||
|
$this->executeQuery($line, $options);
|
|||
|
if($command === 'INSERT') $numInserts++;
|
|||
|
if($command === 'CREATE') $numTables++;
|
|||
|
$numQueries++;
|
|||
|
|
|||
|
} catch(\Exception $e) {
|
|||
|
$this->error($e->getMessage());
|
|||
|
if($options['haltOnError']) break;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
fclose($fp);
|
|||
|
|
|||
|
$this->note('queries', $numQueries);
|
|||
|
$this->note('inserts', $numInserts);
|
|||
|
$this->note('tables', $numTables);
|
|||
|
|
|||
|
if(count($this->errors) > 0) {
|
|||
|
$this->error(count($this->errors) . " queries generated errors ($numQueries queries and $numInserts inserts for $numTables were successful)");
|
|||
|
return false;
|
|||
|
} else {
|
|||
|
return $numQueries > 0;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Import a database SQL file using exec(mysql)
|
|||
|
*
|
|||
|
* @param string $filename Filename to restore (must be SQL file exported by this class)
|
|||
|
* @param array $options See $restoreOptions
|
|||
|
* @return bool True on success, false on failure. Call the errors() method to retrieve errors.
|
|||
|
*
|
|||
|
*/
|
|||
|
protected function restoreExec($filename, array $options = array()) {
|
|||
|
|
|||
|
$cmd = $options['execCommand'];
|
|||
|
$cmd = str_replace(array("\n", "\t"), ' ', $cmd);
|
|||
|
$cmd = str_replace('[dbFile]', $filename, $cmd);
|
|||
|
|
|||
|
foreach($this->databaseConfig as $key => $value) {
|
|||
|
$cmd = str_replace("[$key]", $value, $cmd);
|
|||
|
}
|
|||
|
|
|||
|
$o = array();
|
|||
|
$r = 0;
|
|||
|
exec($cmd, $o, $r);
|
|||
|
|
|||
|
if($r > 0) {
|
|||
|
// 0=success, 1=warning, 2=not found
|
|||
|
$this->error("mysql reported error code $r");
|
|||
|
foreach($o as $e) $this->error($e);
|
|||
|
return false;
|
|||
|
}
|
|||
|
|
|||
|
return true;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Returns true or false if a line should be used for restore
|
|||
|
*
|
|||
|
* @param $line
|
|||
|
* @return bool
|
|||
|
*
|
|||
|
*/
|
|||
|
protected function restoreUseLine($line) {
|
|||
|
if(empty($line) || substr($line, 0, 2) == '--' || substr($line, 0, 1) == '#') return false;
|
|||
|
return true;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Restore from 2 SQL files while resolving table differences (think of it as array_merge for a DB restore)
|
|||
|
*
|
|||
|
* The CREATE TABLE and INSERT statements in filename2 take precedence of those in filename1.
|
|||
|
* INSERT statements from both will be executed, with filename2 INSERTs updating those of filename1.
|
|||
|
* CREATE TABLE statements in filename1 won't be executed if they also exist in filename2.
|
|||
|
*
|
|||
|
* This method assumes both files follow the SQL dump format created by this class.
|
|||
|
*
|
|||
|
* #pw-advanced
|
|||
|
*
|
|||
|
* @param string $filename1 Original filename
|
|||
|
* @param string $filename2 Filename that may have statements that will update/override those in filename1
|
|||
|
* @param array $options
|
|||
|
* @return bool True on success, false on fail.
|
|||
|
* @throws \Exception|WireException if $options['haltOnErrors'] == true.
|
|||
|
*
|
|||
|
*/
|
|||
|
public function restoreMerge($filename1, $filename2, $options) {
|
|||
|
|
|||
|
$options = array_merge($this->restoreOptions, $options);
|
|||
|
$creates1 = $this->findCreateTables($filename1, $options);
|
|||
|
$creates2 = $this->findCreateTables($filename2, $options);
|
|||
|
$creates = array_merge($creates1, $creates2); // CREATE TABLE statements in filename2 override those in filename1
|
|||
|
$numErrors = 0;
|
|||
|
|
|||
|
foreach($creates as $table => $create) {
|
|||
|
if($options['allowDrop']) {
|
|||
|
if(!$this->executeQuery("DROP TABLE IF EXISTS `$table`", $options)) $numErrors++;
|
|||
|
}
|
|||
|
if(!$this->executeQuery($create, $options)) $numErrors++;
|
|||
|
}
|
|||
|
|
|||
|
$inserts = $this->findInserts($filename1);
|
|||
|
foreach($inserts as /*$table =>*/ $tableInserts) {
|
|||
|
foreach($tableInserts as $insert) {
|
|||
|
if(!$this->executeQuery($insert, $options)) $numErrors++;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
// Convert line 1 to line 2:
|
|||
|
// 1. INSERT INTO `field_process` (pages_id, data) VALUES('6', '17');
|
|||
|
// 2. INSERT INTO `field_process` (pages_id, data) VALUES('6', '17') ON DUPLICATE KEY UPDATE pages_id=VALUES(pages_id), data=VALUES(data);
|
|||
|
|
|||
|
$inserts = $this->findInserts($filename2);
|
|||
|
foreach($inserts as $table => $tableInserts) {
|
|||
|
foreach($tableInserts as $insert) {
|
|||
|
// check if table existed in both dump files, and has no duplicate update statement
|
|||
|
$regex = '/\s+ON\s+DUPLICATE\s+KEY\s+UPDATE\s+[^\'";]+;$/i';
|
|||
|
if(isset($creates1[$table]) && !preg_match($regex, $insert)) {
|
|||
|
// line doesn't already contain an ON DUPLICATE section, so we need to add it
|
|||
|
$pos1 = strpos($insert, '(') + 1;
|
|||
|
$pos2 = strpos($insert, ')') - $pos1;
|
|||
|
$fields = substr($insert, $pos1, $pos2);
|
|||
|
$insert = rtrim($insert, '; ') . " ON DUPLICATE KEY UPDATE ";
|
|||
|
foreach(explode(',', $fields) as $name) {
|
|||
|
$name = trim($name);
|
|||
|
$insert .= "$name=VALUES($name), ";
|
|||
|
}
|
|||
|
$insert = rtrim($insert, ", ") . ";";
|
|||
|
}
|
|||
|
if(!$this->executeQuery($insert, $options)) $numErrors++;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
return $numErrors === 0;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Drop all tables from database
|
|||
|
*
|
|||
|
* @return int Quantity of tables dropped
|
|||
|
* @throws \Exception
|
|||
|
* @since 3.0.130
|
|||
|
*
|
|||
|
*/
|
|||
|
public function dropAllTables() {
|
|||
|
|
|||
|
$database = $this->getDatabase();
|
|||
|
$tables = $this->getAllTables(false, false);
|
|||
|
$qty = 0;
|
|||
|
|
|||
|
$database->exec("SET FOREIGN_KEY_CHECKS=0");
|
|||
|
|
|||
|
foreach($tables as $table) {
|
|||
|
if($database->exec("DROP TABLE IF EXISTS `$table`")) $qty++;
|
|||
|
}
|
|||
|
|
|||
|
$database->exec("SET FOREIGN_KEY_CHECKS=1");
|
|||
|
|
|||
|
return $qty;
|
|||
|
}
|
|||
|
|
|||
|
|
|||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
|||
|
|
|||
|
/**
|
|||
|
* Returns array of all create table statements, indexed by table name
|
|||
|
*
|
|||
|
* @param string $filename to extract all CREATE TABLE statements from
|
|||
|
* @param string $regex Regex (PCRE) to match for statement to be returned, must stuff table name into first match
|
|||
|
* @param bool $multi Whether there can be multiple matches per table
|
|||
|
* @return array of statements, indexed by table name. If $multi is true, it will be array of arrays.
|
|||
|
* @throws \Exception if unable to open specified file
|
|||
|
*
|
|||
|
*/
|
|||
|
protected function findStatements($filename, $regex, $multi = true) {
|
|||
|
$filename = $this->sanitizeFilename($filename);
|
|||
|
$fp = fopen($filename, 'r');
|
|||
|
if(!$fp) throw new \Exception("Unable to open: $filename");
|
|||
|
$statements = array();
|
|||
|
while(!feof($fp)) {
|
|||
|
$line = trim(fgets($fp));
|
|||
|
if(!preg_match($regex, $line, $matches)) continue;
|
|||
|
if(empty($matches[1])) continue;
|
|||
|
$table = $matches[1];
|
|||
|
while(substr($line, -1) != ';' && !feof($fp)) $line .= " " . rtrim(fgets($fp));
|
|||
|
if($multi) {
|
|||
|
if(!isset($statements[$table])) $statements[$table] = array();
|
|||
|
$statements[$table][] = $line;
|
|||
|
} else {
|
|||
|
$statements[$table] = $line;
|
|||
|
}
|
|||
|
}
|
|||
|
fclose($fp);
|
|||
|
return $statements;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Returns array of all create table statements, indexed by table name
|
|||
|
*
|
|||
|
* #pw-internal
|
|||
|
*
|
|||
|
* @param string $filename to extract all CREATE TABLE statements from
|
|||
|
* @param array $options
|
|||
|
* @return array of CREATE TABLE statements, associative: indexed by table name
|
|||
|
* @throws \Exception if unable to open specified file
|
|||
|
*
|
|||
|
*/
|
|||
|
public function findCreateTables($filename, array $options) {
|
|||
|
$regex = '/^CREATE\s+TABLE\s+`?([^`\s]+)/i';
|
|||
|
$statements = $this->findStatements($filename, $regex, false);
|
|||
|
if(!empty($options['findReplaceCreateTable'])) {
|
|||
|
foreach($options['findReplaceCreateTable'] as $find => $replace) {
|
|||
|
foreach($statements as $key => $line) {
|
|||
|
if(strpos($line, $find) === false) continue;
|
|||
|
$line = str_replace($find, $replace, $line);
|
|||
|
$statements[$key] = $line;
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
return $statements;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Returns array of all INSERT statements in given filename, indexed by table name
|
|||
|
*
|
|||
|
* #pw-internal
|
|||
|
*
|
|||
|
* @param string $filename to extract all CREATE TABLE statements from
|
|||
|
* @return array of arrays of INSERT statements. Base array is associative indexed by table name.
|
|||
|
* Inside arrays are numerically indexed by order of appearance.
|
|||
|
*
|
|||
|
*/
|
|||
|
public function findInserts($filename) {
|
|||
|
$regex = '/^INSERT\s+INTO\s+`?([^`\s]+)/i';
|
|||
|
return $this->findStatements($filename, $regex, true);
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Execute an SQL query, either a string or PDOStatement
|
|||
|
*
|
|||
|
* @param string|\PDOStatement $query
|
|||
|
* @param bool|array $options May be boolean (for haltOnError), or array containing the property (i.e. $options array)
|
|||
|
* - `haltOnError` (bool): Halt execution when error occurs? (default=false)
|
|||
|
* @return bool Query result
|
|||
|
* @throws \Exception if haltOnError, otherwise it populates $this->errors
|
|||
|
*
|
|||
|
*/
|
|||
|
protected function executeQuery($query, $options = array()) {
|
|||
|
$defaults = array(
|
|||
|
'haltOnError' => false
|
|||
|
);
|
|||
|
if(is_bool($options)) {
|
|||
|
$defaults['haltOnError'] = $options;
|
|||
|
$options = array();
|
|||
|
}
|
|||
|
$options = array_merge($defaults, $options);
|
|||
|
$result = false;
|
|||
|
try {
|
|||
|
if(is_string($query)) {
|
|||
|
$result = $this->getDatabase()->exec($query);
|
|||
|
} else if($query instanceof \PDOStatement) {
|
|||
|
$result = $query->execute();
|
|||
|
}
|
|||
|
} catch(\Exception $e) {
|
|||
|
if(empty($options['haltOnError'])) {
|
|||
|
$this->error($e->getMessage());
|
|||
|
} else {
|
|||
|
throw $e;
|
|||
|
}
|
|||
|
}
|
|||
|
return $result === false ? false : true;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* For path: Normalizes slashes and ensures it ends with a slash
|
|||
|
*
|
|||
|
* @param $path
|
|||
|
* @return string
|
|||
|
*
|
|||
|
*/
|
|||
|
protected function sanitizePath($path) {
|
|||
|
if(DIRECTORY_SEPARATOR != '/') $path = str_replace(DIRECTORY_SEPARATOR, '/', $path);
|
|||
|
$path = rtrim($path, '/') . '/'; // ensure it ends with trailing slash
|
|||
|
return $path;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* For filename: Normalizes slashes and ensures it starts with a path
|
|||
|
*
|
|||
|
* @param $filename
|
|||
|
* @return string
|
|||
|
* @throws \Exception if path has not yet been set
|
|||
|
*
|
|||
|
*/
|
|||
|
protected function sanitizeFilename($filename) {
|
|||
|
if(DIRECTORY_SEPARATOR != '/') $filename = str_replace(DIRECTORY_SEPARATOR, '/', $filename);
|
|||
|
if(strpos($filename, '/') === false) {
|
|||
|
$filename = $this->path . $filename;
|
|||
|
}
|
|||
|
if(strpos($filename, '/') === false) {
|
|||
|
$path = $this->getPath();
|
|||
|
if(!strlen($path)) throw new \Exception("Please supply full path to file, or call setPath('/backup/files/path/') first");
|
|||
|
$filename = $path . $filename;
|
|||
|
}
|
|||
|
return $filename;
|
|||
|
}
|
|||
|
|
|||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
|||
|
|
|||
|
/**
|
|||
|
* Determine if exec is available for the given command
|
|||
|
*
|
|||
|
* Note that WireDatabaseBackup does not currently use exec() mode so this is here for future use.
|
|||
|
*
|
|||
|
* @param array $options
|
|||
|
* @return bool
|
|||
|
* @throws \Exception on unknown exec type
|
|||
|
*
|
|||
|
*/
|
|||
|
protected function supportsExec(array $options = array()) {
|
|||
|
|
|||
|
if(!$options['exec']) return false;
|
|||
|
|
|||
|
if(empty($this->databaseConfig['dbUser'])) return false; // no db config options provided
|
|||
|
|
|||
|
if(preg_match('{^(?:\[dbPath\])?([_a-zA-Z0-9]+)\s}', $options['execCommand'], $matches)) {
|
|||
|
$type = $matches[1];
|
|||
|
} else {
|
|||
|
throw new \Exception("Unable to determine command for exec");
|
|||
|
}
|
|||
|
|
|||
|
if($type == 'mysqldump') {
|
|||
|
// these options are not supported by mysqldump via exec
|
|||
|
if( !empty($options['excludeCreateTables']) ||
|
|||
|
!empty($options['excludeExportTables']) ||
|
|||
|
!empty($options['findReplace']) ||
|
|||
|
!empty($options['findReplaceCreateTable']) ||
|
|||
|
!empty($options['allowUpdateTables']) ||
|
|||
|
!empty($options['allowUpdate'])) {
|
|||
|
return false;
|
|||
|
}
|
|||
|
|
|||
|
} else if($type == 'mysql') {
|
|||
|
// these options are not supported by mysql via exec
|
|||
|
if( !empty($options['tables']) ||
|
|||
|
!empty($options['allowDrop']) ||
|
|||
|
!empty($options['findReplace']) ||
|
|||
|
!empty($options['findReplaceCreateTable'])) {
|
|||
|
return false;
|
|||
|
}
|
|||
|
|
|||
|
} else {
|
|||
|
throw new \Exception("Unrecognized exec command: $type");
|
|||
|
}
|
|||
|
|
|||
|
// first check if exec is available (http://stackoverflow.com/questions/3938120/check-if-exec-is-disabled)
|
|||
|
if(ini_get('safe_mode')) return false;
|
|||
|
$d = ini_get('disable_functions');
|
|||
|
$s = ini_get('suhosin.executor.func.blacklist');
|
|||
|
if("$d$s") {
|
|||
|
$a = preg_split('/,\s*/', "$d,$s");
|
|||
|
if(in_array('exec', $a)) return false;
|
|||
|
}
|
|||
|
|
|||
|
// now check if mysqldump is available
|
|||
|
$o = array();
|
|||
|
$r = 0;
|
|||
|
$path = $this->databaseConfig['dbPath'];
|
|||
|
exec("{$path}$type --version", $o, $r);
|
|||
|
if(!$r && count($o) && stripos($o[0], $type) !== false && stripos($o[0], 'Ver') !== false) {
|
|||
|
// i.e. mysqldump Ver 10.13 Distrib 5.5.34, for osx10.6 (i386)
|
|||
|
return true;
|
|||
|
}
|
|||
|
|
|||
|
return false;
|
|||
|
}
|
|||
|
|
|||
|
}
|