adm_program/system/classes/ComponentUpdate.php
<?php
use Admidio\Exception;
/**
* @brief Manage the update of a component from the actual version to the target version
*
* The class is an extension to the component class and will handle the update of a
* component. It will read the database version from the component and set this as
* source version. Then you should set the target version. The class will then search
* for specific update xml files in special directories. For the system this should be
* **adm_program/installation/db_scripts** and for plugins there should be an installed folder within the
* plugin directory. The xml files should have the prefix update and then the main und subversion
* within their filename e.g. **update_3_0.xml**.
*
* **Code example**
* ```
* // update the system module to the actual filesystem version
* $componentUpdateHandle = new ComponentUpdate($gDb);
* $componentUpdateHandle->readDataByColumns(array('com_type' => 'SYSTEM', 'com_name_intern' => 'CORE'));
* $componentUpdateHandle->update(ADMIDIO_VERSION);
* ```
* @copyright The Admidio Team
* @see https://www.admidio.org/
* @license https://www.gnu.org/licenses/gpl-2.0.html GNU General Public License v2.0 only
*/
class ComponentUpdate extends Component
{
public const UPDATE_STEP_STOP = 'stop';
/**
* Constructor that will create an object for component updating.
* @param Database $database Object of the class Database. This should be the default global object **$gDb**.
* @throws Exception
*/
public function __construct(Database $database)
{
parent::__construct($database);
ComponentUpdateSteps::setDatabase($database);
}
/**
* Gets the version parts of a version string
* @param string $versionString A version string
* @return array<int,int> Returns an array with the version parts
*/
private static function getVersionArrayFromVersion(string $versionString): array
{
return array_map('intval', explode('.', $versionString));
}
/**
* Will open an XML file of a specific version that contains all the update steps that
* must be passed to successfully update Admidio to this version
* @param int $mainVersion Contains a string with the main version number e.g. 2 or 3 from 2.x or 3.x.
* @param int $minorVersion Contains a string with the main version number e.g. 1 or 2 from x.1 or x.2.
* @return SimpleXMLElement
* @throws UnexpectedValueException|Exception
*/
private function getXmlObject(int $mainVersion, int $minorVersion): SimpleXMLElement
{
global $gLogger;
// update of Admidio core has another path for the xml files as plugins
if ($this->getValue('com_type') === 'SYSTEM') {
$updateFile = ADMIDIO_PATH . FOLDER_INSTALLATION . '/db_scripts/update_'.$mainVersion.'_'.$minorVersion.'.xml';
if (is_file($updateFile)) {
try {
return new SimpleXMLElement($updateFile, 0, true);
} catch (\Exception $e) {
throw new Exception($e->getMessage());
}
}
$message = 'XML-Update file not found!';
$gLogger->warning($message, array('filePath' => $updateFile));
throw new UnexpectedValueException($message);
}
throw new UnexpectedValueException('No System update!');
}
/**
* Goes step by step through the update xml file of the current database version and search for the maximum step.
* If the last step is found than the id of this step will be returned.
* @return int Return the number of the last update step that was found in xml file of the current version.
* @throws Exception
*/
public function getMaxUpdateStep(): int
{
$maxUpdateStep = 0;
$currentVersionArray = self::getVersionArrayFromVersion($this->getValue('com_version'));
if ($currentVersionArray[0] > 1) {
try {
// open xml file for this version
$xmlObject = $this->getXmlObject($currentVersionArray[0], $currentVersionArray[1]);
} catch (UnexpectedValueException $exception) {
return 0;
} catch (Exception $exception) {
throw new Exception($exception->getMessage());
}
// go step by step through the SQL statements until the last one is found
foreach ($xmlObject->children() as $updateStep) {
if ((string) $updateStep === self::UPDATE_STEP_STOP) {
break;
}
$maxUpdateStep = (int) $updateStep['id'];
}
}
return $maxUpdateStep;
}
/**
* Get method name and execute this method
* @param string $updateStepContent
*/
private static function executeUpdateMethod(string $updateStepContent)
{
// get the method name (remove "ComponentUpdateSteps::")
$methodName = substr($updateStepContent, 22);
// now call the method
ComponentUpdateSteps::{$methodName}();
}
/**
* Prepares and execute a sql statement.
* @param string $sql The sql statement that should be executed.
* @param bool $showError If set to **true** the error will be shown and the script will be terminated
* within the Database class.
* @return bool Return **true** if the sql statement could be successfully executed otherwise **false**
* @throws Exception
*/
private function executeUpdateSql(string $sql, bool $showError): bool
{
return !($this->db->queryPrepared(Database::prepareSqlAdmidioParameters($sql), array(), $showError) === false);
}
/**
* Will execute the specific update step that is set through the parameter $xmlNode.
* If the step was successfully done the id will be stored in the component recordset
* so if the whole update crashs later we know that this step was successfully executed.
* When the node has an attribute **database** than this sql statement will only be executed
* if the value of the attribute is equal to your current **DB_ENGINE**. If the node has
* an attribute **error** and this is set to **ignore** than a sql error will not stop
* the update script.
* @param SimpleXMLElement $xmlNode A SimpleXML node of the current update step.
* @param string $version A version string of the version corresponding to the $xmlNode
* @throws Exception
*/
private function executeStep(SimpleXMLElement $xmlNode, string $version = '')
{
global $gLogger;
$updateStepContent = trim((string) $xmlNode);
$startTime = microtime(true);
// only execute if sql statement is for all databases or for the used database
if (!isset($xmlNode['database']) || (string) $xmlNode['database'] === DB_ENGINE) {
$errorMessage = '<p>An error occured within the update script. Please visit our
support forum <a href="https://www.admidio.org/forum">https://www.admidio.org/forum</a> and
provide the following information.</p>
<p><b>VERSION:</b> ' . $version . '<br><b>STEP:</b> ' . (int) $xmlNode['id'] . '</p>';
$gLogger->info('UPDATE: Execute update step Nr: ' . (int) $xmlNode['id']);
// if a method of this class was set in the update step
// then call this function and don't execute a SQL statement
if (str_starts_with($updateStepContent, 'ComponentUpdateSteps::')) {
try {
self::executeUpdateMethod($updateStepContent);
} catch (Throwable $e) {
echo '
<div style="font-family: monospace;">
<p><strong>S C R I P T - E R R O R</strong></p>
' . $errorMessage . '
<p><strong>MESSAGE:</strong> ' . $e->getMessage() . '</p>
<p><strong>B A C K T R A C E</strong></p>
<p>' . str_replace('#', '<br />', $e->getTraceAsString()) . '</p>
</div>';
exit();
}
} else {
$showError = true;
// if the attribute error was set to "ignore" then don't show errors that occurs on sql execution
if (isset($xmlNode['error']) && (string) $xmlNode['error'] === 'ignore') {
$showError = false;
}
$returnCodeSql = $this->executeUpdateSql($updateStepContent, false);
if($showError && !$returnCodeSql) {
$this->db->showError($errorMessage);
// => EXIT
}
}
$gLogger->debug('UPDATE: Execution time ' . getExecutionTime($startTime));
} else {
$gLogger->info(
'UPDATE: Update step is for another database!',
array('database' => (string) $xmlNode['database'], 'step' => (int) $xmlNode['id'])
);
}
// save the successful executed update step in database
$this->setValue('com_update_step', (int) $xmlNode['id']);
$this->save();
}
/**
* Do a loop through all versions start with the last installed version and end with the current version of the
* file system (**$targetVersion**). Within every subversion the method will search for an update xml file and
* execute all steps in this file until the end of file is reached. If an error occurred then the update will
* be stopped and the system will be marked with update not completed so that it's possible to continue the
* update later if the problem was fixed.
* @param string $targetVersion The target version to update.
* @throws Exception
*/
public function update(string $targetVersion)
{
global $gLogger;
$currentVersionArray = self::getVersionArrayFromVersion($this->getValue('com_version'));
$targetVersionArray = self::getVersionArrayFromVersion($targetVersion);
$initialMinorVersion = $currentVersionArray[1];
// if the update is from a version lower than 4.2.0 than the field com_update_complete doesn't exist
// otherwise set the status to incomplete update
if(version_compare($this->getValue('com_update_version'), '4.2.0', '>')) {
$this->setValue('com_update_completed', false);
$this->save();
}
for ($mainVersion = $currentVersionArray[0]; $mainVersion <= $targetVersionArray[0]; ++$mainVersion) {
// Set max subversion for iteration. If we are in the loop of the target main version
// then set target minor-version to the max version
$maxMinorVersion = 20;
if ($mainVersion === $targetVersionArray[0]) {
$maxMinorVersion = $targetVersionArray[1];
}
for ($minorVersion = $initialMinorVersion; $minorVersion <= $maxMinorVersion; ++$minorVersion) {
// if version is not equal to current version then start update step with 0
if ($mainVersion !== $currentVersionArray[0] || $minorVersion !== $currentVersionArray[1]) {
$this->setValue('com_update_step', 0);
$this->save();
}
// save current version to system component
$this->setValue('com_version', $mainVersion.'.'.$minorVersion.'.0');
$this->save();
// output of the version number for better debugging
$gLogger->notice('UPDATE: Start executing update steps to version '.$mainVersion.'.'.$minorVersion);
// open xml file for this version
try {
$xmlObject = $this->getXmlObject($mainVersion, $minorVersion);
// go step by step through the SQL statements and execute them
foreach ($xmlObject->children() as $updateStep) {
if ((string) $updateStep === self::UPDATE_STEP_STOP) {
break;
}
if ((int) $updateStep['id'] > (int) $this->getValue('com_update_step')) {
$this->executeStep($updateStep, $mainVersion.'.'.$minorVersion.'.0');
} else {
$gLogger->info('UPDATE: Skip update step Nr: ' . (int) $updateStep['id']);
}
}
} catch (UnexpectedValueException|Exception $exception) {
// TODO
}
$gLogger->notice('UPDATE: Finish executing update steps to version '.$mainVersion.'.'.$minorVersion);
}
// reset subversion because we want to start update for next main version with subversion 0
$initialMinorVersion = 0;
}
// save current version of file system to all modules
$sql = 'UPDATE '.TBL_COMPONENTS.'
SET com_version = ? -- ADMIDIO_VERSION
, com_beta = ? -- ADMIDIO_VERSION_BETA
, com_update_completed = true
WHERE com_type IN (\'SYSTEM\', \'MODULE\')';
$this->db->queryPrepared($sql, array(ADMIDIO_VERSION, ADMIDIO_VERSION_BETA));
}
}