N°9165 - secure data cleanup

This commit is contained in:
Eric Espié
2026-04-30 10:20:25 +02:00
committed by GitHub
parent 0fc7434794
commit 4582256f01
31 changed files with 1505 additions and 511 deletions

View File

@@ -3190,4 +3190,30 @@ TXT
} }
} }
} }
/**
* Read memory limit from the php.ini file
*
* @return int Memory limit in bytes
*/
public static function GetMemoryLimit(): int
{
$sLimit = ini_get('memory_limit');
if ($sLimit == '-1') {
return 128 * 1048576;
}
switch (substr($sLimit, -1)) {
case 'M':
case 'm':
return (int)$sLimit * 1048576;
case 'K':
case 'k':
return (int)$sLimit * 1024;
case 'G':
case 'g':
return (int)$sLimit * 1073741824;
default:
return (int)$sLimit;
}
}
} }

View File

@@ -4,8 +4,7 @@
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Combodo\\iTop\\DataFeatureRemoval\\": "src", "Combodo\\iTop\\DataFeatureRemoval\\": "src"
"": "src/NoNamespace"
} }
}, },
"name": "combodo/combodo-data-feature-removal", "name": "combodo/combodo-data-feature-removal",

View File

@@ -28,7 +28,7 @@ Dict::Add('EN US', 'English', 'English', [
'DataFeatureRemoval:DeletionPlan:SubTitle' => '%1$s rows to clean before continuing', 'DataFeatureRemoval:DeletionPlan:SubTitle' => '%1$s rows to clean before continuing',
'DataFeatureRemoval:DoDeletion:Title' => 'Do deletion', 'DataFeatureRemoval:DoDeletion:Title' => 'Do deletion',
'DataFeatureRemoval:DoDeletion:SubTitle' => 'Remove all the entries from the database', 'DataFeatureRemoval:DoDeletion:SubTitle' => 'Remove all the entries from the database',
'DataFeatureRemoval:DeletionPlan:ToManyOperations' => 'Too many entries to clean', 'DataFeatureRemoval:DeletionPlan:Error:Issues' => 'Some objects must be deleted manually prior to cleanup',
'DataFeatureRemoval:Table:Analysis:ClassName' => 'Element to remove', 'DataFeatureRemoval:Table:Analysis:ClassName' => 'Element to remove',
'DataFeatureRemoval:Table:Analysis:FeatureName' => 'Feature name', 'DataFeatureRemoval:Table:Analysis:FeatureName' => 'Feature name',
@@ -51,7 +51,7 @@ Dict::Add('EN US', 'English', 'English', [
'DataFeatureRemoval:Column:Class' => 'Class', 'DataFeatureRemoval:Column:Class' => 'Class',
'DataFeatureRemoval:Column:DeleteCount' => 'Entries to delete', 'DataFeatureRemoval:Column:DeleteCount' => 'Entries to delete',
'DataFeatureRemoval:Column:UpdateCount' => 'Entries to update', 'DataFeatureRemoval:Column:UpdateCount' => 'Entries to update',
'DataFeatureRemoval:Column:Issue' => 'Issue', 'DataFeatureRemoval:Column:IssueCount' => 'Issues found preventing automatic cleanup',
'DataFeatureRemoval:Column:DeletedCount' => 'Deleted entries', 'DataFeatureRemoval:Column:DeletedCount' => 'Deleted entries',
'DataFeatureRemoval:Column:UpdatedCount' => 'Updated entries', 'DataFeatureRemoval:Column:UpdatedCount' => 'Updated entries',

View File

@@ -28,7 +28,7 @@ Dict::Add('FR FR', 'French', 'Français', [
'DataFeatureRemoval:DeletionPlan:SubTitle' => '%1$s ligne(s) à nettoyer avant de poursuivre', 'DataFeatureRemoval:DeletionPlan:SubTitle' => '%1$s ligne(s) à nettoyer avant de poursuivre',
'DataFeatureRemoval:DoDeletion:Title' => 'Exécuter la suppression', 'DataFeatureRemoval:DoDeletion:Title' => 'Exécuter la suppression',
'DataFeatureRemoval:DoDeletion:SubTitle' => 'Supprime toutes les entrées de la base de données', 'DataFeatureRemoval:DoDeletion:SubTitle' => 'Supprime toutes les entrées de la base de données',
'DataFeatureRemoval:DeletionPlan:ToManyOperations' => 'Trop dentrées à nettoyer', 'DataFeatureRemoval:DeletionPlan:Error:Issues' => 'Certains objets doivent être supprimés manuellement avant le nettoyage',
'DataFeatureRemoval:Table:Analysis:ClassName' => 'Élément à supprimer', 'DataFeatureRemoval:Table:Analysis:ClassName' => 'Élément à supprimer',
'DataFeatureRemoval:Table:Analysis:FeatureName' => 'Fonctionnalité', 'DataFeatureRemoval:Table:Analysis:FeatureName' => 'Fonctionnalité',
@@ -51,7 +51,7 @@ Dict::Add('FR FR', 'French', 'Français', [
'DataFeatureRemoval:Column:Class' => 'Classe', 'DataFeatureRemoval:Column:Class' => 'Classe',
'DataFeatureRemoval:Column:DeleteCount' => 'Entrées à supprimer', 'DataFeatureRemoval:Column:DeleteCount' => 'Entrées à supprimer',
'DataFeatureRemoval:Column:UpdateCount' => 'Entrées à mettre à jour', 'DataFeatureRemoval:Column:UpdateCount' => 'Entrées à mettre à jour',
'DataFeatureRemoval:Column:Issue' => 'Problème', 'DataFeatureRemoval:Column:IssueCount' => 'Problèmes empêchant le nettoyage automatique',
'DataFeatureRemoval:Column:DeletedCount' => 'Entrées supprimées', 'DataFeatureRemoval:Column:DeletedCount' => 'Entrées supprimées',
'DataFeatureRemoval:Column:UpdatedCount' => 'Entrées mises à jour', 'DataFeatureRemoval:Column:UpdatedCount' => 'Entrées mises à jour',

View File

@@ -14,8 +14,8 @@ use Combodo\iTop\Application\TwigBase\Controller\Controller;
use Combodo\iTop\DataFeatureRemoval\Helper\DataFeatureRemovalConfig; use Combodo\iTop\DataFeatureRemoval\Helper\DataFeatureRemovalConfig;
use Combodo\iTop\DataFeatureRemoval\Helper\DataFeatureRemovalException; use Combodo\iTop\DataFeatureRemoval\Helper\DataFeatureRemovalException;
use Combodo\iTop\DataFeatureRemoval\Helper\DataFeatureRemovalHelper; use Combodo\iTop\DataFeatureRemoval\Helper\DataFeatureRemovalHelper;
use Combodo\iTop\DataFeatureRemoval\Service\DataCleanupService;
use Combodo\iTop\DataFeatureRemoval\Service\DataFeatureRemoverExtensionService; use Combodo\iTop\DataFeatureRemoval\Service\DataFeatureRemoverExtensionService;
use Combodo\iTop\DataFeatureRemoval\Service\DeletionPlanService;
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 Dict; use Dict;
@@ -104,17 +104,20 @@ class DataFeatureRemovalController extends Controller
$aClasses = utils::ReadPostedParam('classes', null, utils::ENUM_SANITIZATION_FILTER_CLASS); $aClasses = utils::ReadPostedParam('classes', null, utils::ENUM_SANITIZATION_FILTER_CLASS);
$aDeletionPlanSummaryEntities = DeletionPlanService::GetInstance()->GetDeletionPlanSummary($aClasses); $oDataCleanupService = new DataCleanupService();
$aColumns = ['Class', 'DeleteCount' , 'UpdateCount', 'Issue']; $aDeletionPlanSummaryEntities = $oDataCleanupService->GetCleanupSummary($aClasses);
$aColumns = ['Class', 'DeleteCount' , 'UpdateCount', 'IssueCount'];
$aRows = []; $aRows = [];
$iQueryCount = 0; $iQueryCount = 0;
$bHasIssues = false;
foreach ($aDeletionPlanSummaryEntities as $oDeletionPlanSummaryEntity) { foreach ($aDeletionPlanSummaryEntities as $oDeletionPlanSummaryEntity) {
$aRows[] = [ $aRows[] = [
$oDeletionPlanSummaryEntity->sClass, $oDeletionPlanSummaryEntity->sClass,
$oDeletionPlanSummaryEntity->iDeleteCount, $oDeletionPlanSummaryEntity->iDeleteCount,
$oDeletionPlanSummaryEntity->iUpdateCount, $oDeletionPlanSummaryEntity->iUpdateCount,
$oDeletionPlanSummaryEntity->sIssue ?? '', $oDeletionPlanSummaryEntity->iIssueCount,
]; ];
$bHasIssues |= ($oDeletionPlanSummaryEntity->iIssueCount !== 0);
$iQueryCount += $oDeletionPlanSummaryEntity->iDeleteCount; $iQueryCount += $oDeletionPlanSummaryEntity->iDeleteCount;
$iQueryCount += $oDeletionPlanSummaryEntity->iUpdateCount; $iQueryCount += $oDeletionPlanSummaryEntity->iUpdateCount;
} }
@@ -123,7 +126,7 @@ class DataFeatureRemovalController extends Controller
$aParams['aDeletionPlanSummary'] = $this->GetTableData('Extensions', $aColumns, $aRows); $aParams['aDeletionPlanSummary'] = $this->GetTableData('Extensions', $aColumns, $aRows);
$aParams['aClasses'] = $aClasses; $aParams['aClasses'] = $aClasses;
$aParams['iQueryCount'] = $iQueryCount; $aParams['iQueryCount'] = $iQueryCount;
$aParams['bDeletionPossible'] = ($iQueryCount <= DataFeatureRemovalConfig::GetInstance()->Get('max_count_estimation_for_safe_cleanup', 100)); $aParams['bDeletionPossible'] = !$bHasIssues;
$this->DisplayPage($aParams); $this->DisplayPage($aParams);
} }
@@ -135,7 +138,8 @@ class DataFeatureRemovalController extends Controller
$aClasses = utils::ReadPostedParam('classes', null, utils::ENUM_SANITIZATION_FILTER_CLASS); $aClasses = utils::ReadPostedParam('classes', null, utils::ENUM_SANITIZATION_FILTER_CLASS);
$aDeletionExecutionSummary = DeletionPlanService::GetInstance()->ExecuteDeletionPlan($aClasses); $oDataCleanupService = new DataCleanupService();
$aDeletionExecutionSummary = $oDataCleanupService->ExecuteCleanup($aClasses);
$aColumns = ['Class', 'DeletedCount' , 'UpdatedCount']; $aColumns = ['Class', 'DeletedCount' , 'UpdatedCount'];
$aRows = []; $aRows = [];
foreach ($aDeletionExecutionSummary as $oDeletionExecutionSummaryEntity) { foreach ($aDeletionExecutionSummary as $oDeletionExecutionSummaryEntity) {
@@ -148,6 +152,7 @@ class DataFeatureRemovalController extends Controller
$aParams['sTransactionId'] = utils::GetNewTransactionId(); $aParams['sTransactionId'] = utils::GetNewTransactionId();
$aParams['aDeletionExecutionSummary'] = $this->GetTableData('Extensions', $aColumns, $aRows); $aParams['aDeletionExecutionSummary'] = $this->GetTableData('Extensions', $aColumns, $aRows);
$this->DisplayPage($aParams); $this->DisplayPage($aParams);
} }

View File

@@ -2,16 +2,10 @@
namespace Combodo\iTop\DataFeatureRemoval\Entity; namespace Combodo\iTop\DataFeatureRemoval\Entity;
class DeletionPlanSummaryEntity class DataCleanupSummaryEntity
{ {
public string $sClass; public string $sClass;
public int $iIssueCount = 0;
/**
* @var int : DEL_MANUAL|DEL_AUTO|DEL_SILENT|DEL_MOVEUP|DEL_NONE
* @see \AttributeDefinition DEL_xxx
*/
public int $iMode = 0;
public ?string $sIssue = null;
public int $iUpdateCount = 0; public int $iUpdateCount = 0;
public int $iDeleteCount = 0; public int $iDeleteCount = 0;

View File

@@ -20,11 +20,11 @@ class DataFeatureRemovalConfig
final public static function GetInstance(): DataFeatureRemovalConfig final public static function GetInstance(): DataFeatureRemovalConfig
{ {
if (!isset(static::$oInstance)) { if (!isset(self::$oInstance)) {
static::$oInstance = new DataFeatureRemovalConfig(); self::$oInstance = new DataFeatureRemovalConfig();
} }
return static::$oInstance; return self::$oInstance;
} }
public function Get(string $sParamName, $default = null) public function Get(string $sParamName, $default = null)

View File

@@ -0,0 +1,177 @@
<?php
namespace Combodo\iTop\DataFeatureRemoval\Service;
use CMDBObjectSet;
use Combodo\iTop\DataFeatureRemoval\Helper\DataFeatureRemovalLog;
use Combodo\iTop\Service\Limits\ExecutionLimits;
use DBObject;
use DBObjectSearch;
use MetaModel;
class DataCleanupService
{
private array $aVisited = [];
private iObjectService $oObjectService;
private ExecutionLimits $oExecutionLimits;
public function __construct(int $iMaxExecutionTime = 30, int $iMaxMemoryPercent = 80)
{
DataFeatureRemovalLog::Enable();
$this->oExecutionLimits = new ExecutionLimits($iMaxExecutionTime, $iMaxMemoryPercent);
}
/**
* 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
* @throws \CoreUnexpectedValue
* @throws \MySQLException
* @throws \Combodo\iTop\DataFeatureRemoval\Helper\DataFeatureRemovalException
*/
public function GetCleanupSummary(?array $aClasses): array
{
return $this->ExecuteCleanup($aClasses ?? [], oObjectService: new ObjectServiceSummary());
}
private function GetNextObjectToDelete(array $aClasses): ?DBObject
{
foreach ($aClasses as $sClass) {
$oFilter = new DBObjectSearch($sClass);
$oFilter->AllowAllData();
$oSet = new \DBObjectSet($oFilter);
while ($oObject = $oSet->Fetch()) {
if (!$this->IsVisited($oObject)) {
return $oObject;
}
}
}
return null;
}
/**
* @param array $aClasses
* @param \Combodo\iTop\DataFeatureRemoval\Service\iObjectService|null $oObjectService
*
* @return array execution summary
* @throws \ArchivedObjectException
* @throws \Combodo\iTop\DataFeatureRemoval\Helper\DataFeatureRemovalException
* @throws \CoreException
* @throws \CoreUnexpectedValue
* @throws \MySQLException
*/
public function ExecuteCleanup(array $aClasses, ?iObjectService $oObjectService = null): array
{
$this->oObjectService = $oObjectService ?? new ObjectService();
$this->aVisited = [];
while ($oObject = $this->GetNextObjectToDelete($aClasses)) {
if ($this->RecursiveDeletion($oObject) === false) {
// Timeout, stop here
break;
}
}
return $this->oObjectService->GetSummary();
}
private function MarkObjectAsVisited(DBObject $oObject): void
{
$sClass = get_class($oObject);
$sId = $oObject->GetKey();
$sKey = "$sClass-$sId";
$this->aVisited[$sKey] = true;
}
private function IsVisited(DBObject $oObject): bool
{
$sClass = get_class($oObject);
$sId = $oObject->GetKey();
$sKey = "$sClass-$sId";
$bRes = $this->aVisited[$sKey] ?? false;
DataFeatureRemovalLog::Debug('Checking if object is visited', null, [$sKey, $bRes]);
return $bRes;
}
/**
*
* @param \DBObject $oObjectToClean
*
* @return bool true if deletion is complete, false in case of timeout or memory limit reached
*
* @throws \ArchivedObjectException
* @throws \Combodo\iTop\DataFeatureRemoval\Helper\DataFeatureRemovalException
* @throws \CoreException
* @throws \CoreUnexpectedValue
* @throws \MySQLException
*/
private function RecursiveDeletion(DBObject $oObjectToClean): bool
{
$this->MarkObjectAsVisited($oObjectToClean);
$sClass = get_class($oObjectToClean);
$aReferencingMe = MetaModel::EnumReferencingClasses($sClass);
foreach ($aReferencingMe as $sRemoteClass => $aExtKeys) {
/** @var \AttributeExternalKey $oExtKeyAttDef */
foreach ($aExtKeys as $sExtKeyAttCode => $oExtKeyAttDef) {
// skip if this external key is behind an external field
if (!$oExtKeyAttDef->IsExternalKey(EXTKEY_ABSOLUTE)) {
continue;
}
$oSearch = new DBObjectSearch($sRemoteClass);
$oSearch->AddCondition($sExtKeyAttCode, $oObjectToClean->GetKey(), '=');
$oSearch->AllowAllData();
$oSet = new CMDBObjectSet($oSearch);
$oSet->OptimizeColumnLoad([$sRemoteClass => [$oExtKeyAttDef->GetCode()]]);
/** @var DBObject $oDependentObj */
while ($oDependentObj = $oSet->Fetch()) {
$iDeletePropagationOption = $oExtKeyAttDef->GetDeletionPropagationOption();
if ($iDeletePropagationOption == DEL_MANUAL) {
$this->oObjectService->SetIssue(get_class($oDependentObj));
continue;
}
if ($oExtKeyAttDef->IsNullAllowed()) {
// Optional external key, list to reset
if (($iDeletePropagationOption == DEL_MOVEUP) && ($oExtKeyAttDef->IsHierarchicalKey())) {
// Move the child up one level i.e. set the same parent as the current object
$iParentId = $oObjectToClean->Get($oExtKeyAttDef->GetCode());
$this->oObjectService->Update($oDependentObj, $oExtKeyAttDef->GetCode(), $iParentId);
} else {
$this->oObjectService->Update($oDependentObj, $oExtKeyAttDef->GetCode(), 0);
}
if ($this->oExecutionLimits->ShouldStopExecution()) {
return false;
}
} else {
// Propagate deletion only if not visited
if ($this->IsVisited($oDependentObj)) {
continue;
}
if (!$this->RecursiveDeletion($oDependentObj)) {
// Timeout
return false;
}
}
}
}
}
$this->oObjectService->Delete($sClass, $oObjectToClean->GetKey());
if ($this->oExecutionLimits->ShouldStopExecution()) {
return false;
}
return true;
}
}

View File

@@ -1,182 +0,0 @@
<?php
namespace Combodo\iTop\DataFeatureRemoval\Service;
use CMDBSource;
use Combodo\iTop\DataFeatureRemoval\Entity\DeletionPlanSummaryEntity;
use Combodo\iTop\DataFeatureRemoval\Helper\DataFeatureRemovalException;
use DBObjectSearch;
use DeletionPlan;
use MetaModel;
class DeletionPlanService
{
private static DeletionPlanService $oInstance;
protected function __construct()
{
}
final public static function GetInstance(): DeletionPlanService
{
if (!isset(self::$oInstance)) {
self::$oInstance = new DeletionPlanService();
}
return self::$oInstance;
}
final public static function SetInstance(?DeletionPlanService $oInstance): void
{
self::$oInstance = $oInstance;
}
/**
* 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\DeletionPlanSummaryEntity>
* @throws \CoreException
* @throws \CoreUnexpectedValue
* @throws \MySQLException
*/
public function GetDeletionPlanSummary(?array $aClasses): array
{
$aSummary = [];
if (is_null($aClasses)) {
return $aSummary;
}
$oDeletionPlan = $this->GetDeletionPlan($aClasses);
foreach ($oDeletionPlan->ListUpdates() as $sClass => $aUpdates) {
$oDeletionPlanSummaryEntity = new DeletionPlanSummaryEntity($sClass);
$oDeletionPlanSummaryEntity->iUpdateCount = count($aUpdates);
$aSummary[$sClass] = $oDeletionPlanSummaryEntity;
}
foreach ($oDeletionPlan->ListDeletes() as $sClass => $aDeletes) {
$oDeletionPlanSummaryEntity = $aSummary[$sClass] ?? new DeletionPlanSummaryEntity($sClass);
$oDeletionPlanSummaryEntity->iDeleteCount = count($aDeletes);
$aDelete = array_shift($aDeletes);
$oDeletionPlanSummaryEntity->iMode = $aDelete['mode'];
$oDeletionPlanSummaryEntity->sIssue = $aDelete['issue'] ?? null;
$aSummary[$sClass] = $oDeletionPlanSummaryEntity;
}
return $aSummary;
}
/**
* @param string $sClass
*
* @return \DBObject[]
* @throws \CoreException
* @throws \CoreUnexpectedValue
* @throws \MySQLException
* @throws \Exception
*/
private function GetAllObjects(string $sClass): array
{
$oFilter = new DBObjectSearch($sClass);
$oFilter->AllowAllData();
$oSet = new \DBObjectSet($oFilter);
return $oSet->ToArray();
}
/**
* @param array $aClasses
*
* @return array<\Combodo\iTop\DataFeatureRemoval\Entity\DeletionPlanSummaryEntity>
* @throws \Combodo\iTop\DataFeatureRemoval\Helper\DataFeatureRemovalException
* @throws \CoreException
* @throws \CoreUnexpectedValue
* @throws \MySQLException
*/
public function ExecuteDeletionPlan(array $aClasses): array
{
$oDeletionPlan = $this->GetDeletionPlan($aClasses);
if (count($oDeletionPlan->GetIssues()) > 0) {
throw new DataFeatureRemovalException("Deletion Plan cannot be executed due to issues");
}
$aSummary = [];
foreach ($oDeletionPlan->ListUpdates() as $sClass => $aToUpdate) {
$oDeletionPlanSummaryEntity = $aSummary[$sClass] ?? new DeletionPlanSummaryEntity($sClass);
foreach ($aToUpdate as $aData) {
$oToUpdate = $aData['to_reset'];
/** @var \DBObject $oToUpdate */
foreach ($aData['attributes'] as $sRemoteExtKey => $aRemoteAttDef) {
$oToUpdate->Set($sRemoteExtKey, $aData['values'][$sRemoteExtKey]);
}
$oToUpdate->DBUpdate();
$oDeletionPlanSummaryEntity->iUpdateCount++;
}
$aSummary[$sClass] = $oDeletionPlanSummaryEntity;
}
foreach ($oDeletionPlan->ListDeletes() as $sClass => $aDeletes) {
$oDeletionPlanSummaryEntity = $aSummary[$sClass] ?? new DeletionPlanSummaryEntity($sClass);
foreach ($aDeletes as $sId => $aDelete) {
try {
CMDBSource::Query('START TRANSACTION');
// Delete any existing change tracking about the current object
$oFilter = new DBObjectSearch('CMDBChangeOp');
$oFilter->AddCondition('objclass', $sClass, '=');
$oFilter->AddCondition('objkey', $sId, '=');
MetaModel::PurgeData($oFilter);
// Delete the entry
$aClassesToRemove = array_merge(MetaModel::EnumChildClasses($sClass, ENUM_CHILD_CLASSES_ALL), MetaModel::EnumParentClasses($sClass, ENUM_PARENT_CLASSES_EXCLUDELEAF, false));
foreach ($aClassesToRemove as $sParentClass) {
$oFilter = DBObjectSearch::FromOQL_AllData("SELECT $sParentClass WHERE id=:id");
$sQuery = $oFilter->MakeDeleteQuery(['id' => $sId]);
CMDBSource::DeleteFrom($sQuery);
}
CMDBSource::Query('COMMIT');
} catch (\Exception $e) {
\IssueLog::Exception(__METHOD__.': Cleanup failed', $e);
CMDBSource::Query('ROLLBACK');
throw $e;
}
$oDeletionPlanSummaryEntity->iDeleteCount++;
}
$aSummary[$sClass] = $oDeletionPlanSummaryEntity;
}
return $aSummary;
}
/**
* Get a deletion plan for all the objects of the classes
*
* @param array $aClasses array of class names to clean
*
* @return \DeletionPlan
* @throws \CoreException
* @throws \CoreUnexpectedValue
* @throws \MySQLException
*/
public function GetDeletionPlan(array $aClasses): DeletionPlan
{
$oDeletionPlan = new DeletionPlan();
foreach ($aClasses as $sClass) {
$aObjects = $this->GetAllObjects($sClass);
foreach ($aObjects as $oObject) {
$oObject->CheckToDelete($oDeletionPlan);
}
}
return $oDeletionPlan;
}
}

View File

@@ -0,0 +1,60 @@
<?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\Helper\DataFeatureRemovalException;
use Combodo\iTop\DataFeatureRemoval\Helper\DataFeatureRemovalLog;
use DBObject;
use DBObjectSearch;
use MetaModel;
class ObjectService extends ObjectServiceSummary
{
public function Update(DBObject $oToUpdate, string $sAttCode, $value): void
{
$oToUpdate->Set($sAttCode, $value);
$oToUpdate->DBUpdate();
parent::Update($oToUpdate, $sAttCode, $value);
}
public function Delete(string $sClass, string $sId): void
{
try {
CMDBSource::Query('START TRANSACTION');
// Delete any existing change tracking about the current object
$oFilter = new DBObjectSearch('CMDBChangeOp');
$oFilter->AddCondition('objclass', $sClass, '=');
$oFilter->AddCondition('objkey', $sId, '=');
MetaModel::PurgeData($oFilter);
// Delete the entry
$aClassesToRemove = array_merge(MetaModel::EnumChildClasses($sClass, ENUM_CHILD_CLASSES_ALL), MetaModel::EnumParentClasses($sClass, ENUM_PARENT_CLASSES_EXCLUDELEAF, false));
foreach ($aClassesToRemove as $sParentClass) {
/** @var DBObjectSearch $oFilter */
$oFilter = DBObjectSearch::FromOQL_AllData("SELECT $sParentClass WHERE id=:id");
$sQuery = $oFilter->MakeDeleteQuery(['id' => $sId]);
CMDBSource::DeleteFrom($sQuery);
}
CMDBSource::Query('COMMIT');
parent::Delete($sClass, $sId);
} catch (\Exception $e) {
DataFeatureRemovalLog::Exception(__METHOD__.': Cleanup failed', $e);
CMDBSource::Query('ROLLBACK');
throw $e;
}
}
public function SetIssue(string $sClass): void
{
throw new DataFeatureRemovalException('Deletion Plan cannot be executed due to issues');
}
}

View File

@@ -0,0 +1,58 @@
<?php
/*
* @copyright Copyright (C) 2010-2026 Combodo SAS
* @license http://opensource.org/licenses/AGPL-3.0
*/
namespace Combodo\iTop\DataFeatureRemoval\Service;
use Combodo\iTop\DataFeatureRemoval\Entity\DataCleanupSummaryEntity;
use Combodo\iTop\DataFeatureRemoval\Helper\DataFeatureRemovalLog;
use DBObject;
/**
* Manage operation summary instead of doing the actual update or delete
*
* The summary is an array [class => DeletionPlanSummaryEntity]
*/
class ObjectServiceSummary implements iObjectService
{
private array $aSummary = [];
public function Update(DBObject $oToUpdate, string $sAttCode, $value): void
{
$sClass = get_class($oToUpdate);
DataFeatureRemovalLog::Info('Update object', null, ['class' => $sClass, 'id' => $oToUpdate->GetKey(), 'code' => $sAttCode, 'value' => "$value"]);
if (! array_key_exists($sClass, $this->aSummary)) {
$this->aSummary[$sClass] = new DataCleanupSummaryEntity($sClass);
}
$oDeletionPlanSummaryEntity = $this->aSummary[$sClass];
$oDeletionPlanSummaryEntity->iUpdateCount++;
}
public function Delete(string $sClass, string $sId): void
{
DataFeatureRemovalLog::Info('Delete object', null, ['class' => $sClass, 'id' => $sId]);
if (!array_key_exists($sClass, $this->aSummary)) {
$this->aSummary[$sClass] = new DataCleanupSummaryEntity($sClass);
}
$oDeletionPlanSummaryEntity = $this->aSummary[$sClass];
$oDeletionPlanSummaryEntity->iDeleteCount++;
}
public function SetIssue(string $sClass): void
{
DataFeatureRemovalLog::Info('Issue on object', null, ['class' => $sClass]);
if (!array_key_exists($sClass, $this->aSummary)) {
$this->aSummary[$sClass] = new DataCleanupSummaryEntity($sClass);
}
$oDeletionPlanSummaryEntity = $this->aSummary[$sClass];
$oDeletionPlanSummaryEntity->iIssueCount++;
}
public function GetSummary(): array
{
return $this->aSummary;
}
}

View File

@@ -0,0 +1,21 @@
<?php
/*
* @copyright Copyright (C) 2010-2026 Combodo SAS
* @license http://opensource.org/licenses/AGPL-3.0
*/
namespace Combodo\iTop\DataFeatureRemoval\Service;
use DBObject;
interface iObjectService
{
public function Update(DBObject $oToUpdate, string $sAttCode, $value): void;
public function Delete(string $sClass, string $sId): void;
public function SetIssue(string $sClass): void;
public function GetSummary(): array;
}

View File

@@ -17,7 +17,7 @@
{% EndUIToolbar %} {% EndUIToolbar %}
{% EndUIForm %} {% EndUIForm %}
{% else %} {% else %}
{{ 'DataFeatureRemoval:DeletionPlan:ToManyOperations'|dict_s }} {{ 'DataFeatureRemoval:DeletionPlan:Error:Issues'|dict_s }}
{% endif %} {% endif %}
{% UIForm Standard {} %} {% UIForm Standard {} %}

View File

@@ -0,0 +1,396 @@
<?php
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer;
use Composer\Autoload\ClassLoader;
use Composer\Semver\VersionParser;
/**
* This class is copied in every Composer installed project and available to all
*
* See also https://getcomposer.org/doc/07-runtime.md#installed-versions
*
* To require its presence, you can require `composer-runtime-api ^2.0`
*
* @final
*/
class InstalledVersions
{
/**
* @var string|null if set (by reflection by Composer), this should be set to the path where this class is being copied to
* @internal
*/
private static $selfDir = null;
/**
* @var mixed[]|null
* @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
*/
private static $installed;
/**
* @var bool
*/
private static $installedIsLocalDir;
/**
* @var bool|null
*/
private static $canGetVendors;
/**
* @var array[]
* @psalm-var array<string, array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
private static $installedByVendor = array();
/**
* Returns a list of all package names which are present, either by being installed, replaced or provided
*
* @return string[]
* @psalm-return list<string>
*/
public static function getInstalledPackages()
{
$packages = array();
foreach (self::getInstalled() as $installed) {
$packages[] = array_keys($installed['versions']);
}
if (1 === \count($packages)) {
return $packages[0];
}
return array_keys(array_flip(\call_user_func_array('array_merge', $packages)));
}
/**
* Returns a list of all package names with a specific type e.g. 'library'
*
* @param string $type
* @return string[]
* @psalm-return list<string>
*/
public static function getInstalledPackagesByType($type)
{
$packagesByType = array();
foreach (self::getInstalled() as $installed) {
foreach ($installed['versions'] as $name => $package) {
if (isset($package['type']) && $package['type'] === $type) {
$packagesByType[] = $name;
}
}
}
return $packagesByType;
}
/**
* Checks whether the given package is installed
*
* This also returns true if the package name is provided or replaced by another package
*
* @param string $packageName
* @param bool $includeDevRequirements
* @return bool
*/
public static function isInstalled($packageName, $includeDevRequirements = true)
{
foreach (self::getInstalled() as $installed) {
if (isset($installed['versions'][$packageName])) {
return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false;
}
}
return false;
}
/**
* Checks whether the given package satisfies a version constraint
*
* e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call:
*
* Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3')
*
* @param VersionParser $parser Install composer/semver to have access to this class and functionality
* @param string $packageName
* @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package
* @return bool
*/
public static function satisfies(VersionParser $parser, $packageName, $constraint)
{
$constraint = $parser->parseConstraints((string) $constraint);
$provided = $parser->parseConstraints(self::getVersionRanges($packageName));
return $provided->matches($constraint);
}
/**
* Returns a version constraint representing all the range(s) which are installed for a given package
*
* It is easier to use this via isInstalled() with the $constraint argument if you need to check
* whether a given version of a package is installed, and not just whether it exists
*
* @param string $packageName
* @return string Version constraint usable with composer/semver
*/
public static function getVersionRanges($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
$ranges = array();
if (isset($installed['versions'][$packageName]['pretty_version'])) {
$ranges[] = $installed['versions'][$packageName]['pretty_version'];
}
if (array_key_exists('aliases', $installed['versions'][$packageName])) {
$ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']);
}
if (array_key_exists('replaced', $installed['versions'][$packageName])) {
$ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']);
}
if (array_key_exists('provided', $installed['versions'][$packageName])) {
$ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']);
}
return implode(' || ', $ranges);
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
*/
public static function getVersion($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
if (!isset($installed['versions'][$packageName]['version'])) {
return null;
}
return $installed['versions'][$packageName]['version'];
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
*/
public static function getPrettyVersion($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
if (!isset($installed['versions'][$packageName]['pretty_version'])) {
return null;
}
return $installed['versions'][$packageName]['pretty_version'];
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference
*/
public static function getReference($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
if (!isset($installed['versions'][$packageName]['reference'])) {
return null;
}
return $installed['versions'][$packageName]['reference'];
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path.
*/
public static function getInstallPath($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null;
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @return array
* @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}
*/
public static function getRootPackage()
{
$installed = self::getInstalled();
return $installed[0]['root'];
}
/**
* Returns the raw installed.php data for custom implementations
*
* @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect.
* @return array[]
* @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}
*/
public static function getRawData()
{
@trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED);
if (null === self::$installed) {
// only require the installed.php file if this file is loaded from its dumped location,
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
if (substr(__DIR__, -8, 1) !== 'C') {
self::$installed = include __DIR__ . '/installed.php';
} else {
self::$installed = array();
}
}
return self::$installed;
}
/**
* Returns the raw data of all installed.php which are currently loaded for custom implementations
*
* @return array[]
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
public static function getAllRawData()
{
return self::getInstalled();
}
/**
* Lets you reload the static array from another file
*
* This is only useful for complex integrations in which a project needs to use
* this class but then also needs to execute another project's autoloader in process,
* and wants to ensure both projects have access to their version of installed.php.
*
* A typical case would be PHPUnit, where it would need to make sure it reads all
* the data it needs from this class, then call reload() with
* `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure
* the project in which it runs can then also use this class safely, without
* interference between PHPUnit's dependencies and the project's dependencies.
*
* @param array[] $data A vendor/composer/installed.php data set
* @return void
*
* @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $data
*/
public static function reload($data)
{
self::$installed = $data;
self::$installedByVendor = array();
// when using reload, we disable the duplicate protection to ensure that self::$installed data is
// always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not,
// so we have to assume it does not, and that may result in duplicate data being returned when listing
// all installed packages for example
self::$installedIsLocalDir = false;
}
/**
* @return string
*/
private static function getSelfDir()
{
if (self::$selfDir === null) {
self::$selfDir = strtr(__DIR__, '\\', '/');
}
return self::$selfDir;
}
/**
* @return array[]
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
private static function getInstalled()
{
if (null === self::$canGetVendors) {
self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders');
}
$installed = array();
$copiedLocalDir = false;
if (self::$canGetVendors) {
$selfDir = self::getSelfDir();
foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
$vendorDir = strtr($vendorDir, '\\', '/');
if (isset(self::$installedByVendor[$vendorDir])) {
$installed[] = self::$installedByVendor[$vendorDir];
} elseif (is_file($vendorDir.'/composer/installed.php')) {
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
$required = require $vendorDir.'/composer/installed.php';
self::$installedByVendor[$vendorDir] = $required;
$installed[] = $required;
if (self::$installed === null && $vendorDir.'/composer' === $selfDir) {
self::$installed = $required;
self::$installedIsLocalDir = true;
}
}
if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) {
$copiedLocalDir = true;
}
}
}
if (null === self::$installed) {
// only require the installed.php file if this file is loaded from its dumped location,
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
if (substr(__DIR__, -8, 1) !== 'C') {
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
$required = require __DIR__ . '/installed.php';
self::$installed = $required;
} else {
self::$installed = array();
}
}
if (self::$installed !== array() && !$copiedLocalDir) {
$installed[] = self::$installed;
}
return $installed;
}
}

View File

@@ -7,12 +7,15 @@ $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\\DeletionPlanSummaryEntity' => $baseDir . '/src/Entity/DeletionPlanSummaryEntity.php', 'Combodo\\iTop\\DataFeatureRemoval\\Entity\\DataCleanupSummaryEntity' => $baseDir . '/src/Entity/DataCleanupSummaryEntity.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',
'Combodo\\iTop\\DataFeatureRemoval\\Helper\\DataFeatureRemovalLog' => $baseDir . '/src/Helper/DataFeatureRemovalLog.php', 'Combodo\\iTop\\DataFeatureRemoval\\Helper\\DataFeatureRemovalLog' => $baseDir . '/src/Helper/DataFeatureRemovalLog.php',
'Combodo\\iTop\\DataFeatureRemoval\\Service\\DataCleanupService' => $baseDir . '/src/Service/DataCleanupService.php',
'Combodo\\iTop\\DataFeatureRemoval\\Service\\DataFeatureRemoverExtensionService' => $baseDir . '/src/Service/DataFeatureRemoverExtensionService.php', 'Combodo\\iTop\\DataFeatureRemoval\\Service\\DataFeatureRemoverExtensionService' => $baseDir . '/src/Service/DataFeatureRemoverExtensionService.php',
'Combodo\\iTop\\DataFeatureRemoval\\Service\\DeletionPlanService' => $baseDir . '/src/Service/DeletionPlanService.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\\iObjectService' => $baseDir . '/src/Service/iObjectService.php',
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php', 'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
); );

View File

@@ -7,5 +7,4 @@ $baseDir = dirname($vendorDir);
return array( return array(
'Combodo\\iTop\\DataFeatureRemoval\\' => array($baseDir . '/src'), 'Combodo\\iTop\\DataFeatureRemoval\\' => array($baseDir . '/src'),
'' => array($baseDir . '/src/NoNamespace'),
); );

View File

@@ -20,19 +20,18 @@ class ComposerStaticInit4f96a7199e2c0d90e547333758b26464
), ),
); );
public static $fallbackDirsPsr4 = array (
0 => __DIR__ . '/../..' . '/src/NoNamespace',
);
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\\DeletionPlanSummaryEntity' => __DIR__ . '/../..' . '/src/Entity/DeletionPlanSummaryEntity.php', 'Combodo\\iTop\\DataFeatureRemoval\\Entity\\DataCleanupSummaryEntity' => __DIR__ . '/../..' . '/src/Entity/DataCleanupSummaryEntity.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',
'Combodo\\iTop\\DataFeatureRemoval\\Helper\\DataFeatureRemovalLog' => __DIR__ . '/../..' . '/src/Helper/DataFeatureRemovalLog.php', 'Combodo\\iTop\\DataFeatureRemoval\\Helper\\DataFeatureRemovalLog' => __DIR__ . '/../..' . '/src/Helper/DataFeatureRemovalLog.php',
'Combodo\\iTop\\DataFeatureRemoval\\Service\\DataCleanupService' => __DIR__ . '/../..' . '/src/Service/DataCleanupService.php',
'Combodo\\iTop\\DataFeatureRemoval\\Service\\DataFeatureRemoverExtensionService' => __DIR__ . '/../..' . '/src/Service/DataFeatureRemoverExtensionService.php', 'Combodo\\iTop\\DataFeatureRemoval\\Service\\DataFeatureRemoverExtensionService' => __DIR__ . '/../..' . '/src/Service/DataFeatureRemoverExtensionService.php',
'Combodo\\iTop\\DataFeatureRemoval\\Service\\DeletionPlanService' => __DIR__ . '/../..' . '/src/Service/DeletionPlanService.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\\iObjectService' => __DIR__ . '/../..' . '/src/Service/iObjectService.php',
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
); );
@@ -41,7 +40,6 @@ class ComposerStaticInit4f96a7199e2c0d90e547333758b26464
return \Closure::bind(function () use ($loader) { return \Closure::bind(function () use ($loader) {
$loader->prefixLengthsPsr4 = ComposerStaticInit4f96a7199e2c0d90e547333758b26464::$prefixLengthsPsr4; $loader->prefixLengthsPsr4 = ComposerStaticInit4f96a7199e2c0d90e547333758b26464::$prefixLengthsPsr4;
$loader->prefixDirsPsr4 = ComposerStaticInit4f96a7199e2c0d90e547333758b26464::$prefixDirsPsr4; $loader->prefixDirsPsr4 = ComposerStaticInit4f96a7199e2c0d90e547333758b26464::$prefixDirsPsr4;
$loader->fallbackDirsPsr4 = ComposerStaticInit4f96a7199e2c0d90e547333758b26464::$fallbackDirsPsr4;
$loader->classMap = ComposerStaticInit4f96a7199e2c0d90e547333758b26464::$classMap; $loader->classMap = ComposerStaticInit4f96a7199e2c0d90e547333758b26464::$classMap;
}, null, ClassLoader::class); }, null, ClassLoader::class);

View File

@@ -0,0 +1,5 @@
{
"packages": [],
"dev": true,
"dev-package-names": []
}

View File

@@ -0,0 +1,23 @@
<?php return array(
'root' => array(
'name' => 'combodo/combodo-data-feature-removal',
'pretty_version' => 'dev-develop',
'version' => 'dev-develop',
'reference' => '19bbf6759bb4f6f5814d9ec1b0b5514208efc0b2',
'type' => 'itop-extension',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
'dev' => true,
),
'versions' => array(
'combodo/combodo-data-feature-removal' => array(
'pretty_version' => 'dev-develop',
'version' => 'dev-develop',
'reference' => '19bbf6759bb4f6f5814d9ec1b0b5514208efc0b2',
'type' => 'itop-extension',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
'dev_requirement' => false,
),
),
);

View File

@@ -14,10 +14,7 @@ if (PHP_VERSION_ID < 50600) {
echo $err; echo $err;
} }
} }
trigger_error( throw new RuntimeException($err);
$err,
E_USER_ERROR
);
} }
require_once __DIR__ . '/composer/autoload_real.php'; require_once __DIR__ . '/composer/autoload_real.php';

View File

@@ -625,6 +625,7 @@ return array(
'Combodo\\iTop\\Service\\Events\\iEventServiceSetup' => $baseDir . '/sources/Service/Events/iEventServiceSetup.php', 'Combodo\\iTop\\Service\\Events\\iEventServiceSetup' => $baseDir . '/sources/Service/Events/iEventServiceSetup.php',
'Combodo\\iTop\\Service\\Import\\CSVImportPageProcessor' => $baseDir . '/sources/Service/Import/CSVImportPageProcessor.php', 'Combodo\\iTop\\Service\\Import\\CSVImportPageProcessor' => $baseDir . '/sources/Service/Import/CSVImportPageProcessor.php',
'Combodo\\iTop\\Service\\InterfaceDiscovery\\InterfaceDiscovery' => $baseDir . '/sources/Service/InterfaceDiscovery/InterfaceDiscovery.php', 'Combodo\\iTop\\Service\\InterfaceDiscovery\\InterfaceDiscovery' => $baseDir . '/sources/Service/InterfaceDiscovery/InterfaceDiscovery.php',
'Combodo\\iTop\\Service\\Limits\\ExecutionLimits' => $baseDir . '/sources/Service/Limits/ExecutionLimits.php',
'Combodo\\iTop\\Service\\Links\\LinkSetDataTransformer' => $baseDir . '/sources/Service/Links/LinkSetDataTransformer.php', 'Combodo\\iTop\\Service\\Links\\LinkSetDataTransformer' => $baseDir . '/sources/Service/Links/LinkSetDataTransformer.php',
'Combodo\\iTop\\Service\\Links\\LinkSetModel' => $baseDir . '/sources/Service/Links/LinkSetModel.php', 'Combodo\\iTop\\Service\\Links\\LinkSetModel' => $baseDir . '/sources/Service/Links/LinkSetModel.php',
'Combodo\\iTop\\Service\\Links\\LinkSetRepository' => $baseDir . '/sources/Service/Links/LinkSetRepository.php', 'Combodo\\iTop\\Service\\Links\\LinkSetRepository' => $baseDir . '/sources/Service/Links/LinkSetRepository.php',

View File

@@ -65,7 +65,7 @@ return array(
'Psr\\Cache\\' => array($vendorDir . '/psr/cache/src'), 'Psr\\Cache\\' => array($vendorDir . '/psr/cache/src'),
'PhpParser\\' => array($vendorDir . '/nikic/php-parser/lib/PhpParser'), 'PhpParser\\' => array($vendorDir . '/nikic/php-parser/lib/PhpParser'),
'Pelago\\Emogrifier\\' => array($vendorDir . '/pelago/emogrifier/src'), 'Pelago\\Emogrifier\\' => array($vendorDir . '/pelago/emogrifier/src'),
'League\\OAuth2\\Client\\' => array($vendorDir . '/league/oauth2-client/src', $vendorDir . '/league/oauth2-google/src'), 'League\\OAuth2\\Client\\' => array($vendorDir . '/league/oauth2-google/src', $vendorDir . '/league/oauth2-client/src'),
'GuzzleHttp\\Psr7\\' => array($vendorDir . '/guzzlehttp/psr7/src'), 'GuzzleHttp\\Psr7\\' => array($vendorDir . '/guzzlehttp/psr7/src'),
'GuzzleHttp\\Promise\\' => array($vendorDir . '/guzzlehttp/promises/src'), 'GuzzleHttp\\Promise\\' => array($vendorDir . '/guzzlehttp/promises/src'),
'GuzzleHttp\\' => array($vendorDir . '/guzzlehttp/guzzle/src'), 'GuzzleHttp\\' => array($vendorDir . '/guzzlehttp/guzzle/src'),

View File

@@ -360,8 +360,8 @@ class ComposerStaticInitfc0e9e9dea11dcbb6272414776c30685
), ),
'League\\OAuth2\\Client\\' => 'League\\OAuth2\\Client\\' =>
array ( array (
0 => __DIR__ . '/..' . '/league/oauth2-client/src', 0 => __DIR__ . '/..' . '/league/oauth2-google/src',
1 => __DIR__ . '/..' . '/league/oauth2-google/src', 1 => __DIR__ . '/..' . '/league/oauth2-client/src',
), ),
'GuzzleHttp\\Psr7\\' => 'GuzzleHttp\\Psr7\\' =>
array ( array (
@@ -1026,6 +1026,7 @@ class ComposerStaticInitfc0e9e9dea11dcbb6272414776c30685
'Combodo\\iTop\\Service\\Events\\iEventServiceSetup' => __DIR__ . '/../..' . '/sources/Service/Events/iEventServiceSetup.php', 'Combodo\\iTop\\Service\\Events\\iEventServiceSetup' => __DIR__ . '/../..' . '/sources/Service/Events/iEventServiceSetup.php',
'Combodo\\iTop\\Service\\Import\\CSVImportPageProcessor' => __DIR__ . '/../..' . '/sources/Service/Import/CSVImportPageProcessor.php', 'Combodo\\iTop\\Service\\Import\\CSVImportPageProcessor' => __DIR__ . '/../..' . '/sources/Service/Import/CSVImportPageProcessor.php',
'Combodo\\iTop\\Service\\InterfaceDiscovery\\InterfaceDiscovery' => __DIR__ . '/../..' . '/sources/Service/InterfaceDiscovery/InterfaceDiscovery.php', 'Combodo\\iTop\\Service\\InterfaceDiscovery\\InterfaceDiscovery' => __DIR__ . '/../..' . '/sources/Service/InterfaceDiscovery/InterfaceDiscovery.php',
'Combodo\\iTop\\Service\\Limits\\ExecutionLimits' => __DIR__ . '/../..' . '/sources/Service/Limits/ExecutionLimits.php',
'Combodo\\iTop\\Service\\Links\\LinkSetDataTransformer' => __DIR__ . '/../..' . '/sources/Service/Links/LinkSetDataTransformer.php', 'Combodo\\iTop\\Service\\Links\\LinkSetDataTransformer' => __DIR__ . '/../..' . '/sources/Service/Links/LinkSetDataTransformer.php',
'Combodo\\iTop\\Service\\Links\\LinkSetModel' => __DIR__ . '/../..' . '/sources/Service/Links/LinkSetModel.php', 'Combodo\\iTop\\Service\\Links\\LinkSetModel' => __DIR__ . '/../..' . '/sources/Service/Links/LinkSetModel.php',
'Combodo\\iTop\\Service\\Links\\LinkSetRepository' => __DIR__ . '/../..' . '/sources/Service/Links/LinkSetRepository.php', 'Combodo\\iTop\\Service\\Links\\LinkSetRepository' => __DIR__ . '/../..' . '/sources/Service/Links/LinkSetRepository.php',

View File

@@ -36,8 +36,7 @@ if ($issues) {
echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL; echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL;
} }
} }
trigger_error( throw new \RuntimeException(
'Composer detected issues in your platform: ' . implode(' ', $issues), 'Composer detected issues in your platform: ' . implode(' ', $issues)
E_USER_ERROR
); );
} }

View File

@@ -312,9 +312,6 @@ if ($bInstall) {
} else { } else {
echo "\nFailed to record designer updates(".$oMysqli->error.").\n"; echo "\nFailed to record designer updates(".$oMysqli->error.").\n";
} }
} else {
echo "\nFailed to read the revision from $sDeltaFile file. No designer update information will be recorded.\n";
} }
} }
} }
@@ -349,8 +346,7 @@ if (! $bFoundIssues) {
$sLogMsg = "installed!"; $sLogMsg = "installed!";
if ($bUseItopConfig && is_file("$sConfigFile.backup")) { if ($bUseItopConfig && is_file("$sConfigFile.backup")) {
echo "\nuse config file provided by backup in $sConfigFile."; unlink("$sConfigFile.backup");
copy("$sConfigFile.backup", $sConfigFile);
} }
SetupLog::Info($sLogMsg); SetupLog::Info($sLogMsg);

View File

@@ -18,7 +18,6 @@
* You should have received a copy of the GNU Affero General Public License * You should have received a copy of the GNU Affero General Public License
*/ */
use Combodo\iTop\Application\WebPage\WebPage;
use Combodo\iTop\PhpParser\Evaluation\PhpExpressionEvaluator; use Combodo\iTop\PhpParser\Evaluation\PhpExpressionEvaluator;
use Combodo\iTop\Setup\ModuleDiscovery\ModuleFileReader; use Combodo\iTop\Setup\ModuleDiscovery\ModuleFileReader;
use Combodo\iTop\Setup\ModuleDiscovery\ModuleFileReaderException; use Combodo\iTop\Setup\ModuleDiscovery\ModuleFileReaderException;
@@ -509,7 +508,7 @@ EOF
$sDisplayChoices .= '<li>'.$aChoice['title'].'</li>'; $sDisplayChoices .= '<li>'.$aChoice['title'].'</li>';
if (isset($aChoice['modules'])) { if (isset($aChoice['modules'])) {
if (count($aChoice['modules']) === 0) { if (count($aChoice['modules']) === 0) {
throw new Exception('Extension '.$aChoice['extension_code'].' does not have any module associated'); //throw new Exception('Extension '.$aChoice['extension_code'].' does not have any module associated');
} }
foreach ($aChoice['modules'] as $sModuleId) { foreach ($aChoice['modules'] as $sModuleId) {
$bSelected = true; $bSelected = true;

View File

@@ -0,0 +1,49 @@
<?php
/*
* @copyright Copyright (C) 2010-2026 Combodo SAS
* @license http://opensource.org/licenses/AGPL-3.0
*/
namespace Combodo\iTop\Service\Limits;
use utils;
class ExecutionLimits
{
private int $iMaxTime;
private int $iMaxMemoryPercent;
/**
* @param int $iMaxDuration Max duration time in s
* @param int $iMaxMemoryPercent Max memory percent allowed (0-100)
*/
public function __construct(int $iMaxDuration = 0, int $iMaxMemoryPercent = 100)
{
public function __construct(int $iMaxDuration = 0, int $iMaxMemoryPercent = 100)
{
$this->iMaxTime = ($iMaxDuration > 0) ? ($iMaxDuration + time()) : 0;
$this->iMaxMemoryPercent = (int)min(max($iMaxMemoryPercent, 0), 100);
}
/**
* @return bool true when duration is
*/
public function ShouldStopExecution(): bool
{
if (($this->iMaxTime != 0) && (time() > $this->iMaxTime)) {
\IssueLog::Debug(__METHOD__.' timeout '.time()." (current) > $this->iMaxTime (max)");
return true;
}
$iMaxMemoryPercent = (int)min(max($this->iMaxMemoryPercent, 0), 100);
$iMemory = memory_get_usage(true);
$iMaxMemory = utils::GetMemoryLimit() * $iMaxMemoryPercent / 100;
if ($iMemory > $iMaxMemory) {
\IssueLog::Debug(__METHOD__." Memory limit $iMemory (current) > $iMaxMemory (max)");
return true;
}
return false;
}
}

View File

@@ -36,6 +36,6 @@ parameters:
- ../../../env-php-unit-tests (?) # Irrelevant as it will either already be in `env-production` or might be desynchronized from `env-production` - ../../../env-php-unit-tests (?) # Irrelevant as it will either already be in `env-production` or might be desynchronized from `env-production`
- ../../../env-toolkit (?) # Irrelevent as it will either already be in `env-production` or might be desynchronized from `env-production` (for local run only, not useful in the CI) - ../../../env-toolkit (?) # Irrelevent as it will either already be in `env-production` or might be desynchronized from `env-production` (for local run only, not useful in the CI)
- ../../../tests (?) # Exclude tests for now #- ../../../tests (?) # Exclude tests for now
- ../../../toolkit (?) # Exclude toolkit for now - ../../../toolkit (?) # Exclude toolkit for now
- ../../../setup/compat # Exclude fake DOMDocument & DOMElement declarations - ../../../setup/compat # Exclude fake DOMDocument & DOMElement declarations

View File

@@ -0,0 +1,301 @@
<?php
/*
* @copyright Copyright (C) 2010-2026 Combodo SAS
* @license http://opensource.org/licenses/AGPL-3.0
*/
namespace Combodo\iTop\Test\UnitTest\Module\DataFeatureRemoval\Service;
use Cleanup;
use Combodo\iTop\DataFeatureRemoval\Entity\DataCleanupSummaryEntity;
use Combodo\iTop\DataFeatureRemoval\Helper\DataFeatureRemovalException;
use Combodo\iTop\DataFeatureRemoval\Service\DataCleanupService;
use Combodo\iTop\Test\UnitTest\ItopCustomDatamodelTestCase;
use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
use PHPUnit\Framework\MockObject\MockObject;
/**
* Unit tests for the CleanupService cf the Combodo Data Feature Removal module.
*
* These tests cover:
* - GetCleanupSummary method: handling null and empty input, and verifying summary output for various delete/update scenarios.
* - ExecuteCleanup method: confirming that an exception is thrown when issues are detected in the deletion plan.
*
* Key aspects tested:
* - Consistent singleton instance management.
* - Accurate summary generation for deletion and update operations, including mode and issue reporting per class.
* - Edge cases such as null, empty, and multiple classes.
* - Proper exception handling when the deletion plan contains issues.
*
* The tests use PHPUnit, mocks for Cleanup and CleanupService, and data providers to cover multiple scenarios.
*
* @see DataCleanupService
* @see DataCleanupSummaryEntity
* @see ItopDataTestCase
*/
class DataCleanupServiceTest extends ItopCustomDatamodelTestCase
{
private ExecutionLimits&MockObject $oExecutionLimits;
public function GetDatamodelDeltaAbsPath(): string
{
return __DIR__.'/data_cleanup_delta.xml';
}
//--- GetCleanupSummary tests ---
/**
* Tests that GetCleanupSummary returns an empty array when passed null as input.
*/
public function testGetCleanupSummaryReturnsEmptyArrayWhenNull(): void
{
$oService = new DataCleanupService();
$aResult = $oService->GetCleanupSummary(null);
$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.');
}
//--- ExecuteCleanup tests ---
public function testExecuteCleanup_DeleteOneObjPerClassWithoutLimit()
{
$this->GivenDFRTreeInDB(<<<EOF
DFRToRemoveLeaf_1 <- DFRToUpdate_1
DFRToRemoveLeaf_1 <- DFRRemovedCollateral_1
DFRRemovedCollateral_1 <- DFRRemovedCollateralCascade_1
EOF);
$aClasses = [ 'DFRToRemoveLeaf' ];
$oService = new DataCleanupService();
$aRes = $oService->ExecuteCleanup($aClasses);
$aExpected = [
['DFRToUpdate', 1, 0 ],
['DFRToRemoveLeaf', 0, 1 ],
['DFRRemovedCollateral', 0, 1 ],
['DFRRemovedCollateralCascade', 0, 1 ],
];
$this->AssertSummaryEquals($aExpected, $aRes);
}
public function testExecuteCleanup_DeleteManyObjPerClassWithoutLimit()
{
$this->GivenDFRTreeInDB(<<<EOF
DFRToRemoveLeaf_1 <- DFRToUpdate_1
DFRToRemoveLeaf_1 <- DFRRemovedCollateral_1
DFRRemovedCollateral_1 <- DFRRemovedCollateralCascade_1
DFRToRemoveLeaf_2 <- DFRToUpdate_2
DFRToRemoveLeaf_2 <- DFRRemovedCollateral_2
DFRRemovedCollateral_2 <- DFRRemovedCollateralCascade_2
DFRToRemoveLeaf_3 <- DFRToUpdate_3
DFRToRemoveLeaf_3 <- DFRRemovedCollateral_3
DFRRemovedCollateral_3 <- DFRRemovedCollateralCascade_3
EOF);
$aClasses = [ 'DFRToRemoveLeaf' ];
$oService = new DataCleanupService();
$aRes = $oService->ExecuteCleanup($aClasses);
$aExpected = [
['DFRToUpdate', 3, 0 ],
['DFRToRemoveLeaf', 0, 3 ],
['DFRRemovedCollateral', 0, 3 ],
['DFRRemovedCollateralCascade', 0, 3 ],
];
$this->AssertSummaryEquals($aExpected, $aRes);
}
public function testGetCleanupSummary_DeleteManyObjPerClassWithoutLimit()
{
$this->GivenDFRTreeInDB(<<<EOF
DFRToRemoveLeaf_1 <- DFRToUpdate_1
DFRToRemoveLeaf_1 <- DFRRemovedCollateral_1
DFRRemovedCollateral_1 <- DFRRemovedCollateralCascade_1
DFRToRemoveLeaf_2 <- DFRToUpdate_2
DFRToRemoveLeaf_2 <- DFRRemovedCollateral_2
DFRRemovedCollateral_2 <- DFRRemovedCollateralCascade_2
DFRToRemoveLeaf_3 <- DFRToUpdate_3
DFRToRemoveLeaf_3 <- DFRRemovedCollateral_3
DFRRemovedCollateral_3 <- DFRRemovedCollateralCascade_3
EOF);
$aClasses = [ 'DFRToRemoveLeaf' ];
$oService = new DataCleanupService();
$aRes = $oService->GetCleanupSummary($aClasses);
$aExpected = [
['DFRToUpdate', 3, 0 ],
['DFRToRemoveLeaf', 0, 3 ],
['DFRRemovedCollateral', 0, 3 ],
['DFRRemovedCollateralCascade', 0, 3 ],
];
$this->AssertSummaryEquals($aExpected, $aRes);
}
public function testExecuteCleanup_ManualDeleteShouldFail()
{
$this->GivenDFRTreeInDB(<<<EOF
DFRToRemoveLeaf_1 <- DFRManual_1
EOF);
$aClasses = [ 'DFRToRemoveLeaf' ];
$this->expectException(DataFeatureRemovalException::class);
$this->expectExceptionMessage('Deletion Plan cannot be executed due to issues');
$oService = new DataCleanupService();
$oService->ExecuteCleanup($aClasses);
}
public function testGetCleanupSummary_ManualDeleteShouldFail()
{
$this->GivenDFRTreeInDB(<<<EOF
DFRToRemoveLeaf_1 <- DFRManual_1
DFRToRemoveLeaf_1 <- DFRManual_2
DFRToRemoveLeaf_2 <- DFRManual_3
EOF);
$aClasses = [ 'DFRToRemoveLeaf' ];
$oService = new DataCleanupService();
$aRes = $oService->GetCleanupSummary($aClasses);
$aExpected = [
['DFRManual', 0, 0, 3 ],
['DFRToRemoveLeaf', 0, 2],
];
$this->AssertSummaryEquals($aExpected, $aRes);
}
private function AssertSummaryEquals(array $expected, $actual, $sMessage = '')
{
$aExpected = [];
foreach ($expected as $line) {
$sClass = $line[0];
$iUpdate = $line[1];
$iDelete = $line[2];
$iIssue = $line[3] ?? 0;
$oCleanupSummaryEntity = new DataCleanupSummaryEntity($sClass);
$oCleanupSummaryEntity->iUpdateCount = $iUpdate;
$oCleanupSummaryEntity->iDeleteCount = $iDelete;
$oCleanupSummaryEntity->iIssueCount = $iIssue;
$aExpected[$sClass] = $oCleanupSummaryEntity;
}
$this->assertEquals($aExpected, $actual, $sMessage);
}
public static function ExecuteCleanup_StopInProcessKeepDatabaseOk(): array
{
return [
'Stop after 1' => [
1,
[
['DFRToUpdate', 1, 0],
],
],
'Stop after 2' => [
2,
[
['DFRToUpdate', 1, 0],
['DFRRemovedCollateralCascade', 0, 1],
],
],
'Stop after 3' => [
3,
[
['DFRToUpdate', 1, 0],
['DFRRemovedCollateralCascade', 0, 1],
['DFRRemovedCollateral', 0, 1],
],
],
'Stop after 4' => [
4,
[
['DFRToUpdate', 1, 0],
['DFRRemovedCollateralCascade', 0, 1],
['DFRRemovedCollateral', 0, 1],
['DFRToRemoveLeaf', 0, 1],
],
],
'Stop after 5' => [
5,
[
['DFRToUpdate', 2, 0],
['DFRRemovedCollateralCascade', 0, 1],
['DFRRemovedCollateral', 0, 1],
['DFRToRemoveLeaf', 0, 1],
],
],
];
}
/**
* @dataProvider ExecuteCleanup_StopInProcessKeepDatabaseOk
*/
public function testExecuteCleanup_StopInProcessKeepDatabaseOk(int $iExecutionCount, array $aExpected): void
{
$this->GivenDFRTreeInDB(<<<EOF
DFRToRemoveLeaf_1 <- DFRToUpdate_1
DFRToRemoveLeaf_1 <- DFRRemovedCollateral_1
DFRRemovedCollateral_1 <- DFRRemovedCollateralCascade_1
DFRToRemoveLeaf_2 <- DFRToUpdate_2
DFRToRemoveLeaf_2 <- DFRRemovedCollateral_2
DFRRemovedCollateral_2 <- DFRRemovedCollateralCascade_2
DFRToRemoveLeaf_3 <- DFRToUpdate_3
DFRToRemoveLeaf_3 <- DFRRemovedCollateral_3
DFRRemovedCollateral_3 <- DFRRemovedCollateralCascade_3
EOF);
$aClasses = [ 'DFRToRemoveLeaf' ];
$oDeletionPlaService = new DataCleanupService();
$this->GivenExecutionLimits($iExecutionCount);
$this->SetNonPublicProperty($oDeletionPlaService, 'oExecutionLimits', $this->oExecutionLimits);
$aRes = $oDeletionPlaService->ExecuteCleanup($aClasses);
$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)
{
list($sLeft, $sRight) = explode('<-', $sLine);
$sLeft = trim($sLeft);
$iLeftId = $this->aIdByObjectName[$sLeft] ?? 0;
if ($iLeftId === 0) {
list($sChildClass, ) = explode('_', $sLeft, 2);
$iLeftId = $this->GivenObjectInDB($sChildClass, ['name' => $sLeft]);
$this->aIdByObjectName[$sLeft] = $iLeftId;
}
$sRight = trim($sRight);
list($sChildClass, ) = explode('_', $sRight, 2);
$iRightId = $this->GivenObjectInDB($sChildClass, ['name' => $sRight, 'extkey_id' => $iLeftId]);
$this->aIdByObjectName[$sRight] = $iRightId;
}
private function GivenExecutionLimits(int $iStopAfterCallNumberReached): void
{
$matcher = $this->any();
$this->oExecutionLimits = $this->createMock(ExecutionLimits::class);
$this->oExecutionLimits->expects($matcher)
->method('ShouldStopExecution')->willReturnCallback(function () use ($matcher, $iStopAfterCallNumberReached) {
$invocationCount = $matcher->getInvocationCount();
return ($invocationCount >= $iStopAfterCallNumberReached);
});
}
}

View File

@@ -1,277 +0,0 @@
<?php
/*
* @copyright Copyright (C) 2010-2026 Combodo SAS
* @license http://opensource.org/licenses/AGPL-3.0
*/
namespace Combodo\iTop\Test\UnitTest\Module\DataFeatureRemoval\Service;
use Combodo\iTop\DataFeatureRemoval\Entity\DeletionPlanSummaryEntity;
use Combodo\iTop\DataFeatureRemoval\Helper\DataFeatureRemovalException;
use Combodo\iTop\DataFeatureRemoval\Service\DeletionPlanService;
use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
use DeletionPlan;
/**
* Unit tests for the DeletionPlanService class in the Combodo Data Feature Removal module.
*
* These tests cover:
* - GetDeletionPlanSummary method: handling null and empty input, and verifying summary output for various delete/update scenarios.
* - ExecuteDeletionPlan method: confirming that an exception is thrown when issues are detected in the deletion plan.
*
* Key aspects tested:
* - Consistent singleton instance management.
* - Accurate summary generation for deletion and update operations, including mode and issue reporting per class.
* - Edge cases such as null, empty, and multiple classes.
* - Proper exception handling when the deletion plan contains issues.
*
* The tests use PHPUnit, mocks for DeletionPlan and DeletionPlanService, and data providers to cover multiple scenarios.
*
* @see DeletionPlanService
* @see DeletionPlanSummaryEntity
* @see ItopDataTestCase
*/
class DeletionPlanServiceTest extends ItopDataTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->RequireOnceItopFile('env-production/combodo-data-feature-removal/vendor/autoload.php');
}
//--- GetDeletionPlanSummary tests ---
/**
* Tests that GetDeletionPlanSummary returns an empty array when passed null as input.
*/
public function testGetDeletionPlanSummaryReturnsEmptyArrayWhenNull(): void
{
$oService = DeletionPlanService::GetInstance();
$aResult = $oService->GetDeletionPlanSummary(null);
$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.');
}
/**
* Tests that GetDeletionPlanSummary returns an empty array when the input class list is empty.
*/
public function testGetDeletionPlanSummaryReturnsEmptyArrayWhenEmptyClasses(): void
{
$oMockService = $this->getMockBuilder(DeletionPlanService::class)
->disableOriginalConstructor()
->onlyMethods(['GetDeletionPlan'])
->getMock();
$oDeletionPlan = new DeletionPlan();
$oMockService->method('GetDeletionPlan')->willReturn($oDeletionPlan);
DeletionPlanService::SetInstance($oMockService);
$aResult = $oMockService->GetDeletionPlanSummary([]);
$this->assertIsArray($aResult, 'Expected result to be an array when input class list is empty.');
$this->assertEmpty($aResult, 'Expected result to be empty array when input class list is empty.');
}
/**
* Tests GetDeletionPlanSummary for various delete/update scenarios using a data provider.
* Verifies summary output for each class matches expected values.
*
* @dataProvider GetDeletionPlanSummaryWithDeletesProvider
*/
public function testGetDeletionPlanSummaryWithDeletes(array $aToDelete, array $aToUpdate, array $aExpected): void
{
$oDeletionPlan = $this->createMock(DeletionPlan::class);
$oDeletionPlan->method('ListDeletes')->willReturn($aToDelete);
$oDeletionPlan->method('ListUpdates')->willReturn($aToUpdate);
$oMockService = $this->getMockBuilder(DeletionPlanService::class)
->disableOriginalConstructor()
->onlyMethods(['GetDeletionPlan'])
->getMock();
$oMockService->method('GetDeletionPlan')->willReturn($oDeletionPlan);
$aResult = $oMockService->GetDeletionPlanSummary(['SomeClass']);
foreach ($aExpected as $sClass => $aExpectedValues) {
$this->assertArrayHasKey(
$sClass,
$aResult,
"Expected key '$sClass' to exist in summary."
);
$this->assertInstanceOf(
DeletionPlanSummaryEntity::class,
$aResult[$sClass],
"Expected summary for '$sClass' to be instance of DeletionPlanSummaryEntity."
);
$this->assertEquals(
$aExpectedValues['iDeleteCount'],
$aResult[$sClass]->iDeleteCount,
"Expected iDeleteCount for '$sClass' to be {$aExpectedValues['iDeleteCount']}, got {$aResult[$sClass]->iDeleteCount}."
);
$this->assertEquals(
$aExpectedValues['iUpdateCount'],
$aResult[$sClass]->iUpdateCount,
"Expected iUpdateCount for '$sClass' to be {$aExpectedValues['iUpdateCount']}, got {$aResult[$sClass]->iUpdateCount}."
);
$this->assertEquals(
$aExpectedValues['iMode'],
$aResult[$sClass]->iMode,
"Expected iMode for '$sClass' to be {$aExpectedValues['iMode']}, got {$aResult[$sClass]->iMode}."
);
$this->assertEquals(
$aExpectedValues['sIssue'],
$aResult[$sClass]->sIssue,
"Expected sIssue for '$sClass' to be '{$aExpectedValues['sIssue']}', got '{$aResult[$sClass]->sIssue}'."
);
}
}
/**
* Provides multiple scenarios for testGetDeletionPlanSummaryWithDeletes, including deletes, updates, issues, and multiple classes.
*
* @return array
*/
public function GetDeletionPlanSummaryWithDeletesProvider(): array
{
return [
'single class with deletes only' => [
'aToDelete' => [
'Server' => [
1 => ['to_delete' => null, 'mode' => DEL_AUTO, 'requested_explicitely' => true],
2 => ['to_delete' => null, 'mode' => DEL_SILENT, 'requested_explicitely' => false],
],
],
'aToUpdate' => [],
'aExpected' => [
'Server' => [
'iDeleteCount' => 2,
'iUpdateCount' => 0,
'iMode' => DEL_AUTO,
'sIssue' => null,
],
],
],
'single class with deletes and issue' => [
'aToDelete' => [
'Server' => [
1 => ['to_delete' => null, 'mode' => DEL_MANUAL, 'requested_explicitely' => false, 'issue' => 'Cannot delete'],
],
],
'aToUpdate' => [],
'aExpected' => [
'Server' => [
'iDeleteCount' => 1,
'iUpdateCount' => 0,
'iMode' => DEL_MANUAL,
'sIssue' => 'Cannot delete',
],
],
],
'single class with updates only' => [
'aToDelete' => [],
'aToUpdate' => [
'Person' => [
10 => ['to_reset' => null, 'attributes' => ['org_id' => null]],
11 => ['to_reset' => null, 'attributes' => ['org_id' => null]],
12 => ['to_reset' => null, 'attributes' => ['org_id' => null]],
],
],
'aExpected' => [
'Person' => [
'iDeleteCount' => 0,
'iUpdateCount' => 3,
'iMode' => 0,
'sIssue' => null,
],
],
],
'class with both deletes and updates' => [
'aToDelete' => [
'Server' => [
1 => ['to_delete' => null, 'mode' => DEL_AUTO, 'requested_explicitely' => true],
],
],
'aToUpdate' => [
'Server' => [
5 => ['to_reset' => null, 'attributes' => ['org_id' => null]],
],
],
'aExpected' => [
'Server' => [
'iDeleteCount' => 1,
'iUpdateCount' => 1,
'iMode' => DEL_AUTO,
'sIssue' => null,
],
],
],
'multiple classes' => [
'aToDelete' => [
'Server' => [
1 => ['to_delete' => null, 'mode' => DEL_AUTO, 'requested_explicitely' => true],
],
'NetworkDevice' => [
3 => ['to_delete' => null, 'mode' => DEL_SILENT, 'requested_explicitely' => false],
4 => ['to_delete' => null, 'mode' => DEL_SILENT, 'requested_explicitely' => false],
],
],
'aToUpdate' => [
'Person' => [
10 => ['to_reset' => null, 'attributes' => ['org_id' => null]],
],
],
'aExpected' => [
'Server' => [
'iDeleteCount' => 1,
'iUpdateCount' => 0,
'iMode' => DEL_AUTO,
'sIssue' => null,
],
'NetworkDevice' => [
'iDeleteCount' => 2,
'iUpdateCount' => 0,
'iMode' => DEL_SILENT,
'sIssue' => null,
],
'Person' => [
'iDeleteCount' => 0,
'iUpdateCount' => 1,
'iMode' => 0,
'sIssue' => null,
],
],
],
];
}
//--- ExecuteDeletionPlan tests ---
/**
* Tests that ExecuteDeletionPlan throws a DataFeatureRemovalException when the deletion plan contains issues.
*/
public function testExecuteDeletionPlanThrowsExceptionWhenIssuesExist(): void
{
$this->RequireOnceItopFile('env-production/combodo-data-feature-removal/src/Helper/DataFeatureRemovalException.php');
$oDeletionPlan = $this->createMock(DeletionPlan::class);
$oDeletionPlan->method('GetIssues')->willReturn(['Some issue']);
$oDeletionPlan->method('ListDeletes')->willReturn([]);
$oDeletionPlan->method('ListUpdates')->willReturn([]);
$oMockService = $this->getMockBuilder(DeletionPlanService::class)
->disableOriginalConstructor()
->onlyMethods(['GetDeletionPlan'])
->getMock();
$oMockService->method('GetDeletionPlan')->willReturn($oDeletionPlan);
$this->expectException(DataFeatureRemovalException::class);
$this->expectExceptionMessage('Deletion Plan cannot be executed due to issues');
$oMockService->ExecuteDeletionPlan(['SomeClass']);
}
}

View File

@@ -0,0 +1,346 @@
<?xml version="1.0" encoding="UTF-8"?>
<itop_design xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="3.2">
<classes>
<class id="DFRToRemove" _created_in="itop-structure" _delta="define">
<properties>
<category>bizmodel,searchable</category>
<abstract>true</abstract>
<db_table>dfrtoremove</db_table>
<naming>
<attributes/>
</naming>
<reconciliation>
<attributes/>
</reconciliation>
</properties>
<fields>
<field id="name" xsi:type="AttributeString">
<sql>name</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>
</items>
</details>
</presentation>
<parent>cmdbAbstractObject</parent>
</class>
<class id="DFRToUpdate" _created_in="itop-structure" _delta="define">
<properties>
<category>bizmodel,searchable</category>
<abstract>false</abstract>
<db_table>dfrtoupdate</db_table>
<naming>
<attributes/>
</naming>
<reconciliation>
<attributes/>
</reconciliation>
</properties>
<fields>
<field id="name" xsi:type="AttributeString">
<sql>name</sql>
<default_value/>
<is_null_allowed>true</is_null_allowed>
<validation_pattern/>
<dependencies/>
<tracking_level>all</tracking_level>
</field>
<field id="extkey_id" xsi:type="AttributeExternalKey">
<sql>extkey_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>
<methods/>
<presentation>
<list>
<items/>
</list>
<search>
<items/>
</search>
<details>
<items>
<item id="name">
<rank>10</rank>
</item>
<item id="extkey_id">
<rank>20</rank>
</item>
</items>
</details>
</presentation>
<parent>cmdbAbstractObject</parent>
</class>
<class id="DFRRemovedCollateral" _created_in="itop-structure" _delta="define">
<properties>
<category>bizmodel,searchable</category>
<abstract>false</abstract>
<db_table>dfrremovedcollateral</db_table>
<naming>
<attributes/>
</naming>
<reconciliation>
<attributes/>
</reconciliation>
</properties>
<fields>
<field id="name" xsi:type="AttributeString">
<sql>name</sql>
<default_value/>
<is_null_allowed>true</is_null_allowed>
<validation_pattern/>
<dependencies/>
<tracking_level>all</tracking_level>
</field>
<field id="extkey_id" xsi:type="AttributeExternalKey">
<sql>extkey_id</sql>
<filter/>
<dependencies/>
<is_null_allowed>false</is_null_allowed>
<target_class>DFRToRemove</target_class>
<on_target_delete>DEL_AUTO</on_target_delete>
<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="extkey_id">
<rank>20</rank>
</item>
</items>
</details>
</presentation>
<parent>cmdbAbstractObject</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>
<class id="DFRRemovedCollateralCascade" _created_in="itop-structure" _delta="define">
<properties>
<category>bizmodel,searchable</category>
<abstract>false</abstract>
<db_table>dfrremovedcollateralcascade</db_table>
<naming>
<attributes/>
</naming>
<reconciliation>
<attributes/>
</reconciliation>
</properties>
<fields>
<field id="name" xsi:type="AttributeString">
<sql>name</sql>
<default_value/>
<is_null_allowed>true</is_null_allowed>
<validation_pattern/>
<dependencies/>
<tracking_level>all</tracking_level>
</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>
<methods/>
<presentation>
<list>
<items/>
</list>
<search>
<items/>
</search>
<details>
<items>
<item id="name">
<rank>10</rank>
</item>
<item id="extkey_id">
<rank>20</rank>
</item>
</items>
</details>
</presentation>
<parent>cmdbAbstractObject</parent>
</class>
<class id="DFRManual" _created_in="itop-structure" _delta="define">
<properties>
<category>bizmodel,searchable</category>
<abstract>false</abstract>
<db_table>dfrmanual</db_table>
<naming>
<attributes/>
</naming>
<reconciliation>
<attributes/>
</reconciliation>
</properties>
<fields>
<field id="name" xsi:type="AttributeString">
<sql>name</sql>
<default_value/>
<is_null_allowed>true</is_null_allowed>
<validation_pattern/>
<dependencies/>
<tracking_level>all</tracking_level>
</field>
<field id="extkey_id" xsi:type="AttributeExternalKey">
<sql>extkey_id</sql>
<filter/>
<dependencies/>
<is_null_allowed>false</is_null_allowed>
<target_class>DFRToRemove</target_class>
<on_target_delete>DEL_MANUAL</on_target_delete>
<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="extkey_id">
<rank>20</rank>
</item>
</items>
</details>
</presentation>
<parent>cmdbAbstractObject</parent>
</class>
</classes>
<dictionaries>
<dictionary id="EN US">
<entries>
<entry id="Class:DFRToRemove/Name" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRToRemove/ComplementaryName" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRToRemove" _delta="define"><![CDATA[DFRToRemove]]></entry>
<entry id="Class:DFRToRemove+" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRToRemove/Attribute:name" _delta="define"><![CDATA[Name]]></entry>
<entry id="Class:DFRToRemove/Attribute:name+" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRToUpdate/Name" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRToUpdate/ComplementaryName" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRToUpdate" _delta="define"><![CDATA[DFRToUpdate]]></entry>
<entry id="Class:DFRToUpdate+" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRToUpdate/Attribute:name" _delta="define"><![CDATA[Name]]></entry>
<entry id="Class:DFRToUpdate/Attribute:name+" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRToUpdate/Attribute:extkey_id" _delta="define"><![CDATA[Dfrtoremove id]]></entry>
<entry id="Class:DFRToUpdate/Attribute:extkey_id+" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRRemovedCollateral/Name" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRRemovedCollateral/ComplementaryName" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRRemovedCollateral" _delta="define"><![CDATA[DFRRemovedCollateral]]></entry>
<entry id="Class:DFRRemovedCollateral+" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRRemovedCollateral/Attribute:name" _delta="define"><![CDATA[Name]]></entry>
<entry id="Class:DFRRemovedCollateral/Attribute:name+" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRRemovedCollateral/Attribute:extkey_id" _delta="define"><![CDATA[Dfrtoremove id]]></entry>
<entry id="Class:DFRRemovedCollateral/Attribute:extkey_id+" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRToRemoveLeaf/Name" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRToRemoveLeaf/ComplementaryName" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRToRemoveLeaf" _delta="define"><![CDATA[DFRToRemoveLeaf]]></entry>
<entry id="Class:DFRToRemoveLeaf+" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRToRemoveLeaf/Attribute:desc" _delta="define"><![CDATA[Desc]]></entry>
<entry id="Class:DFRToRemoveLeaf/Attribute:desc+" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRRemovedCollateralCascade/Name" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRRemovedCollateralCascade/ComplementaryName" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRRemovedCollateralCascade" _delta="define"><![CDATA[DFRRemovedCollateralCascade]]></entry>
<entry id="Class:DFRRemovedCollateralCascade+" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRRemovedCollateralCascade/Attribute:name" _delta="define"><![CDATA[Name]]></entry>
<entry id="Class:DFRRemovedCollateralCascade/Attribute:name+" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRRemovedCollateralCascade/Attribute:extkey_id" _delta="define"><![CDATA[Dfrremovedcollateral id]]></entry>
<entry id="Class:DFRRemovedCollateralCascade/Attribute:extkey_id+" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRManual/Name" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRManual/ComplementaryName" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRManual" _delta="define"><![CDATA[DFRManual]]></entry>
<entry id="Class:DFRManual+" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRManual/Attribute:name" _delta="define"><![CDATA[Name]]></entry>
<entry id="Class:DFRManual/Attribute:name+" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRManual/Attribute:extkey_id" _delta="define"><![CDATA[Dfrtoremove id]]></entry>
<entry id="Class:DFRManual/Attribute:extkey_id+" _delta="define"><![CDATA[]]></entry>
</entries>
</dictionary>
</dictionaries>
</itop_design>