Compare commits

...

10 Commits

18 changed files with 691 additions and 62 deletions

View File

@@ -916,7 +916,9 @@ class CMDBSource
$aColumn = []; $aColumn = [];
$aData = self::QueryToArray($sSql); $aData = self::QueryToArray($sSql);
foreach ($aData as $aRow) { foreach ($aData as $aRow) {
@$aColumn[] = $aRow[$col]; if ($aRow[$col] !== null) {
$aColumn[] = $aRow[$col];
}
} }
return $aColumn; return $aColumn;
} }

View File

@@ -18,6 +18,7 @@ use Combodo\iTop\DataFeatureRemoval\Helper\DataFeatureRemovalHelper;
use Combodo\iTop\DataFeatureRemoval\Helper\DataFeatureRemovalLog; use Combodo\iTop\DataFeatureRemoval\Helper\DataFeatureRemovalLog;
use Combodo\iTop\DataFeatureRemoval\Service\DataCleanupService; use Combodo\iTop\DataFeatureRemoval\Service\DataCleanupService;
use Combodo\iTop\DataFeatureRemoval\Service\DataFeatureRemoverExtensionService; use Combodo\iTop\DataFeatureRemoval\Service\DataFeatureRemoverExtensionService;
use Combodo\iTop\DataFeatureRemoval\Service\StaticDeletionPlan;
use Combodo\iTop\Setup\FeatureRemoval\DryRemovalRuntimeEnvironment; use Combodo\iTop\Setup\FeatureRemoval\DryRemovalRuntimeEnvironment;
use Combodo\iTop\Setup\FeatureRemoval\SetupAudit; use Combodo\iTop\Setup\FeatureRemoval\SetupAudit;
use ContextTag; use ContextTag;
@@ -168,6 +169,7 @@ class DataFeatureRemovalController extends Controller
$oSetupAudit = new SetupAudit($sSourceEnv); $oSetupAudit = new SetupAudit($sSourceEnv);
$aGetRemovedClasses = array_keys($oSetupAudit->RunDataAudit()); $aGetRemovedClasses = array_keys($oSetupAudit->RunDataAudit());
DataFeatureRemovalLog::Debug(__METHOD__, null, ['aGetRemovedClasses' => $aGetRemovedClasses]); DataFeatureRemovalLog::Debug(__METHOD__, null, ['aGetRemovedClasses' => $aGetRemovedClasses]);
$aDeletionPlan = (new StaticDeletionPlan())->GetStaticDeletionPlan($aGetRemovedClasses);
$aParams['aClasses'] = $aGetRemovedClasses; $aParams['aClasses'] = $aGetRemovedClasses;
@@ -222,6 +224,7 @@ class DataFeatureRemovalController extends Controller
$bIsDirEmpty = count(scandir($sBuildDir)) === 2; $bIsDirEmpty = count(scandir($sBuildDir)) === 2;
if ($bIsDirEmpty || $bForceCompilation) { if ($bIsDirEmpty || $bForceCompilation) {
Session::Unset('bForceCompilation');
DataFeatureRemovalLog::Debug( DataFeatureRemovalLog::Debug(
__METHOD__, __METHOD__,
null, null,
@@ -272,7 +275,7 @@ class DataFeatureRemovalController extends Controller
private function GetDeletionPlanSummaryTable(array $aRemovedClasses): array private function GetDeletionPlanSummaryTable(array $aRemovedClasses): array
{ {
$sName = 'DeletionPlanSummary'; $sName = 'DeletionPlanSummary';
$oDataCleanupService = new DataCleanupService(); $oDataCleanupService = new StaticDeletionPlan();
$aDeletionPlanSummaryEntities = $oDataCleanupService->GetCleanupSummary($aRemovedClasses); $aDeletionPlanSummaryEntities = $oDataCleanupService->GetCleanupSummary($aRemovedClasses);
$aColumns = ['Class', 'Delete Count' , 'Update Count', 'Issue Count']; $aColumns = ['Class', 'Delete Count' , 'Update Count', 'Issue Count'];
$aRows = []; $aRows = [];

View File

@@ -0,0 +1,37 @@
<?php
/*
* @copyright Copyright (C) 2010-2026 Combodo SAS
* @license http://opensource.org/licenses/AGPL-3.0
*/
namespace Combodo\iTop\DataFeatureRemoval\Entity;
class DeletionPlanEntity
{
public readonly DeletionPlanItem $oDelete;
public readonly DeletionPlanItem $oUpdate;
public readonly DeletionPlanItem $oIssue;
/**
* @param \Combodo\iTop\DataFeatureRemoval\Entity\DeletionPlanItem $oDelete
* @param \Combodo\iTop\DataFeatureRemoval\Entity\DeletionPlanItem $oUpdate
* @param \Combodo\iTop\DataFeatureRemoval\Entity\DeletionPlanItem $oIssue
*/
public function __construct()
{
$this->oDelete = $oDelete ?? new DeletionPlanItem();
$this->oUpdate = $oUpdate ?? new DeletionPlanItem();
$this->oIssue = $oIssue ?? new DeletionPlanItem();
}
public function TotalCount(): int
{
return $this->oDelete->Count() + $this->oUpdate->Count() + $this->oIssue->Count();
}
public function FilterUpdatesByDeletes()
{
$this->oUpdate->FilterBy($this->oDelete);
}
}

View File

@@ -0,0 +1,40 @@
<?php
/*
* @copyright Copyright (C) 2010-2026 Combodo SAS
* @license http://opensource.org/licenses/AGPL-3.0
*/
namespace Combodo\iTop\DataFeatureRemoval\Entity;
class DeletionPlanItem
{
public array $aQueries = [];
public array $aIds = [];
/**
* @param array $aQueries
* @param array $aIds
*/
public function __construct(array $aQueries = [], array $aIds = [])
{
$this->aQueries = $aQueries;
$this->aIds = $aIds;
}
public function Merge(DeletionPlanItem $oItem): void
{
$this->aQueries = array_merge($this->aQueries, $oItem->aQueries);
$this->aIds = array_unique(array_merge($this->aIds, $oItem->aIds));
}
public function Count(): int
{
return count($this->aIds);
}
public function FilterBy(DeletionPlanItem $oItem): void
{
$this->aIds = array_diff($this->aIds, $oItem->aIds);
}
}

View File

@@ -0,0 +1,242 @@
<?php
/*
* @copyright Copyright (C) 2010-2026 Combodo SAS
* @license http://opensource.org/licenses/AGPL-3.0
*/
namespace Combodo\iTop\DataFeatureRemoval\Service;
use CMDBSource;
use Combodo\iTop\DataFeatureRemoval\Entity\DataCleanupSummaryEntity;
use Combodo\iTop\DataFeatureRemoval\Entity\DeletionPlanEntity;
use Combodo\iTop\DataFeatureRemoval\Entity\DeletionPlanItem;
use MetaModel;
class StaticDeletionPlan
{
/** @var array<DeletionPlanEntity> */
private array $aDeletionPlan = [];
/**
* Get a summary of the deletion plan computed for the classes.
* The result is used for display
*
* @param array|null $aClasses
*
* @return array<\Combodo\iTop\DataFeatureRemoval\Entity\DataCleanupSummaryEntity>
* @throws \CoreException
*/
public function GetCleanupSummary(?array $aClasses): array
{
$aSummary = [];
$aDeletionPlan = $this->GetStaticDeletionPlan($aClasses ?? []);
foreach ($aDeletionPlan as $sClass => $oDeletionPlanEntity) {
if ($oDeletionPlanEntity->TotalCount() === 0) {
continue;
}
$oDeletionPlanEntity->FilterUpdatesByDeletes();
$oDataCleanupSummary = new DataCleanupSummaryEntity($sClass);
$oDataCleanupSummary->iUpdateCount = $oDeletionPlanEntity->oUpdate->Count();
$oDataCleanupSummary->iDeleteCount = $oDeletionPlanEntity->oDelete->Count();
$oDataCleanupSummary->iIssueCount = $oDeletionPlanEntity->oIssue->Count();
$aSummary[$sClass] = $oDataCleanupSummary;
}
return $aSummary;
}
/**
* @param array $aClasses Classes to clean entirely
*
* @return array ['class' => DeletionPlanEntity];
*
* @throws \CoreException
*/
public function GetStaticDeletionPlan(array $aClasses): array
{
foreach ($aClasses as $sClass) {
$oDeletionPlanItem = $this->GetInitialClassDeletionPlan($sClass);
$oDeletionPlanEntity = new DeletionPlanEntity();
$oDeletionPlanEntity->oDelete->Merge($oDeletionPlanItem);
$this->aDeletionPlan[$sClass] = $oDeletionPlanEntity;
$this->DeletionPlanForReferencingClasses($sClass);
}
return $this->aDeletionPlan;
}
private function DeletionPlanForReferencingClasses(string $sClass): void
{
$sIdsToRemove = implode(', ', $this->aDeletionPlan[$sClass]->oDelete->aIds);
$aReferencingMe = MetaModel::EnumReferencingClasses($sClass);
foreach ($aReferencingMe as $sRemoteClass => $aExtKeys) {
if (!isset($this->aDeletionPlan[$sRemoteClass])) {
$this->aDeletionPlan[$sRemoteClass] = new DeletionPlanEntity();
}
$oDeletionPlanEntity = $this->aDeletionPlan[$sRemoteClass];
/** @var \AttributeExternalKey $oExtKeyAttDef */
foreach ($aExtKeys as $sExtKeyAttCode => $oExtKeyAttDef) {
// skip if this external key is behind an external field
if (!$oExtKeyAttDef->IsExternalKey(EXTKEY_ABSOLUTE)) {
continue;
}
if ($oExtKeyAttDef->IsNullAllowed()) {
// update
$oUpdateItem = $this->UpdateExtKeyNullable($sRemoteClass, $sExtKeyAttCode, $sIdsToRemove);
$oDeletionPlanEntity->oUpdate->Merge($oUpdateItem);
} else {
// delete
$aRemoteIdsToRemove = $this->GetRemoteIdsForExtKey($sRemoteClass, $sExtKeyAttCode, $sIdsToRemove);
$iDeletePropagationOption = $oExtKeyAttDef->GetDeletionPropagationOption();
if ($iDeletePropagationOption == DEL_MANUAL) {
// Issue, do not recurse
$oDeletionPlanItem = new DeletionPlanItem(aIds: $aRemoteIdsToRemove);
$oDeletionPlanEntity->oIssue->Merge($oDeletionPlanItem);
continue;
}
if (($iDeletePropagationOption == DEL_MOVEUP) && ($oExtKeyAttDef->IsHierarchicalKey())) {
// update hierarchical keys due to row cleanup in the same table
$sIdsToRemove = implode(',', $this->aDeletionPlan[$sRemoteClass]->oDelete->aIds);
$oUpdateItem = $this->UpdateHierarchicalExtKey($sRemoteClass, $sExtKeyAttCode, $sIdsToRemove);
$oDeletionPlanEntity->oUpdate->Merge($oUpdateItem);
// do not recurse
continue;
}
// Delete entries in Remote Class
if (count($aRemoteIdsToRemove) !== 0) {
// TODO see ObjectService::Delete() !!!!!!
$sRemoteIdsToDelete = implode(',', $aRemoteIdsToRemove);
$sRemoteTable = MetaModel::DBGetTable($sRemoteClass);
$sDBKey = MetaModel::DBGetKey($sRemoteClass);
$sSQL = "DELETE FROM `$sRemoteTable` WHERE `$sDBKey` IN ($sRemoteIdsToDelete)";
$oDeletionPlanEntity->oDelete->Merge(new DeletionPlanItem([$sSQL], $aRemoteIdsToRemove));
$this->DeletionPlanForReferencingClasses($sRemoteClass);
}
}
}
}
}
/**
* @param string $sRemoteClass
* @param string $sExtKeyAttCode
* @param string $sIdsToRemoveInTargetClass
*
* @return \Combodo\iTop\DataFeatureRemoval\Entity\DeletionPlanItem
* @throws \CoreException
*/
public function UpdateExtKeyNullable(string $sRemoteClass, string $sExtKeyAttCode, string $sIdsToRemoveInTargetClass): DeletionPlanItem
{
$aIds = $this->GetRemoteIdsForExtKey($sRemoteClass, $sExtKeyAttCode, $sIdsToRemoveInTargetClass);
[$sDBTable, $sDBField] = $this->GetDBInfoForAttcode($sRemoteClass, $sExtKeyAttCode);
$sUpdateSQL = <<<SQL
UPDATE `$sDBTable` SET `updated`.`$sDBField` = 0
FROM `$sDBTable` AS `updated`
WHERE `updated`.`$sDBField` IN ($sIdsToRemoveInTargetClass)
SQL;
return new DeletionPlanItem([$sExtKeyAttCode => $sUpdateSQL], $aIds);
}
/**
* @param string $sRemoteClass
* @param string $sExtKeyAttCode
* @param string $sIdsToRemoveInTargetClass
*
* @return \Combodo\iTop\DataFeatureRemoval\Entity\DeletionPlanItem
* @throws \CoreException
* @throws \MySQLException
*/
public function UpdateHierarchicalExtKey(string $sRemoteClass, string $sExtKeyAttCode, string $sIdsToRemoveInTargetClass): DeletionPlanItem
{
[$sDBTable, $sDBField, $sDBKey] = $this->GetDBInfoForAttcode($sRemoteClass, $sExtKeyAttCode);
$sUpdateSQL = <<<SQL
UPDATE `$sDBTable` SET `updated`.`$sDBField` = `removed`.`$sDBField`
FROM `$sDBTable` AS `updated`
INNER JOIN `$sDBTable` AS `removed` ON `updated`.`$sDBField` = `removed`.`$sDBKey`
WHERE `removed`.`$sDBKey` IN ($sIdsToRemoveInTargetClass)
SQL;
$sSQL = <<<SQL
SELECT `$sDBKey`
FROM `$sDBTable` AS `updated`
INNER JOIN `$sDBTable` AS `removed` ON `updated`.`$sDBField` = `removed`.`$sDBKey`
WHERE `removed`.`$sDBKey` IN ($sIdsToRemoveInTargetClass)
SQL;
$aIds = CMDBSource::QueryToCol($sSQL, $sDBKey);
return new DeletionPlanItem([$sExtKeyAttCode => $sUpdateSQL], $aIds);
}
/**
* @param string $sRemoteClass
* @param string $sExtKeyAttCode
* @param string $sIdsToRemoveInTargetClass
*
* @return array
* @throws \CoreException
* @throws \MySQLException
*/
public function GetRemoteIdsForExtKey(string $sRemoteClass, string $sExtKeyAttCode, string $sIdsToRemoveInTargetClass): array
{
if (\utils::IsNullOrEmptyString($sIdsToRemoveInTargetClass)) {
return [];
}
[$sDBTable, $sDBField, $sDBKey] = $this->GetDBInfoForAttcode($sRemoteClass, $sExtKeyAttCode);
$sSQL = "SELECT `$sDBKey` FROM `$sDBTable` WHERE `$sDBField` IN ($sIdsToRemoveInTargetClass)";
return CMDBSource::QueryToCol($sSQL, $sDBKey);
}
/**
* @param string $sClass
*
* @return \Combodo\iTop\DataFeatureRemoval\Entity\DeletionPlanItem
* @throws \CoreException
* @throws \MySQLException
*/
public function GetInitialClassDeletionPlan(string $sClass): DeletionPlanItem
{
$sTable = MetaModel::DBGetTable($sClass);
$sDBKey = MetaModel::DBGetKey($sClass);
$sSQL = "SELECT `$sDBKey` FROM `$sTable`";
$aIds = CMDBSource::QueryToCol($sSQL, $sDBKey);
// TODO see ObjectService::Delete() !!!!!!
$sDeleteSQL = "DELETE FROM `$sTable`";
return new DeletionPlanItem([$sDeleteSQL], $aIds);
}
/**
* Get database table for an attcode
*
* @param string $sClass
* @param string $sExtKeyAttCode
*
* @return array
* @throws \CoreException
* @throws \Exception
*/
public function GetDBInfoForAttcode(string $sClass, string $sExtKeyAttCode): array
{
$sOriginClass = MetaModel::GetAttributeOrigin($sClass, $sExtKeyAttCode);
$sDBTable = MetaModel::DBGetTable($sOriginClass);
$oAttDef = MetaModel::GetAttributeDef($sOriginClass, $sExtKeyAttCode);
// External key is on a single DB column
$sDBField = array_keys($oAttDef->GetSQLColumns())[0];
$sDBKey = MetaModel::DBGetKey($sClass);
return [$sDBTable, $sDBField, $sDBKey];
}
}

View File

@@ -8,6 +8,8 @@ $baseDir = dirname($vendorDir);
return array( return array(
'Combodo\\iTop\\DataFeatureRemoval\\Controller\\DataFeatureRemovalController' => $baseDir . '/src/Controller/DataFeatureRemovalController.php', 'Combodo\\iTop\\DataFeatureRemoval\\Controller\\DataFeatureRemovalController' => $baseDir . '/src/Controller/DataFeatureRemovalController.php',
'Combodo\\iTop\\DataFeatureRemoval\\Entity\\DataCleanupSummaryEntity' => $baseDir . '/src/Entity/DataCleanupSummaryEntity.php', 'Combodo\\iTop\\DataFeatureRemoval\\Entity\\DataCleanupSummaryEntity' => $baseDir . '/src/Entity/DataCleanupSummaryEntity.php',
'Combodo\\iTop\\DataFeatureRemoval\\Entity\\DeletionPlanEntity' => $baseDir . '/src/Entity/DeletionPlanEntity.php',
'Combodo\\iTop\\DataFeatureRemoval\\Entity\\DeletionPlanItem' => $baseDir . '/src/Entity/DeletionPlanItem.php',
'Combodo\\iTop\\DataFeatureRemoval\\Helper\\DataFeatureRemovalConfig' => $baseDir . '/src/Helper/DataFeatureRemovalConfig.php', 'Combodo\\iTop\\DataFeatureRemoval\\Helper\\DataFeatureRemovalConfig' => $baseDir . '/src/Helper/DataFeatureRemovalConfig.php',
'Combodo\\iTop\\DataFeatureRemoval\\Helper\\DataFeatureRemovalException' => $baseDir . '/src/Helper/DataFeatureRemovalException.php', 'Combodo\\iTop\\DataFeatureRemoval\\Helper\\DataFeatureRemovalException' => $baseDir . '/src/Helper/DataFeatureRemovalException.php',
'Combodo\\iTop\\DataFeatureRemoval\\Helper\\DataFeatureRemovalHelper' => $baseDir . '/src/Helper/DataFeatureRemovalHelper.php', 'Combodo\\iTop\\DataFeatureRemoval\\Helper\\DataFeatureRemovalHelper' => $baseDir . '/src/Helper/DataFeatureRemovalHelper.php',
@@ -16,6 +18,7 @@ return array(
'Combodo\\iTop\\DataFeatureRemoval\\Service\\DataFeatureRemoverExtensionService' => $baseDir . '/src/Service/DataFeatureRemoverExtensionService.php', 'Combodo\\iTop\\DataFeatureRemoval\\Service\\DataFeatureRemoverExtensionService' => $baseDir . '/src/Service/DataFeatureRemoverExtensionService.php',
'Combodo\\iTop\\DataFeatureRemoval\\Service\\ObjectService' => $baseDir . '/src/Service/ObjectService.php', 'Combodo\\iTop\\DataFeatureRemoval\\Service\\ObjectService' => $baseDir . '/src/Service/ObjectService.php',
'Combodo\\iTop\\DataFeatureRemoval\\Service\\ObjectServiceSummary' => $baseDir . '/src/Service/ObjectServiceSummary.php', 'Combodo\\iTop\\DataFeatureRemoval\\Service\\ObjectServiceSummary' => $baseDir . '/src/Service/ObjectServiceSummary.php',
'Combodo\\iTop\\DataFeatureRemoval\\Service\\StaticDeletionPlan' => $baseDir . '/src/Service/StaticDeletionPlan.php',
'Combodo\\iTop\\DataFeatureRemoval\\Service\\iObjectService' => $baseDir . '/src/Service/iObjectService.php', 'Combodo\\iTop\\DataFeatureRemoval\\Service\\iObjectService' => $baseDir . '/src/Service/iObjectService.php',
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php', 'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
); );

View File

@@ -23,6 +23,8 @@ class ComposerStaticInit4f96a7199e2c0d90e547333758b26464
public static $classMap = array ( public static $classMap = array (
'Combodo\\iTop\\DataFeatureRemoval\\Controller\\DataFeatureRemovalController' => __DIR__ . '/../..' . '/src/Controller/DataFeatureRemovalController.php', 'Combodo\\iTop\\DataFeatureRemoval\\Controller\\DataFeatureRemovalController' => __DIR__ . '/../..' . '/src/Controller/DataFeatureRemovalController.php',
'Combodo\\iTop\\DataFeatureRemoval\\Entity\\DataCleanupSummaryEntity' => __DIR__ . '/../..' . '/src/Entity/DataCleanupSummaryEntity.php', 'Combodo\\iTop\\DataFeatureRemoval\\Entity\\DataCleanupSummaryEntity' => __DIR__ . '/../..' . '/src/Entity/DataCleanupSummaryEntity.php',
'Combodo\\iTop\\DataFeatureRemoval\\Entity\\DeletionPlanEntity' => __DIR__ . '/../..' . '/src/Entity/DeletionPlanEntity.php',
'Combodo\\iTop\\DataFeatureRemoval\\Entity\\DeletionPlanItem' => __DIR__ . '/../..' . '/src/Entity/DeletionPlanItem.php',
'Combodo\\iTop\\DataFeatureRemoval\\Helper\\DataFeatureRemovalConfig' => __DIR__ . '/../..' . '/src/Helper/DataFeatureRemovalConfig.php', 'Combodo\\iTop\\DataFeatureRemoval\\Helper\\DataFeatureRemovalConfig' => __DIR__ . '/../..' . '/src/Helper/DataFeatureRemovalConfig.php',
'Combodo\\iTop\\DataFeatureRemoval\\Helper\\DataFeatureRemovalException' => __DIR__ . '/../..' . '/src/Helper/DataFeatureRemovalException.php', 'Combodo\\iTop\\DataFeatureRemoval\\Helper\\DataFeatureRemovalException' => __DIR__ . '/../..' . '/src/Helper/DataFeatureRemovalException.php',
'Combodo\\iTop\\DataFeatureRemoval\\Helper\\DataFeatureRemovalHelper' => __DIR__ . '/../..' . '/src/Helper/DataFeatureRemovalHelper.php', 'Combodo\\iTop\\DataFeatureRemoval\\Helper\\DataFeatureRemovalHelper' => __DIR__ . '/../..' . '/src/Helper/DataFeatureRemovalHelper.php',
@@ -31,6 +33,7 @@ class ComposerStaticInit4f96a7199e2c0d90e547333758b26464
'Combodo\\iTop\\DataFeatureRemoval\\Service\\DataFeatureRemoverExtensionService' => __DIR__ . '/../..' . '/src/Service/DataFeatureRemoverExtensionService.php', 'Combodo\\iTop\\DataFeatureRemoval\\Service\\DataFeatureRemoverExtensionService' => __DIR__ . '/../..' . '/src/Service/DataFeatureRemoverExtensionService.php',
'Combodo\\iTop\\DataFeatureRemoval\\Service\\ObjectService' => __DIR__ . '/../..' . '/src/Service/ObjectService.php', 'Combodo\\iTop\\DataFeatureRemoval\\Service\\ObjectService' => __DIR__ . '/../..' . '/src/Service/ObjectService.php',
'Combodo\\iTop\\DataFeatureRemoval\\Service\\ObjectServiceSummary' => __DIR__ . '/../..' . '/src/Service/ObjectServiceSummary.php', 'Combodo\\iTop\\DataFeatureRemoval\\Service\\ObjectServiceSummary' => __DIR__ . '/../..' . '/src/Service/ObjectServiceSummary.php',
'Combodo\\iTop\\DataFeatureRemoval\\Service\\StaticDeletionPlan' => __DIR__ . '/../..' . '/src/Service/StaticDeletionPlan.php',
'Combodo\\iTop\\DataFeatureRemoval\\Service\\iObjectService' => __DIR__ . '/../..' . '/src/Service/iObjectService.php', 'Combodo\\iTop\\DataFeatureRemoval\\Service\\iObjectService' => __DIR__ . '/../..' . '/src/Service/iObjectService.php',
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
); );

View File

@@ -242,8 +242,7 @@ class AjaxController extends Controller
throw new SecurityException('Access forbidden'); throw new SecurityException('Access forbidden');
} }
$sConfigFile = APPCONF.'production/config-itop.php'; SetupUtils::CreateSetupToken();
@chmod($sConfigFile, 0770); // Allow overwriting the file
header('Location: ../setup/'); header('Location: ../setup/');
} }

View File

@@ -166,8 +166,6 @@ class UpdateController extends Controller
public function OperationRunSetup() public function OperationRunSetup()
{ {
SetupUtils::CheckSetupToken(true); SetupUtils::CheckSetupToken(true);
$sConfigFile = APPCONF.'production/'.ITOP_CONFIG_FILE;
@chmod($sConfigFile, 0770);
$sRedirectURL = utils::GetAbsoluteUrlAppRoot().'setup/index.php'; $sRedirectURL = utils::GetAbsoluteUrlAppRoot().'setup/index.php';
header("Location: $sRedirectURL"); header("Location: $sRedirectURL");
} }

View File

@@ -74,7 +74,6 @@ if (SetupUtils::IsSessionSetupTokenValid()) {
// The configuration file already exists // The configuration file already exists
if (!is_writable($sConfigFile)) { if (!is_writable($sConfigFile)) {
SetupUtils::ExitReadOnlyMode(false); // Reset readonly mode in case of problem SetupUtils::ExitReadOnlyMode(false); // Reset readonly mode in case of problem
SetupUtils::EraseSetupToken();
$sRelativePath = utils::GetConfigFilePathRelative(ITOP_DEFAULT_ENV); $sRelativePath = utils::GetConfigFilePathRelative(ITOP_DEFAULT_ENV);
$oP = new SetupPage('Installation Cannot Continue'); $oP = new SetupPage('Installation Cannot Continue');
$oP->add("<h2>Fatal error</h2>\n"); $oP->add("<h2>Fatal error</h2>\n");
@@ -87,7 +86,6 @@ HTML;
$oP->p($sButtonsHtml); $oP->p($sButtonsHtml);
$oP->output(); $oP->output();
// Prevent token creation
exit; exit;
} else { } else {
chmod($sConfigFile, 0440); chmod($sConfigFile, 0440);

View File

@@ -196,7 +196,6 @@ class WizardController
SetupLog::Info("=== Setup screen: ".$oStep->GetTitle().' ('.get_class($oStep).')'); SetupLog::Info("=== Setup screen: ".$oStep->GetTitle().' ('.get_class($oStep).')');
$oPage = new SetupPage($oStep->GetTitle()); $oPage = new SetupPage($oStep->GetTitle());
$oPage->LinkScriptFromAppRoot('setup/setup.js'); $oPage->LinkScriptFromAppRoot('setup/setup.js');
$oStep->PreFormDisplay($oPage);
$oPage->add('<form id="wiz_form" class="ibo-setup--wizard" method="post">'); $oPage->add('<form id="wiz_form" class="ibo-setup--wizard" method="post">');
$oPage->add('<div class="ibo-setup--wizard--content">'); $oPage->add('<div class="ibo-setup--wizard--content">');

View File

@@ -124,18 +124,18 @@ HTML
if (file_exists($sBuildConfigFile)) { if (file_exists($sBuildConfigFile)) {
$oPage->add( $oPage->add(
<<<HTML <<<HTML
<form method="post"> <form id="fast_setup" method="post">
<input type="hidden" name="_class" value="WizStepLandingBeforeAudit"/> <input type="hidden" name="_class" value="WizStepLandingBeforeAudit"/>
<input type="hidden" name="operation" value="next"/> <input type="hidden" name="operation" value="next"/>
<input type="hidden" name="_params[skip_wizard]" value="1"/> <input type="hidden" name="_params[skip_wizard]" value="1"/>
<table style="width:100%;" class="ibo-setup--wizard--buttons-container">
<tr>
<td style="text-align: right"><button type="submit" class="ibo-button ibo-is-regular ibo-is-secondary">Keep current choices</button></td>
</tr>
</table>
</form> </form>
HTML HTML
); );
$oPage->add_ready_script(
<<<JS
$('.ibo-setup--wizard--buttons-container tr td:nth-child(1)').before('<td style="text-align:center;"><button class="ibo-button ibo-is-alternative ibo-is-neutral" form="fast_setup"><span class="ibo-button--label">Keep current choices</span></button></td>');
JS
);
} }
} }
} }

View File

@@ -76,10 +76,6 @@ abstract class WizardStep
{ {
} }
public function PreFormDisplay(SetupPage $oPage)
{
}
protected function CheckDependencies() protected function CheckDependencies()
{ {
if (is_null($this->bDependencyCheck)) { if (is_null($this->bDependencyCheck)) {

View File

@@ -0,0 +1,64 @@
<?php
/*
* @copyright Copyright (C) 2010-2026 Combodo SAS
* @license http://opensource.org/licenses/AGPL-3.0
*/
use Combodo\iTop\Test\UnitTest\ItopCustomDatamodelTestCase;
class AbstractCleanup extends ItopCustomDatamodelTestCase
{
public function GetDatamodelDeltaAbsPath(): string
{
return __DIR__.'/data_cleanup_delta.xml';
}
protected array $aIdByClass;
protected array $aIdByObjectName = [];
protected function GivenDFRTreeInDB(string $sTree)
{
$this->aIdByClass = [];
$aTree = explode("\n", $sTree);
foreach ($aTree as $sLine) {
if (trim($sLine) === '') {
continue;
}
$this->GivenDFRTreeLineInDB($sLine);
}
}
protected function GivenDFRTreeLineInDB(string $sLine)
{
[$sLeft, $sRight] = explode('<-', $sLine);
$sLeft = trim($sLeft);
$iLeftId = $this->aIdByObjectName[$sLeft] ?? 0;
if ($iLeftId === 0) {
[$sChildClass] = explode('_', $sLeft, 2);
$iLeftId = $this->GivenObjectInDB($sChildClass, ['name' => $sLeft]);
$this->aIdByClass[$sChildClass][] = $iLeftId;
$this->aIdByObjectName[$sLeft] = $iLeftId;
}
$sRight = trim($sRight);
if (preg_match("/(?<name>(?<class>[^_]+)_\d+)(\s+\((?<extkey>\w+)\))?/", $sRight, $aMatches) !== false) {
$sName = $aMatches['name'];
$sChildClass = $aMatches['class'];
$sExtKey = $aMatches['extkey'] ?? 'extkey_id';
$iRightId = $this->aIdByObjectName[$sName] ?? 0;
if ($iRightId === 0) {
$iRightId = $this->GivenObjectInDB($sChildClass, ['name' => $sName, $sExtKey => $iLeftId]);
$this->aIdByClass[$sChildClass][] = $iRightId;
$this->aIdByObjectName[$sName] = $iRightId;
} else {
// Update object
$oObj = MetaModel::GetObject($sChildClass, $iRightId);
$oObj->Set($sExtKey, $iLeftId);
$oObj->DBUpdate();
}
}
}
}

View File

@@ -34,15 +34,10 @@ use PHPUnit\Framework\MockObject\MockObject;
* @see DataCleanupSummaryEntity * @see DataCleanupSummaryEntity
* @see ItopDataTestCase * @see ItopDataTestCase
*/ */
class DataCleanupServiceTest extends ItopCustomDatamodelTestCase class DataCleanupServiceTest extends \AbstractCleanup
{ {
private ExecutionLimits&MockObject $oExecutionLimits; private ExecutionLimits&MockObject $oExecutionLimits;
public function GetDatamodelDeltaAbsPath(): string
{
return __DIR__.'/data_cleanup_delta.xml';
}
//--- GetCleanupSummary tests --- //--- GetCleanupSummary tests ---
/** /**
@@ -261,36 +256,6 @@ class DataCleanupServiceTest extends ItopCustomDatamodelTestCase
$this->AssertSummaryEquals($aExpected, $aRes); $this->AssertSummaryEquals($aExpected, $aRes);
} }
private function GivenDFRTreeInDB(string $sTree)
{
$aTree = explode("\n", $sTree);
foreach ($aTree as $sLine) {
if (trim($sLine) === "") {
continue;
}
$this->GivenDFRTreeLineInDB($sLine);
}
}
private array $aIdByObjectName = [];
private function GivenDFRTreeLineInDB(string $sLine)
{
[$sLeft, $sRight] = explode('<-', $sLine);
$sLeft = trim($sLeft);
$iLeftId = $this->aIdByObjectName[$sLeft] ?? 0;
if ($iLeftId === 0) {
[$sChildClass, ] = explode('_', $sLeft, 2);
$iLeftId = $this->GivenObjectInDB($sChildClass, ['name' => $sLeft]);
$this->aIdByObjectName[$sLeft] = $iLeftId;
}
$sRight = trim($sRight);
[$sChildClass, ] = explode('_', $sRight, 2);
$iRightId = $this->GivenObjectInDB($sChildClass, ['name' => $sRight, 'extkey_id' => $iLeftId]);
$this->aIdByObjectName[$sRight] = $iRightId;
}
private function GivenExecutionLimits(int $iStopAfterCallNumberReached): void private function GivenExecutionLimits(int $iStopAfterCallNumberReached): void
{ {
$matcher = $this->any(); $matcher = $this->any();

View File

@@ -0,0 +1,167 @@
<?php
namespace Combodo\iTop\Test\UnitTest\Module\DataFeatureRemoval;
/*
* @copyright Copyright (C) 2010-2026 Combodo SAS
* @license http://opensource.org/licenses/AGPL-3.0
*/
use Combodo\iTop\DataFeatureRemoval\Service\StaticDeletionPlan;
use MetaModel;
class StaticDeletionPlanTest extends \AbstractCleanup
{
public function GetDatamodelDeltaAbsPath(): string
{
return __DIR__.'/data_cleanup_delta.xml';
}
public function testGetInitialClassDeletionPlan()
{
$this->GivenDFRTreeInDB(<<<EOF
DFRToRemoveLeaf_1 <- DFRToUpdate_1
DFRToRemoveLeaf_1 <- DFRToUpdate_2
DFRToRemoveLeaf_2 <- DFRToUpdate_3
EOF);
$oService = new StaticDeletionPlan();
$oDeletionPlanItem = $oService->GetInitialClassDeletionPlan('DFRToRemoveLeaf');
self::assertEquals(2, $oDeletionPlanItem->Count(), 'All entries of root table should be removed');
self::assertEquals($this->aIdByClass['DFRToRemoveLeaf'], $oDeletionPlanItem->aIds, 'All the Ids found in root table should correspond to the one created');
$sTable = MetaModel::DBGetTable('DFRToRemoveLeaf');
$sExpectedSQL = "DELETE FROM `$sTable`";
self::assertEquals($sExpectedSQL, $oDeletionPlanItem->aQueries[0], 'Removing elements in root class should suppress all entries');
}
public function testUpdateExtKeyNullable()
{
$this->GivenDFRTreeInDB(<<<EOF
DFRToRemoveLeaf_1 <- DFRToUpdate_1
DFRToRemoveLeaf_1 <- DFRToUpdate_2
DFRToRemoveLeaf_2 <- DFRToUpdate_3
DFRLeafNotToRemove_1 <- DFRToUpdate_4
EOF);
// WHEN
$oService = new StaticDeletionPlan();
$sRemoteClass = 'DFRToUpdate';
$oDeletionPlanItem = $oService->UpdateExtKeyNullable(
$sRemoteClass,
'extkey_id',
implode(',', $this->aIdByClass['DFRToRemoveLeaf'])
);
$sUpdateSQL = $oDeletionPlanItem->aQueries['extkey_id'];
// THEN
$sExpectedSQLEnd = " IN (".implode(',', $this->aIdByClass['DFRToRemoveLeaf']).")";
self::assertStringEndsWith($sExpectedSQLEnd, $sUpdateSQL, 'The query should be filtered with the ids of the root class');
self::assertEquals(3, $oDeletionPlanItem->Count(), 'All entries of root table should be removed');
$sIdsToRemoveInTargetClass = implode(',', $this->aIdByClass['DFRToRemoveLeaf']);
$aExpectedIds = $oService->GetRemoteIdsForExtKey($sRemoteClass, 'extkey_id', $sIdsToRemoveInTargetClass);
self::assertEquals($aExpectedIds, $oDeletionPlanItem->aIds, 'All entries pointing on root class should be removed');
}
/**
* Tests that GetCleanupSummary returns an empty array when passed null as input.
*/
public function testGetCleanupSummaryReturnsEmptyArrayWhenNull(): void
{
$oService = new StaticDeletionPlan();
$aResult = $oService->GetStaticDeletionPlan([]);
$this->assertIsArray($aResult, 'Expected result to be an array when input is null.');
$this->assertEmpty($aResult, 'Expected result to be empty array when input is null.');
}
public function testGetStaticDeletionPlan_DeleteObjRecursively()
{
$this->GivenDFRTreeInDB(<<<EOF
DFRToRemoveLeaf_1 <- DFRToUpdate_1
DFRToRemoveLeaf_1 <- DFRRemovedCollateral_1
DFRRemovedCollateral_1 <- DFRRemovedCollateralCascade_1
DFRRemovedCollateral_1 <- DFRRemovedCollateralCascade_2
EOF);
$aClasses = [ 'DFRToRemoveLeaf' ];
$oService = new StaticDeletionPlan();
$aRes = $oService->GetStaticDeletionPlan($aClasses);
self::assertArrayHasKey('DFRRemovedCollateralCascade', $aRes, 'The cleanup should descend to the cascaded classes');
// echo json_encode($aRes, JSON_PRETTY_PRINT)."\n";
// echo json_encode($this->aIdByClass, JSON_PRETTY_PRINT);
}
public function testGetStaticDeletionPlan_IssuesArePresent()
{
$this->GivenDFRTreeInDB(<<<EOF
DFRToRemoveLeaf_1 <- DFRToUpdate_1
DFRToRemoveLeaf_1 <- DFRRemovedCollateral_1
DFRRemovedCollateral_1 <- DFRRemovedCollateralCascade_1
DFRRemovedCollateral_1 <- DFRRemovedCollateralCascade_2
DFRToRemoveLeaf_1 <- DFRManual_1
EOF);
$aClasses = [ 'DFRToRemoveLeaf' ];
$oService = new StaticDeletionPlan();
$aRes = $oService->GetStaticDeletionPlan($aClasses);
self::assertEquals(1, $aRes['DFRManual']->oIssue->Count(), 'Issue should be found because of DEL_MANUAL deletion policy');
self::assertEquals($this->aIdByClass['DFRManual'], $aRes['DFRManual']->oIssue->aIds, 'Issue should be correspond to the entries created');
}
public function testGetStaticDeletionPlan_UpdateMultipleExtKeys()
{
$this->GivenDFRTreeInDB(<<<EOF
DFRToRemoveLeaf_1 <- DFRToUpdate_1 (extkey_id)
DFRToRemoveLeaf_2 <- DFRToUpdate_2 (extkey2_id)
DFRLeafNotToRemove_1 <- DFRToUpdate_3 (extkey_id)
EOF);
$aClasses = [ 'DFRToRemoveLeaf' ];
$oService = new StaticDeletionPlan();
$aRes = $oService->GetStaticDeletionPlan($aClasses);
self::assertArrayHasKey('DFRToUpdate', $aRes, 'Class to update should be targeted');
self::assertEquals(2, $aRes['DFRToUpdate']->oUpdate->Count(), 'Update should be counted only for removed pointed classes');
}
public function testGetCleanupSummary()
{
$this->GivenDFRTreeInDB(<<<EOF
DFRToRemoveLeaf_1 <- DFRToUpdate_1
DFRToRemoveLeaf_1 <- DFRRemovedCollateral_1
DFRRemovedCollateral_1 <- DFRRemovedCollateralCascade_1
DFRRemovedCollateral_1 <- DFRRemovedCollateralCascade_2
DFRToRemoveLeaf_1 <- DFRManual_1
EOF);
$aClasses = [ 'DFRToRemoveLeaf' ];
$oService = new StaticDeletionPlan();
$aRes = $oService->GetCleanupSummary($aClasses);
self::assertEquals(1, $aRes['DFRManual']->iIssueCount, 'Issue should have been detected during cleanup count');
}
public function testCircularRefsShouldNotRunInfinitely()
{
$this->GivenDFRTreeInDB(<<<EOF
DFRToRemoveLeaf_1 <- DFRRemovedCollateral_1
DFRRemovedCollateral_1 <- DFRCircularRefs_1
DFRCircularRefs_1 <- DFRRemovedCollateral_1 (circular_id)
EOF);
$aClasses = [ 'DFRToRemoveLeaf' ];
$oService = new StaticDeletionPlan();
$aRes = $oService->GetCleanupSummary($aClasses);
echo json_encode($aRes, JSON_PRETTY_PRINT)."\n";
$aRes = $oService->GetStaticDeletionPlan($aClasses);
echo json_encode($aRes, JSON_PRETTY_PRINT)."\n";
self::assertTrue(true);
}
}

View File

@@ -71,6 +71,15 @@
<on_target_delete>DEL_AUTO</on_target_delete> <on_target_delete>DEL_AUTO</on_target_delete>
<tracking_level>all</tracking_level> <tracking_level>all</tracking_level>
</field> </field>
<field id="extkey2_id" xsi:type="AttributeExternalKey">
<sql>extkey2_id</sql>
<filter/>
<dependencies/>
<is_null_allowed>true</is_null_allowed>
<target_class>DFRToRemove</target_class>
<on_target_delete>DEL_AUTO</on_target_delete>
<tracking_level>all</tracking_level>
</field>
</fields> </fields>
<methods/> <methods/>
<presentation> <presentation>
@@ -123,6 +132,15 @@
<on_target_delete>DEL_AUTO</on_target_delete> <on_target_delete>DEL_AUTO</on_target_delete>
<tracking_level>all</tracking_level> <tracking_level>all</tracking_level>
</field> </field>
<field id="circular_id" xsi:type="AttributeExternalKey">
<sql>circular_id</sql>
<filter/>
<dependencies/>
<is_null_allowed>true</is_null_allowed>
<target_class>DFRCircularRefs</target_class>
<on_target_delete>DEL_AUTO</on_target_delete>
<tracking_level>all</tracking_level>
</field>
</fields> </fields>
<methods/> <methods/>
<presentation> <presentation>
@@ -145,11 +163,11 @@
</presentation> </presentation>
<parent>cmdbAbstractObject</parent> <parent>cmdbAbstractObject</parent>
</class> </class>
<class id="DFRToRemoveLeaf" _created_in="itop-structure" _delta="define"> <class id="DFRCircularRefs" _created_in="itop-structure" _delta="define">
<properties> <properties>
<category>bizmodel,searchable</category> <category>bizmodel,searchable</category>
<abstract>false</abstract> <abstract>false</abstract>
<db_table>dfrtoremoveleaf</db_table> <db_table>dfrcircularrefs</db_table>
<naming> <naming>
<attributes/> <attributes/>
</naming> </naming>
@@ -158,14 +176,23 @@
</reconciliation> </reconciliation>
</properties> </properties>
<fields> <fields>
<field id="desc" xsi:type="AttributeString"> <field id="name" xsi:type="AttributeString">
<sql>desc</sql> <sql>name</sql>
<default_value/> <default_value/>
<is_null_allowed>true</is_null_allowed> <is_null_allowed>true</is_null_allowed>
<validation_pattern/> <validation_pattern/>
<dependencies/> <dependencies/>
<tracking_level>all</tracking_level> <tracking_level>all</tracking_level>
</field> </field>
<field id="extkey_id" xsi:type="AttributeExternalKey">
<sql>extkey_id</sql>
<filter/>
<dependencies/>
<is_null_allowed>false</is_null_allowed>
<target_class>DFRRemovedCollateral</target_class>
<on_target_delete>DEL_AUTO</on_target_delete>
<tracking_level>all</tracking_level>
</field>
</fields> </fields>
<methods/> <methods/>
<presentation> <presentation>
@@ -180,13 +207,13 @@
<item id="name"> <item id="name">
<rank>10</rank> <rank>10</rank>
</item> </item>
<item id="desc"> <item id="extkey_id">
<rank>20</rank> <rank>20</rank>
</item> </item>
</items> </items>
</details> </details>
</presentation> </presentation>
<parent>DFRToRemove</parent> <parent>cmdbAbstractObject</parent>
</class> </class>
<class id="DFRRemovedCollateralCascade" _created_in="itop-structure" _delta="define"> <class id="DFRRemovedCollateralCascade" _created_in="itop-structure" _delta="define">
<properties> <properties>
@@ -292,6 +319,92 @@
</presentation> </presentation>
<parent>cmdbAbstractObject</parent> <parent>cmdbAbstractObject</parent>
</class> </class>
<class id="DFRLeafNotToRemove" _created_in="itop-structure" _delta="define">
<properties>
<category>bizmodel,searchable</category>
<abstract>false</abstract>
<db_table>dfrleafnottoremove</db_table>
<naming>
<attributes/>
</naming>
<reconciliation>
<attributes/>
</reconciliation>
</properties>
<fields>
<field id="info" xsi:type="AttributeString">
<sql>info</sql>
<default_value/>
<is_null_allowed>true</is_null_allowed>
<validation_pattern/>
<dependencies/>
<tracking_level>all</tracking_level>
</field>
</fields>
<methods/>
<presentation>
<list>
<items/>
</list>
<search>
<items/>
</search>
<details>
<items>
<item id="name">
<rank>10</rank>
</item>
<item id="info">
<rank>20</rank>
</item>
</items>
</details>
</presentation>
<parent>DFRToRemove</parent>
</class>
<class id="DFRToRemoveLeaf" _created_in="itop-structure" _delta="define">
<properties>
<category>bizmodel,searchable</category>
<abstract>false</abstract>
<db_table>dfrtoremoveleaf</db_table>
<naming>
<attributes/>
</naming>
<reconciliation>
<attributes/>
</reconciliation>
</properties>
<fields>
<field id="desc" xsi:type="AttributeString">
<sql>desc</sql>
<default_value/>
<is_null_allowed>true</is_null_allowed>
<validation_pattern/>
<dependencies/>
<tracking_level>all</tracking_level>
</field>
</fields>
<methods/>
<presentation>
<list>
<items/>
</list>
<search>
<items/>
</search>
<details>
<items>
<item id="name">
<rank>10</rank>
</item>
<item id="desc">
<rank>20</rank>
</item>
</items>
</details>
</presentation>
<parent>DFRToRemove</parent>
</class>
</classes> </classes>
<dictionaries> <dictionaries>
<dictionary id="EN US"> <dictionary id="EN US">

View File

@@ -59,7 +59,7 @@ foreach ($aAddedExtensions as $iIndex => $sExtensionCode) {
} }
} }
$sRemovedExtensions = utils::ReadParam('removed_modules', '', false, 'raw'); $sRemovedExtensions = utils::ReadParam('removed_modules', 'itop-container-mgmt', false, 'raw');
$aRemovedExtensionsAndModules = []; $aRemovedExtensionsAndModules = [];
if (mb_strlen($sRemovedExtensions) > 0) { if (mb_strlen($sRemovedExtensions) > 0) {
$aRemovedExtensionsAndModules = explode(',', $sRemovedExtensions); $aRemovedExtensionsAndModules = explode(',', $sRemovedExtensions);