From 8940051c3da6ca6b00514f12a84e38b9ab56f874 Mon Sep 17 00:00:00 2001 From: Eric Espie Date: Fri, 17 Feb 2023 14:25:01 +0100 Subject: [PATCH] =?UTF-8?q?N=C2=B05906=20-=20CRUD=20Event=20-=20fire=20eve?= =?UTF-8?q?nt=20EVENT=5FDB=5FLINKS=5FCHANGED=20when=20an=20n-n=20link=20is?= =?UTF-8?q?=20created/updated/deleted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/cmdbabstract.class.inc.php | 354 +++++++++++-- application/datamodel.application.xml | 16 + core/bulkchange.class.inc.php | 208 ++++---- core/dbobject.class.php | 405 ++++++++++----- .../itop-tickets/datamodel.itop-tickets.xml | 19 + js/links/links_view_table_widget.js | 4 +- .../Service/Events/EventService.php | 19 +- .../Service/Events/EventServiceLog.php | 30 -- synchro/synchrodatasource.class.inc.php | 29 +- tests/php-unit-tests/ItopTestCase.php | 55 +- .../application/cmdbAbstractObjectTest.php | 479 ++++++++++++++++++ .../unitary-tests/core/CRUDEventTest.php | 61 ++- 12 files changed, 1304 insertions(+), 375 deletions(-) create mode 100644 tests/php-unit-tests/unitary-tests/application/cmdbAbstractObjectTest.php diff --git a/application/cmdbabstract.class.inc.php b/application/cmdbabstract.class.inc.php index f69a3a5f5..590495ded 100644 --- a/application/cmdbabstract.class.inc.php +++ b/application/cmdbabstract.class.inc.php @@ -190,6 +190,25 @@ abstract class cmdbAbstractObject extends CMDBObject implements iDisplay const MAX_UPDATE_LOOP_COUNT = 10; + /** + * @var array First level classname, second level id, value number of calls done + * @used-by static::RegisterObjectAwaitingEventDbLinksChanged() + * @used-by static::RemoveObjectAwaitingEventDbLinksChanged() + * + */ + protected static array $aObjectsAwaitingEventDbLinksChanged = []; + + /** + * @var bool Flag to allow/block the Event when DBLink are changed + * This is used to avoid sending too many events when doing mass-update + * + * When this flag is set to true, the object registration for links modification is done + * but the event is not fired. + * + * @since 3.1.0 N°5906 + */ + protected static bool $bBlockEventDBLinksChanged = false; + /** * Constructor from a row of data (as a hash 'attcode' => value) @@ -4425,17 +4444,23 @@ HTML; */ public function DBInsertNoReload() { - $res = parent::DBInsertNoReload(); + try { + $res = parent::DBInsertNoReload(); - $this->SetWarningsAsSessionMessages('create'); + $this->SetWarningsAsSessionMessages('create'); - // Invoke extensions after insertion (the object must exist, have an id, etc.) - /** @var \iApplicationObjectExtension $oExtensionInstance */ - foreach(MetaModel::EnumPlugins('iApplicationObjectExtension') as $oExtensionInstance) - { - $oExtensionInstance->OnDBInsert($this, self::GetCurrentChange()); + // Invoke extensions after insertion (the object must exist, have an id, etc.) + /** @var \iApplicationObjectExtension $oExtensionInstance */ + foreach (MetaModel::EnumPlugins('iApplicationObjectExtension') as $oExtensionInstance) { + $oExtensionInstance->OnDBInsert($this, self::GetCurrentChange()); + } + } finally { + if (static::IsCrudStackEmpty()) { + // Avoid signaling the current object that links were modified + static::RemoveObjectAwaitingEventDbLinksChanged(get_class($this), $this->GetKey()); + static::FireEventDbLinksChangedForAllObjects(); + } } - return $res; } @@ -4464,48 +4489,53 @@ HTML; public function DBUpdate() { - $res = parent::DBUpdate(); + try { + $res = parent::DBUpdate(); - $this->SetWarningsAsSessionMessages('update'); + $this->SetWarningsAsSessionMessages('update'); - // Protection against reentrance (e.g. cascading the update of ticket logs) - // Note: This is based on the fix made on r 3190 in DBObject::DBUpdate() - if (!MetaModel::StartReentranceProtection($this)) { - $sClass = get_class($this); - $sKey = $this->GetKey(); - IssueLog::Debug("CRUD: DBUpdate $sClass::$sKey Rejected (reentrance)", LogChannels::DM_CRUD); - - return $res; - } - - try - { - // Invoke extensions after the update (could be before) - /** @var \iApplicationObjectExtension $oExtensionInstance */ - foreach (MetaModel::EnumPlugins('iApplicationObjectExtension') as $oExtensionInstance) - { - $oExtensionInstance->OnDBUpdate($this, self::GetCurrentChange()); - } - } - finally - { - MetaModel::StopReentranceProtection($this); - } - - $aChanges = $this->ListChanges(); - if (count($aChanges) != 0) { - $this->iUpdateLoopCount++; - if ($this->iUpdateLoopCount > self::MAX_UPDATE_LOOP_COUNT) { + // Protection against reentrance (e.g. cascading the update of ticket logs) + // Note: This is based on the fix made on r 3190 in DBObject::DBUpdate() + if (!MetaModel::StartReentranceProtection($this)) { $sClass = get_class($this); $sKey = $this->GetKey(); - $aPlugins = []; + IssueLog::Debug("CRUD: DBUpdate $sClass::$sKey Rejected (reentrance)", LogChannels::DM_CRUD); + + return $res; + } + + try { + // Invoke extensions after the update (could be before) + /** @var \iApplicationObjectExtension $oExtensionInstance */ foreach (MetaModel::EnumPlugins('iApplicationObjectExtension') as $oExtensionInstance) { - $aPlugins[] = get_class($oExtensionInstance); + $oExtensionInstance->OnDBUpdate($this, self::GetCurrentChange()); } - $sPlugins = implode(', ', $aPlugins); - IssueLog::Error("CRUD: DBUpdate $sClass::$sKey Update loop detected plugins: $sPlugins", LogChannels::DM_CRUD); - } else { - return $this->DBUpdate(); + } + finally { + MetaModel::StopReentranceProtection($this); + } + + $aChanges = $this->ListChanges(); + if (count($aChanges) != 0) { + $this->iUpdateLoopCount++; + if ($this->iUpdateLoopCount > self::MAX_UPDATE_LOOP_COUNT) { + $sClass = get_class($this); + $sKey = $this->GetKey(); + $aPlugins = []; + foreach (MetaModel::EnumPlugins('iApplicationObjectExtension') as $oExtensionInstance) { + $aPlugins[] = get_class($oExtensionInstance); + } + $sPlugins = implode(', ', $aPlugins); + IssueLog::Error("CRUD: DBUpdate $sClass::$sKey Update loop detected plugins: $sPlugins", LogChannels::DM_CRUD); + } else { + return $this->DBUpdate(); + } + } + } finally { + if (static::IsCrudStackEmpty()) { + // Avoid signaling the current object that links were modified + static::RemoveObjectAwaitingEventDbLinksChanged(get_class($this), $this->GetKey()); + static::FireEventDbLinksChangedForAllObjects(); } } @@ -4529,7 +4559,22 @@ HTML; } } - protected function DBDeleteTracked_Internal(&$oDeletionPlan = null) + public function DBDelete(&$oDeletionPlan = null) + { + try { + parent::DBDelete($oDeletionPlan); + } finally { + if (static::IsCrudStackEmpty()) { + // Avoid signaling the current object that links were modified + static::RemoveObjectAwaitingEventDbLinksChanged(get_class($this), $this->GetKey()); + static::FireEventDbLinksChangedForAllObjects(); + } + } + + return $oDeletionPlan; + } + + protected function DBDeleteTracked_Internal(&$oDeletionPlan = null) { // Invoke extensions before the deletion (the deletion will do some cleanup and we might loose some information /** @var \iApplicationObjectExtension $oExtensionInstance */ @@ -5197,6 +5242,9 @@ EOF } utils::RemoveTransaction($sTransactionId); } + + // Avoid too many events + static::SetEventDBLinksChangedBlocked(true); $iPreviousTimeLimit = ini_get('max_execution_time'); $iLoopTimeLimit = MetaModel::GetConfig()->Get('max_execution_time_per_loop'); foreach ($aSelectedObj as $iId) { @@ -5226,6 +5274,10 @@ EOF $oObj->DBUpdate(); } } + // Send all the retained events for further computations + static::SetEventDBLinksChangedBlocked(false); + static::FireEventDbLinksChangedForAllObjects(); + set_time_limit(intval($iPreviousTimeLimit)); $oTable = DataTableUIBlockFactory::MakeForForm('BulkModify', $aHeaders, $aRows); $oTable->AddOption("bFullscreen", true); @@ -5296,13 +5348,20 @@ EOF { $oDeletionPlan = new DeletionPlan(); - foreach($aObjects as $oObj) - { - if ($bPreview) { - $oObj->CheckToDelete($oDeletionPlan); - } else { - $oObj->DBDelete($oDeletionPlan); + // Avoid too many events + static::SetEventDBLinksChangedBlocked(true); + try { + foreach ($aObjects as $oObj) { + if ($bPreview) { + $oObj->CheckToDelete($oDeletionPlan); + } else { + $oObj->DBDelete($oDeletionPlan); + } } + } finally { + // Send all the retained events for further computations + static::SetEventDBLinksChangedBlocked(false); + static::FireEventDbLinksChangedForAllObjects(); } if ($bPreview) { @@ -5733,7 +5792,9 @@ JS /// /** - * @inheritDoc + * @return void + * @throws \CoreException + * * @since 3.1.0 */ final protected function FireEventCheckToWrite(): void @@ -5742,11 +5803,14 @@ JS } /** - * @inheritDoc + * @return void + * @throws \CoreException + * * @since 3.1.0 */ final protected function FireEventCreateDone(): void { + $this->NotifyAttachedObjectsOnLinkClassModification(); $this->FireEvent(EVENT_DB_CREATE_DONE); } @@ -5755,11 +5819,16 @@ JS /// /** - * @inheritDoc + * @param array $aChanges + * + * @return void + * @throws \ArchivedObjectException + * @throws \CoreException * @since 3.1.0 */ final protected function FireEventUpdateDone(array $aChanges): void { + $this->NotifyAttachedObjectsOnLinkClassModification(); $this->FireEvent(EVENT_DB_UPDATE_DONE, ['changes' => $aChanges]); } @@ -5768,7 +5837,10 @@ JS /// /** - * @inheritDoc + * @param \DeletionPlan $oDeletionPlan + * + * @return void + * @throws \CoreException * @since 3.1.0 */ final protected function FireEventCheckToDelete(DeletionPlan $oDeletionPlan): void @@ -5777,14 +5849,184 @@ JS } /** - * @inheritDoc + * @return void + * @throws \CoreException + * * @since 3.1.0 */ final protected function FireEventDeleteDone(): void { + $this->NotifyAttachedObjectsOnLinkClassModification(); $this->FireEvent(EVENT_DB_DELETE_DONE); } + /** + * If the passed object is an instance of a link class, then will register each remote object for modification using {@see static::RegisterObjectAwaitingEventDbLinksChanged()} + * If an external key was modified, register also the previous object that was linked previously. + * + * @throws \ArchivedObjectException + * @throws \CoreException + * @throws \Exception + * + * @since 3.1.0 N°5906 + */ + final protected function NotifyAttachedObjectsOnLinkClassModification(): void + { + $sClass = get_class($this); + if (false === MetaModel::IsLinkClass($sClass)) { + return; + } + // previous values in case of link change + $aPreviousValues = $this->ListPreviousValuesForUpdatedAttributes(); + + $aLnkClassExternalKeys = MetaModel::GetAttributesList($sClass, [AttributeExternalKey::class]); + foreach ($aLnkClassExternalKeys as $sExternalKeyAttCode) { + /** @var \AttributeExternalKey $oExternalKeyAttDef */ + $oExternalKeyAttDef = MetaModel::GetAttributeDef($sClass, $sExternalKeyAttCode); + $sRemoteClassName = $oExternalKeyAttDef->GetTargetClass(); + + $sRemoteObjectId = $this->Get($sExternalKeyAttCode); + if ($sRemoteObjectId > 0) { + self::RegisterObjectAwaitingEventDbLinksChanged($sRemoteClassName, $sRemoteObjectId); + } + + $sPreviousRemoteObjectId = $aPreviousValues[$sExternalKeyAttCode] ?? 0; + if ($sPreviousRemoteObjectId > 0) { + self::RegisterObjectAwaitingEventDbLinksChanged($sRemoteClassName, $sPreviousRemoteObjectId); + } + } + } + + /** + * Register one object for later EVENT_DB_LINKS_CHANGED event. + * + * @param string $sClass + * @param string|int|null $sId + * + * @since 3.1.0 N°5906 + */ + private static function RegisterObjectAwaitingEventDbLinksChanged(string $sClass, $sId): void + { + if (isset(self::$aObjectsAwaitingEventDbLinksChanged[$sClass][$sId])) { + self::$aObjectsAwaitingEventDbLinksChanged[$sClass][$sId]++; + } else { + self::$aObjectsAwaitingEventDbLinksChanged[$sClass][$sId] = 1; + } + } + + /** + * Fire the EVENT_DB_LINKS_CHANGED event if current object is registered + * + * @return void + * @throws \ArchivedObjectException + * @throws \CoreException + * + * @since 3.1.0 N°5906 + */ + final protected function FireEventDbLinksChangedForCurrentObject(): void + { + if (true === static::IsEventDBLinksChangedBlocked()) { + return; + } + + $sClass = get_class($this); + $sId = $this->GetKey(); + self::FireEventDbLinksChangedForClassId($sClass, $sId); + } + + /** + * Fire the EVENT_DB_LINKS_CHANGED event if given object is registered, and unregister it + * + * @param string $sClass + * @param string|int|null $sId + * + * @return void + * @throws \ArchivedObjectException + * @throws \CoreException + */ + private static function FireEventDbLinksChangedForClassId(string $sClass, $sId): void + { + if (true === static::IsEventDBLinksChangedBlocked()) { + return; + } + + $bIsObjectAwaitingEventDbLinksChanged = self::RemoveObjectAwaitingEventDbLinksChanged($sClass, $sId); + if (false === $bIsObjectAwaitingEventDbLinksChanged) { + return; + } + + $oObject = MetaModel::GetObject($sClass, $sId); + $oObject->FireEvent(EVENT_DB_LINKS_CHANGED); + + // The event listeners might have generated new lnk instances pointing to this object, so removing object from stack to avoid reentrance + self::RemoveObjectAwaitingEventDbLinksChanged($sClass, $sId); + } + + /** + * Remove the registration of an object concerning the EVENT_DB_LINKS_CHANGED event + * + * @param string $sClass + * @param string|int|null $sId + * + * @return bool true if the object [class, id] was present in the list + * @throws \CoreException + */ + final protected static function RemoveObjectAwaitingEventDbLinksChanged(string $sClass, $sId): bool + { + $bFlagRemoved = false; + $aClassesHierarchy = MetaModel::EnumParentClasses($sClass, ENUM_PARENT_CLASSES_ALL, false); + foreach ($aClassesHierarchy as $sClassInHierarchy) { + if (isset(self::$aObjectsAwaitingEventDbLinksChanged[$sClassInHierarchy][$sId])) { + unset(self::$aObjectsAwaitingEventDbLinksChanged[$sClassInHierarchy][$sId]); + $bFlagRemoved = true; + } + } + + return $bFlagRemoved; + } + + /** + * Fire the EVENT_DB_LINKS_CHANGED event to all the registered objects + * + * @return void + * @throws \ArchivedObjectException + * @throws \CoreException + * + * @since 3.1.0 N°5906 + */ + final public static function FireEventDbLinksChangedForAllObjects() + { + if (true === static::IsEventDBLinksChangedBlocked()) { + return; + } + + foreach (self::$aObjectsAwaitingEventDbLinksChanged as $sClass => $aClassInstances) { + foreach ($aClassInstances as $sId => $iCallsNumber) { + self::FireEventDbLinksChangedForClassId($sClass, $sId); + } + } + } + + /** + * Check if the event EVENT_DB_LINKS_CHANGED is blocked or not (for bulk operations) + * + * @return bool + */ + final public static function IsEventDBLinksChangedBlocked(): bool + { + return self::$bBlockEventDBLinksChanged; + } + + /** + * Block/unblock the event EVENT_DB_LINKS_CHANGED (the registration of objects on links modifications continues to work) + * + * @param bool $bBlockEventDBLinksChanged + */ + final public static function SetEventDBLinksChangedBlocked(bool $bBlockEventDBLinksChanged): void + { + self::$bBlockEventDBLinksChanged = $bBlockEventDBLinksChanged; + } + /** * @inheritDoc * @throws \CoreException diff --git a/application/datamodel.application.xml b/application/datamodel.application.xml index 1f6e2d9fa..bb903757b 100644 --- a/application/datamodel.application.xml +++ b/application/datamodel.application.xml @@ -371,6 +371,22 @@ + + At least one link class was changed + + cmdbAbstractObject + + + + The object where the link is or was pointing to + DBObject + + + Debug string + string + + + An object has been re-loaded from the database diff --git a/core/bulkchange.class.inc.php b/core/bulkchange.class.inc.php index f4f7fbb3b..9180b5266 100644 --- a/core/bulkchange.class.inc.php +++ b/core/bulkchange.class.inc.php @@ -1160,136 +1160,112 @@ class BulkChange } $iPreviousTimeLimit = ini_get('max_execution_time'); $iLoopTimeLimit = MetaModel::GetConfig()->Get('max_execution_time_per_loop'); - foreach($this->m_aData as $iRow => $aRowData) - { - set_time_limit(intval($iLoopTimeLimit)); - if (isset($aResult[$iRow]["__STATUS__"])) - { - // An issue at the earlier steps - skip the rest - continue; - } - try - { - $oReconciliationFilter = new DBObjectSearch($this->m_sClass); - $bSkipQuery = false; - foreach($this->m_aReconcilKeys as $sAttCode) - { - $valuecondition = null; - if (array_key_exists($sAttCode, $this->m_aExtKeys)) - { - if ($this->IsNullExternalKeySpec($aRowData, $sAttCode)) - { - $oExtKey = MetaModel::GetAttributeDef($this->m_sClass, $sAttCode); - if ($oExtKey->IsNullAllowed()) - { - $valuecondition = $oExtKey->GetNullValue(); - $aResult[$iRow][$sAttCode] = new CellStatus_Void($oExtKey->GetNullValue()); - } - else - { - $aResult[$iRow][$sAttCode] = new CellStatus_NullIssue(); - } - } - else - { - // The value has to be found or verified - /** var DBObjectSearch $oReconFilter */ - list($oReconFilter, $aMatches) = $this->ResolveExternalKey($aRowData, $sAttCode, $aResult[$iRow]); + // Avoid too many events + cmdbAbstractObject::SetEventDBLinksChangedBlocked(true); + try { + foreach ($this->m_aData as $iRow => $aRowData) { + set_time_limit(intval($iLoopTimeLimit)); + if (isset($aResult[$iRow]["__STATUS__"])) { + // An issue at the earlier steps - skip the rest + continue; + } + try { + $oReconciliationFilter = new DBObjectSearch($this->m_sClass); + $bSkipQuery = false; + foreach ($this->m_aReconcilKeys as $sAttCode) { + $valuecondition = null; + if (array_key_exists($sAttCode, $this->m_aExtKeys)) { + if ($this->IsNullExternalKeySpec($aRowData, $sAttCode)) { + $oExtKey = MetaModel::GetAttributeDef($this->m_sClass, $sAttCode); + if ($oExtKey->IsNullAllowed()) { + $valuecondition = $oExtKey->GetNullValue(); + $aResult[$iRow][$sAttCode] = new CellStatus_Void($oExtKey->GetNullValue()); + } else { + $aResult[$iRow][$sAttCode] = new CellStatus_NullIssue(); + } + } else { + // The value has to be found or verified - if (count($aMatches) == 1) - { - $oRemoteObj = reset($aMatches); // first item - $valuecondition = $oRemoteObj->GetKey(); - $aResult[$iRow][$sAttCode] = new CellStatus_Void($oRemoteObj->GetKey()); + /** var DBObjectSearch $oReconFilter */ + list($oReconFilter, $aMatches) = $this->ResolveExternalKey($aRowData, $sAttCode, $aResult[$iRow]); + + if (count($aMatches) == 1) { + $oRemoteObj = reset($aMatches); // first item + $valuecondition = $oRemoteObj->GetKey(); + $aResult[$iRow][$sAttCode] = new CellStatus_Void($oRemoteObj->GetKey()); + } elseif (count($aMatches) == 0) { + $oCellStatus_SearchIssue = $this->GetCellSearchIssue($oReconFilter); + $aResult[$iRow][$sAttCode] = $oCellStatus_SearchIssue; + } else { + $aResult[$iRow][$sAttCode] = new CellStatus_Ambiguous(null, count($aMatches), $oReconFilter->serialize()); + } } - elseif (count($aMatches) == 0) - { - $oCellStatus_SearchIssue = $this->GetCellSearchIssue($oReconFilter); - $aResult[$iRow][$sAttCode] = $oCellStatus_SearchIssue; - } - else - { - $aResult[$iRow][$sAttCode] = new CellStatus_Ambiguous(null, count($aMatches), $oReconFilter->serialize()); + } else { + // The value is given in the data row + $iCol = $this->m_aAttList[$sAttCode]; + if ($sAttCode == 'id') { + $valuecondition = $aRowData[$iCol]; + } else { + $oAttDef = MetaModel::GetAttributeDef($this->m_sClass, $sAttCode); + $valuecondition = $oAttDef->MakeValueFromString($aRowData[$iCol], $this->m_bLocalizedValues); } } - } - else - { - // The value is given in the data row - $iCol = $this->m_aAttList[$sAttCode]; - if ($sAttCode == 'id') - { - $valuecondition = $aRowData[$iCol]; - } - else - { - $oAttDef = MetaModel::GetAttributeDef($this->m_sClass, $sAttCode); - $valuecondition = $oAttDef->MakeValueFromString($aRowData[$iCol], $this->m_bLocalizedValues); + if (is_null($valuecondition)) { + $bSkipQuery = true; + } else { + $oReconciliationFilter->AddCondition($sAttCode, $valuecondition, '='); } } - if (is_null($valuecondition)) - { - $bSkipQuery = true; - } - else - { - $oReconciliationFilter->AddCondition($sAttCode, $valuecondition, '='); + if ($bSkipQuery) { + $aResult[$iRow]["__STATUS__"] = new RowStatus_Issue(Dict::S('UI:CSVReport-Row-Issue-Reconciliation')); + } else { + $oReconciliationSet = new CMDBObjectSet($oReconciliationFilter); + switch ($oReconciliationSet->Count()) { + case 0: + $oTargetObj = $this->CreateObject($aResult, $iRow, $aRowData, $oChange); + // $aResult[$iRow]["__STATUS__"]=> set in CreateObject + $aVisited[] = $oTargetObj->GetKey(); + break; + case 1: + $oTargetObj = $oReconciliationSet->Fetch(); + $this->UpdateObject($aResult, $iRow, $oTargetObj, $aRowData, $oChange); + // $aResult[$iRow]["__STATUS__"]=> set in UpdateObject + if (!is_null($this->m_sSynchroScope)) { + $aVisited[] = $oTargetObj->GetKey(); + } + break; + default: + // Found several matches, ambiguous + $aResult[$iRow]["__STATUS__"] = new RowStatus_Issue(Dict::S('UI:CSVReport-Row-Issue-Ambiguous')); + $aResult[$iRow]["id"] = new CellStatus_Ambiguous(0, $oReconciliationSet->Count(), $oReconciliationFilter->serialize()); + $aResult[$iRow]["finalclass"] = 'n/a'; + } } + } catch (Exception $e) { + $aResult[$iRow]["__STATUS__"] = new RowStatus_Issue(Dict::Format('UI:CSVReport-Row-Issue-Internal', get_class($e), $e->getMessage())); } - if ($bSkipQuery) - { - $aResult[$iRow]["__STATUS__"]= new RowStatus_Issue(Dict::S('UI:CSVReport-Row-Issue-Reconciliation')); - } - else - { - $oReconciliationSet = new CMDBObjectSet($oReconciliationFilter); - switch($oReconciliationSet->Count()) - { - case 0: - $oTargetObj = $this->CreateObject($aResult, $iRow, $aRowData, $oChange); - // $aResult[$iRow]["__STATUS__"]=> set in CreateObject - $aVisited[] = $oTargetObj->GetKey(); - break; - case 1: - $oTargetObj = $oReconciliationSet->Fetch(); - $this->UpdateObject($aResult, $iRow, $oTargetObj, $aRowData, $oChange); - // $aResult[$iRow]["__STATUS__"]=> set in UpdateObject - if (!is_null($this->m_sSynchroScope)) - { - $aVisited[] = $oTargetObj->GetKey(); - } - break; - default: - // Found several matches, ambiguous - $aResult[$iRow]["__STATUS__"]= new RowStatus_Issue(Dict::S('UI:CSVReport-Row-Issue-Ambiguous')); - $aResult[$iRow]["id"]= new CellStatus_Ambiguous(0, $oReconciliationSet->Count(), $oReconciliationFilter->serialize()); - $aResult[$iRow]["finalclass"]= 'n/a'; + } + + if (!is_null($this->m_sSynchroScope)) { + // Compute the delta between the scope and visited objects + $oScopeSearch = DBObjectSearch::FromOQL($this->m_sSynchroScope); + $oScopeSet = new DBObjectSet($oScopeSearch); + while ($oObj = $oScopeSet->Fetch()) { + $iObj = $oObj->GetKey(); + if (!in_array($iObj, $aVisited)) { + set_time_limit(intval($iLoopTimeLimit)); + $iRow++; + $this->UpdateMissingObject($aResult, $iRow, $oObj, $oChange); } } } - catch (Exception $e) - { - $aResult[$iRow]["__STATUS__"]= new RowStatus_Issue(Dict::Format('UI:CSVReport-Row-Issue-Internal', get_class($e), $e->getMessage())); - } + } finally { + // Send all the retained events for further computations + cmdbAbstractObject::SetEventDBLinksChangedBlocked(false); + cmdbAbstractObject::FireEventDbLinksChangedForAllObjects(); } - if (!is_null($this->m_sSynchroScope)) - { - // Compute the delta between the scope and visited objects - $oScopeSearch = DBObjectSearch::FromOQL($this->m_sSynchroScope); - $oScopeSet = new DBObjectSet($oScopeSearch); - while ($oObj = $oScopeSet->Fetch()) - { - $iObj = $oObj->GetKey(); - if (!in_array($iObj, $aVisited)) - { - set_time_limit(intval($iLoopTimeLimit)); - $iRow++; - $this->UpdateMissingObject($aResult, $iRow, $oObj, $oChange); - } - } - } set_time_limit(intval($iPreviousTimeLimit)); // Fill in the blanks - the result matrix is expected to be 100% complete diff --git a/core/dbobject.class.php b/core/dbobject.class.php index 17a794e92..faed30457 100644 --- a/core/dbobject.class.php +++ b/core/dbobject.class.php @@ -156,6 +156,18 @@ abstract class DBObject implements iDisplay /** @var \DBObject Source object when updating links */ protected $m_oLinkHostObject = null; + /** + * @var array List all the CRUD stack in progress + * + * The array contains instances of + * ['type' => 'type of CRUD operation (INSERT, UPDATE, DELETE)', + * 'class' => 'class of the object in the CRUD process', + * 'id' => 'id of the object in the CRUD process'] + * + * @since 3.1.0 N°5906 + */ + protected static array $m_aCrudStack = []; + /** * DBObject constructor. * @@ -2952,148 +2964,161 @@ abstract class DBObject implements iDisplay public function DBInsertNoReload() { $sClass = get_class($this); - if (MetaModel::DBIsReadOnly()) - { - $sErrorMessage = "Cannot Insert object of class '$sClass' because of an ongoing maintenance: the database is in ReadOnly mode"; - IssueLog::Error("$sErrorMessage\n".MyHelpers::get_callstack_text(1)); - throw new CoreException("$sErrorMessage (see the log for more information)"); - } - - if ($this->m_bIsInDB) { - throw new CoreException('The object already exists into the Database, you may want to use the clone function'); - } - - $sClass = get_class($this); - $sRootClass = MetaModel::GetRootClass($sClass); - - // Ensure the update of the values (we are accessing the data directly) - $this->DoComputeValues(); - $this->OnInsert(); - - // If not automatically computed, then check that the key is given by the caller - if (!MetaModel::IsAutoIncrementKey($sRootClass)) { - if (empty($this->m_iKey)) { - throw new CoreWarning('Missing key for the object to write - This class is supposed to have a user defined key, not an autonumber', array('class' => $sRootClass)); - } - } - - list($bRes, $aIssues) = $this->CheckToWrite(false); - if (!$bRes) { - throw new CoreCannotSaveObjectException(array('issues' => $aIssues, 'class' => get_class($this), 'id' => $this->GetKey())); - } - - if ($this->m_iKey < 0) { - // This was a temporary "memory" key: discard it so that DBInsertSingleTable will not try to use it! - $this->m_iKey = null; - } - - $this->ComputeStopWatchesDeadline(true); - - $iTransactionRetry = 1; - $bIsTransactionEnabled = MetaModel::GetConfig()->Get('db_core_transactions_enabled'); - if ($bIsTransactionEnabled) { - // TODO Deep clone this object before the transaction (to use it in case of rollback) - // $iTransactionRetryCount = MetaModel::GetConfig()->Get('db_core_transactions_retry_count'); - $iTransactionRetryCount = 1; - $iTransactionRetryDelay = MetaModel::GetConfig()->Get('db_core_transactions_retry_delay_ms'); - $iTransactionRetry = $iTransactionRetryCount; - } - while ($iTransactionRetry > 0) { - try { - $iTransactionRetry--; - if ($bIsTransactionEnabled) { - CMDBSource::Query('START TRANSACTION'); - } - - // First query built upon on the root class, because the ID must be created first - $this->m_iKey = $this->DBInsertSingleTable($sRootClass); - - // Then do the leaf class, if different from the root class - if ($sClass != $sRootClass) { - $this->DBInsertSingleTable($sClass); - } - - // Then do the other classes - foreach (MetaModel::EnumParentClasses($sClass) as $sParentClass) { - if ($sParentClass == $sRootClass) { - continue; - } - $this->DBInsertSingleTable($sParentClass); - } - - $this->OnObjectKeyReady(); - - $this->DBWriteLinks(); - $this->WriteExternalAttributes(); - - // Write object creation history within the transaction - $this->RecordObjCreation(); - - if ($bIsTransactionEnabled) { - CMDBSource::Query('COMMIT'); - } - break; - } - catch (Exception $e) { - IssueLog::Error($e->getMessage()); - if ($bIsTransactionEnabled) { - CMDBSource::Query('ROLLBACK'); - if (!CMDBSource::IsInsideTransaction() && CMDBSource::IsDeadlockException($e)) { - // Deadlock found when trying to get lock; try restarting transaction (only in main transaction) - if ($iTransactionRetry > 0) { - // wait and retry - IssueLog::Error('Insert TRANSACTION Retrying...'); - usleep(random_int(1, 5) * 1000 * $iTransactionRetryDelay * ($iTransactionRetryCount - $iTransactionRetry)); - continue; - } else { - IssueLog::Error('Insert Deadlock TRANSACTION prevention failed.'); - } - } - } - throw $e; - } - } - - $this->m_bIsInDB = true; - $this->m_bDirty = false; - foreach ($this->m_aCurrValues as $sAttCode => $value) { - if (is_object($value)) { - $value = clone $value; - } - $this->m_aOrigValues[$sAttCode] = $value; - } - - // Prevent DBUpdate at this point (reentrance protection) - MetaModel::StartReentranceProtection($this); + $this->AddCurrentObjectInCrudStack('INSERT'); try { - $this->FireEventCreateDone(); - $this->AfterInsert(); + if (MetaModel::DBIsReadOnly()) + { + $sErrorMessage = "Cannot Insert object of class '$sClass' because of an ongoing maintenance: the database is in ReadOnly mode"; + + IssueLog::Error("$sErrorMessage\n".MyHelpers::get_callstack_text(1)); + throw new CoreException("$sErrorMessage (see the log for more information)"); + } + + if ($this->m_bIsInDB) { + throw new CoreException('The object already exists into the Database, you may want to use the clone function'); + } - // Activate any existing trigger $sClass = get_class($this); - $aParams = array('class_list' => MetaModel::EnumParentClasses($sClass, ENUM_PARENT_CLASSES_ALL)); - $oSet = new DBObjectSet(DBObjectSearch::FromOQL('SELECT TriggerOnObjectCreate AS t WHERE t.target_class IN (:class_list)'), array(), $aParams); - while ($oTrigger = $oSet->Fetch()) { - /** @var \Trigger $oTrigger */ - try { - $oTrigger->DoActivate($this->ToArgs('this')); - } - catch (Exception $e) { - utils::EnrichRaisedException($oTrigger, $e); + $sRootClass = MetaModel::GetRootClass($sClass); + + // Ensure the update of the values (we are accessing the data directly) + $this->DoComputeValues(); + $this->OnInsert(); + if ($this->m_iKey < 0) { + // This was a temporary "memory" key: discard it so that DBInsertSingleTable will not try to use it! + $this->m_iKey = null; + $this->UpdateCurrentObjectInCrudStack(); + } + // If not automatically computed, then check that the key is given by the caller + if (!MetaModel::IsAutoIncrementKey($sRootClass)) { + if (empty($this->m_iKey)) { + throw new CoreWarning('Missing key for the object to write - This class is supposed to have a user defined key, not an autonumber', array('class' => $sRootClass)); } } - // - TriggerOnObjectMention - $this->ActivateOnMentionTriggers(true); + list($bRes, $aIssues) = $this->CheckToWrite(false); + if (!$bRes) { + throw new CoreCannotSaveObjectException(array('issues' => $aIssues, 'class' => get_class($this), 'id' => $this->GetKey())); + } + + if ($this->m_iKey < 0) { + // This was a temporary "memory" key: discard it so that DBInsertSingleTable will not try to use it! + $this->m_iKey = null; + } + + $this->ComputeStopWatchesDeadline(true); + + $iTransactionRetry = 1; + $bIsTransactionEnabled = MetaModel::GetConfig()->Get('db_core_transactions_enabled'); + if ($bIsTransactionEnabled) { + // TODO Deep clone this object before the transaction (to use it in case of rollback) + // $iTransactionRetryCount = MetaModel::GetConfig()->Get('db_core_transactions_retry_count'); + $iTransactionRetryCount = 1; + $iTransactionRetryDelay = MetaModel::GetConfig()->Get('db_core_transactions_retry_delay_ms'); + $iTransactionRetry = $iTransactionRetryCount; + } + while ($iTransactionRetry > 0) { + try { + $iTransactionRetry--; + if ($bIsTransactionEnabled) { + CMDBSource::Query('START TRANSACTION'); + } + + // First query built upon on the root class, because the ID must be created first + $this->m_iKey = $this->DBInsertSingleTable($sRootClass); + + // Then do the leaf class, if different from the root class + if ($sClass != $sRootClass) { + $this->DBInsertSingleTable($sClass); + } + + // Then do the other classes + foreach (MetaModel::EnumParentClasses($sClass) as $sParentClass) { + if ($sParentClass == $sRootClass) { + continue; + } + $this->DBInsertSingleTable($sParentClass); + } + + $this->OnObjectKeyReady(); + $this->UpdateCurrentObjectInCrudStack(); + + $this->DBWriteLinks(); + $this->WriteExternalAttributes(); + + // Write object creation history within the transaction + $this->RecordObjCreation(); + + if ($bIsTransactionEnabled) { + CMDBSource::Query('COMMIT'); + } + break; + } + catch (Exception $e) { + IssueLog::Error($e->getMessage()); + if ($bIsTransactionEnabled) { + CMDBSource::Query('ROLLBACK'); + if (!CMDBSource::IsInsideTransaction() && CMDBSource::IsDeadlockException($e)) { + // Deadlock found when trying to get lock; try restarting transaction (only in main transaction) + if ($iTransactionRetry > 0) { + // wait and retry + IssueLog::Error('Insert TRANSACTION Retrying...'); + usleep(random_int(1, 5) * 1000 * $iTransactionRetryDelay * ($iTransactionRetryCount - $iTransactionRetry)); + continue; + } else { + IssueLog::Error('Insert Deadlock TRANSACTION prevention failed.'); + } + } + } + throw $e; + } + } + + $this->m_bIsInDB = true; + $this->m_bDirty = false; + foreach ($this->m_aCurrValues as $sAttCode => $value) { + if (is_object($value)) { + $value = clone $value; + } + $this->m_aOrigValues[$sAttCode] = $value; + } + + // Prevent DBUpdate at this point (reentrance protection) + MetaModel::StartReentranceProtection($this); + + try { + $this->FireEventCreateDone(); + $this->AfterInsert(); + + // Activate any existing trigger + $sClass = get_class($this); + $aParams = array('class_list' => MetaModel::EnumParentClasses($sClass, ENUM_PARENT_CLASSES_ALL)); + $oSet = new DBObjectSet(DBObjectSearch::FromOQL('SELECT TriggerOnObjectCreate AS t WHERE t.target_class IN (:class_list)'), array(), $aParams); + while ($oTrigger = $oSet->Fetch()) { + /** @var \Trigger $oTrigger */ + try { + $oTrigger->DoActivate($this->ToArgs('this')); + } + catch (Exception $e) { + utils::EnrichRaisedException($oTrigger, $e); + } + } + + // - TriggerOnObjectMention + $this->ActivateOnMentionTriggers(true); + } + finally { + MetaModel::StopReentranceProtection($this); + } + + if ($this->IsModified()) { + $this->DBUpdate(); + } } finally { - MetaModel::StopReentranceProtection($this); - } - - if ($this->IsModified()) { - $this->DBUpdate(); + $this->RemoveCurrentObjectInCrudStack(); } return $this->m_iKey; @@ -3167,8 +3192,9 @@ abstract class DBObject implements iDisplay return false; } - try - { + $this->AddCurrentObjectInCrudStack('UPDATE'); + + try { $this->DoComputeValues(); $this->ComputeStopWatchesDeadline(false); $this->OnUpdate(); @@ -3391,6 +3417,7 @@ abstract class DBObject implements iDisplay finally { MetaModel::StopReentranceProtection($this); + $this->RemoveCurrentObjectInCrudStack(); } if ($this->IsModified() || $bModifiedByUpdateDone) { @@ -3781,7 +3808,14 @@ abstract class DBObject implements iDisplay if ($oToDelete->m_bIsInDB) { set_time_limit(intval($iLoopTimeLimit)); - $oToDelete->DBDeleteSingleObject(); + + $oToDelete->AddCurrentObjectInCrudStack('DELETE'); + try { + $oToDelete->DBDeleteSingleObject(); + } + finally { + $oToDelete->RemoveCurrentObjectInCrudStack(); + } } } } @@ -5966,5 +6000,106 @@ abstract class DBObject implements iDisplay protected function FireEventUnArchive(): void { } + + ////////////// + /// CRUD stack in progress + /// + + /** + * Check if an object is currently involved in CRUD operation + * + * @param string $sClass + * @param string|null $sId + * + * @return bool + * @since 3.1.0 N°5609 + */ + final public static function IsObjectCurrentlyInCrud(string $sClass, ?string $sId): bool + { + // during insert key is reset from -1 to null + // so we need to handle null values (will give empty string after conversion) + $sConvertedId = (string)$sId; + + foreach (self::$m_aCrudStack as $aCrudStackEntry) { + if (($sClass === $aCrudStackEntry['class']) + && ($sConvertedId === $aCrudStackEntry['id'])) { + return true; + } + } + + return false; + } + + /** + * Check if an object of the given class is currently involved in CRUD operation + * + * @param string $sClass + * + * @return bool + * @since 3.1.0 N°5609 + */ + final public static function IsClassCurrentlyInCrud(string $sClass): bool + { + foreach (self::$m_aCrudStack as $aCrudStackEntry) { + if ($sClass === $aCrudStackEntry['class']) { + return true; + } + } + + return false; + } + + /** + * Add the current object to the CRUD stack + * + * @param string $sCrudType + * + * @return void + * @since 3.1.0 N°5609 + */ + private function AddCurrentObjectInCrudStack(string $sCrudType): void + { + self::$m_aCrudStack[] = [ + 'type' => $sCrudType, + 'class' => get_class($this), + 'id' => (string)$this->GetKey(), // GetKey() doesn't have type hinting, so forcing type to avoid getting an int + ]; + } + + /** + * Update the last entry of the CRUD stack with the information of the current object + * This is calls during DBInsert since the object id changes + * + * @return void + * @since 3.1.0 N°5609 + */ + private function UpdateCurrentObjectInCrudStack(): void + { + $aCurrentCrudStack = array_pop(self::$m_aCrudStack); + $aCurrentCrudStack['id'] = (string)$this->GetKey(); + self::$m_aCrudStack[] = $aCurrentCrudStack; + } + + /** + * Remove the last entry of the CRUD stack + * + * @return void + * @since 3.1.0 N°5609 + */ + private function RemoveCurrentObjectInCrudStack(): void + { + array_pop(self::$m_aCrudStack); + } + + /** + * Check if there are objects in the CRUD stack + * + * @return bool + * @since 3.1.0 N°5609 + */ + final protected function IsCrudStackEmpty(): bool + { + return count(self::$m_aCrudStack) === 0; + } } diff --git a/datamodels/2.x/itop-tickets/datamodel.itop-tickets.xml b/datamodels/2.x/itop-tickets/datamodel.itop-tickets.xml index a7e7c56f5..11292bff2 100755 --- a/datamodels/2.x/itop-tickets/datamodel.itop-tickets.xml +++ b/datamodels/2.x/itop-tickets/datamodel.itop-tickets.xml @@ -220,7 +220,26 @@ false + + + EVENT_DB_LINKS_CHANGED + UpdateTicketImpactedItems + 0 + + + + + false + public + EventListener + UpdateImpactedItems(); + $this->DBUpdate(); +} + ]]> + false public diff --git a/js/links/links_view_table_widget.js b/js/links/links_view_table_widget.js index 576ed395f..461860a0b 100644 --- a/js/links/links_view_table_widget.js +++ b/js/links/links_view_table_widget.js @@ -36,7 +36,7 @@ $(function() // link object deletion CombodoLinkSetWorker.DeleteLinkedObject(this.options.link_class, sLinkedObjectKey, function (data) { if (data.data.success === true) { - oTrElement.remove(); + me.$tableSettingsDialog.DataTableSettings('DoRefresh'); } else { CombodoModal.OpenInformativeModal(data.data.error_message, 'error'); } @@ -57,7 +57,7 @@ $(function() // link object unlink CombodoLinkSetWorker.DetachLinkedObject(this.options.link_class, sLinkedObjectKey, this.options.external_key_to_me, function (data) { if (data.data.success === true) { - oTrElement.remove(); + me.$tableSettingsDialog.DataTableSettings('DoRefresh'); } else { CombodoModal.OpenInformativeModal(data.data.error_message, 'error'); } diff --git a/sources/Application/Service/Events/EventService.php b/sources/Application/Service/Events/EventService.php index 877dd9b50..1e37ffcde 100644 --- a/sources/Application/Service/Events/EventService.php +++ b/sources/Application/Service/Events/EventService.php @@ -94,12 +94,8 @@ final class EventService }); self::$aEventListeners[$sEvent] = $aEventCallbacks; - $iTotalRegistrations = 0; - foreach (self::$aEventListeners as $aEvent) { - $iTotalRegistrations += count($aEvent); - } - $sLogEventName = "$sEvent:".self::GetSourcesAsString($sEventSource); - EventServiceLog::Trace("Registering event '$sLogEventName' for '$sName' with id '$sId' (total $iTotalRegistrations)"); + $sSource = self::GetSourcesAsString($sEventSource); + EventServiceLog::Debug("Registering Listener '$sName' for event '$sEvent' source '$sSource' from '$sModuleId'"); return $sId; } @@ -133,7 +129,6 @@ final class EventService $sLogEventName = "$sEvent - ".self::GetSourcesAsString($eventSource).' '.json_encode($oEventData->GetEventData()); EventServiceLog::Trace("Fire event '$sLogEventName'"); if (!isset(self::$aEventListeners[$sEvent])) { - EventServiceLog::DebugEvent("No listener for '$sLogEventName'", $sEvent, $eventSource); $oKPI->ComputeStats('FireEvent', $sEvent); return; @@ -141,12 +136,14 @@ final class EventService $oLastException = null; $sLastExceptionMessage = null; + $bEventFired = false; foreach (self::GetListeners($sEvent, $eventSource) as $aEventCallback) { if (!self::MatchContext($aEventCallback['context'])) { continue; } $sName = $aEventCallback['name']; - EventServiceLog::DebugEvent("Fire event '$sLogEventName' calling '$sName'", $sEvent, $eventSource); + EventServiceLog::Debug("Fire event '$sLogEventName' calling '$sName'"); + $bEventFired = true; try { $oEventData->SetCallbackData($aEventCallback['data']); call_user_func($aEventCallback['callback'], $oEventData); @@ -161,7 +158,9 @@ final class EventService $oLastException = $e; } } - EventServiceLog::DebugEvent("End of event '$sLogEventName'", $sEvent, $eventSource); + if ($bEventFired) { + EventServiceLog::Debug("End of event '$sLogEventName'"); + } $oKPI->ComputeStats('FireEvent', $sEvent); if (!is_null($oLastException)) { @@ -176,7 +175,7 @@ final class EventService * * @return array */ - public static function GetListeners(string $sEvent, $eventSource): array + public static function GetListeners(string $sEvent, $eventSource = null): array { $aListeners = []; if (isset(self::$aEventListeners[$sEvent])) { diff --git a/sources/Application/Service/Events/EventServiceLog.php b/sources/Application/Service/Events/EventServiceLog.php index 771e57ef9..376f719e5 100644 --- a/sources/Application/Service/Events/EventServiceLog.php +++ b/sources/Application/Service/Events/EventServiceLog.php @@ -8,38 +8,8 @@ namespace Combodo\iTop\Service\Events; use IssueLog; use LogChannels; -use utils; class EventServiceLog extends IssueLog { const CHANNEL_DEFAULT = LogChannels::EVENT_SERVICE; - - /** - * @param $sMessage - * @param $sEvent - * @param $sources - * - * @return void - * @throws \ConfigException - * @throws \CoreException - */ - public static function DebugEvent($sMessage, $sEvent, $sources) - { - $oConfig = utils::GetConfig(); - $aLogEvents = $oConfig->Get('event_service.debug.filter_events'); - $aLogSources = $oConfig->Get('event_service.debug.filter_sources'); - - if (is_array($aLogEvents)) { - if (!in_array($sEvent, $aLogEvents)) { - return; - } - } - if (is_array($aLogSources)) { - if (!EventHelper::MatchEventSource($aLogSources, $sources)) { - return; - } - } - static::Debug($sMessage); - } - } \ No newline at end of file diff --git a/synchro/synchrodatasource.class.inc.php b/synchro/synchrodatasource.class.inc.php index 467b36ace..570944142 100644 --- a/synchro/synchrodatasource.class.inc.php +++ b/synchro/synchrodatasource.class.inc.php @@ -3535,17 +3535,24 @@ class SynchroExecution $oSetToProcess = $oSetScope; } - $iLastReplicaProcessed = -1; - /** @var \SynchroReplica $oReplica */ - while ($oReplica = $oSetToProcess->Fetch()) - { - set_time_limit(intval($iLoopTimeLimit)); - $iLastReplicaProcessed = $oReplica->GetKey(); - $this->m_oStatLog->AddTrace("Synchronizing replica id=$iLastReplicaProcessed."); - $oReplica->Synchro($this->m_oDataSource, $this->m_aReconciliationKeys, $this->m_aAttributes, $this->m_oChange, - $this->m_oStatLog); - $this->m_oStatLog->AddTrace("Updating replica id=$iLastReplicaProcessed."); - $oReplica->DBUpdate(); + // Avoid too many events + cmdbAbstractObject::SetEventDBLinksChangedBlocked(true); + try { + $iLastReplicaProcessed = -1; + /** @var \SynchroReplica $oReplica */ + while ($oReplica = $oSetToProcess->Fetch()) { + set_time_limit(intval($iLoopTimeLimit)); + $iLastReplicaProcessed = $oReplica->GetKey(); + $this->m_oStatLog->AddTrace("Synchronizing replica id=$iLastReplicaProcessed."); + $oReplica->Synchro($this->m_oDataSource, $this->m_aReconciliationKeys, $this->m_aAttributes, $this->m_oChange, + $this->m_oStatLog); + $this->m_oStatLog->AddTrace("Updating replica id=$iLastReplicaProcessed."); + $oReplica->DBUpdate(); + } + } finally { + // Send all the retained events for further computations + cmdbAbstractObject::SetEventDBLinksChangedBlocked(false); + cmdbAbstractObject::FireEventDbLinksChangedForAllObjects(); } if ($iMaxReplica) diff --git a/tests/php-unit-tests/ItopTestCase.php b/tests/php-unit-tests/ItopTestCase.php index eb20d234b..ea02aa365 100644 --- a/tests/php-unit-tests/ItopTestCase.php +++ b/tests/php-unit-tests/ItopTestCase.php @@ -155,7 +155,7 @@ class ItopTestCase extends TestCase /** * @param string $sObjectClass for example DBObject::class * @param string $sMethodName - * @param object $oObject + * @param ?object $oObject * @param array $aArgs * * @return mixed method result @@ -174,50 +174,79 @@ class ItopTestCase extends TestCase } + /** + * @since 3.1.0 + */ + public function GetNonPublicStaticProperty(string $sClass, string $sProperty) + { + /** @noinspection OneTimeUseVariablesInspection */ + $oProperty = $this->GetProperty($sClass, $sProperty); + + return $oProperty->getValue(); + } + /** * @param object $oObject * @param string $sProperty * * @return mixed property * - * @throws \ReflectionException * @since 2.7.8 3.0.3 3.1.0 */ public function GetNonPublicProperty(object $oObject, string $sProperty) { - $class = new \ReflectionClass(get_class($oObject)); + /** @noinspection OneTimeUseVariablesInspection */ + $oProperty = $this->GetProperty(get_class($oObject), $sProperty); + + return $oProperty->getValue($oObject); + } + + /** + * @since 3.1.0 + */ + private function GetProperty(string $sClass, string $sProperty): \ReflectionProperty + { + $class = new \ReflectionClass($sClass); $property = $class->getProperty($sProperty); $property->setAccessible(true); - return $property->getValue($oObject); + return $property; } + /** * @param object $oObject * @param string $sProperty * @param $value * - * @throws \ReflectionException * @since 2.7.8 3.0.3 3.1.0 */ public function SetNonPublicProperty(object $oObject, string $sProperty, $value) { - $class = new \ReflectionClass(get_class($oObject)); - $property = $class->getProperty($sProperty); - $property->setAccessible(true); - - $property->setValue($oObject, $value); + $oProperty = $this->GetProperty(get_class($oObject), $sProperty); + $oProperty->setValue($oObject, $value); } - public function RecurseRmdir($dir) { + /** + * @since 3.1.0 + */ + public function SetNonPublicStaticProperty(string $sClass, string $sProperty, $value) + { + $oProperty = $this->GetProperty($sClass, $sProperty); + $oProperty->setValue($value); + } + + public function RecurseRmdir($dir) + { if (is_dir($dir)) { $objects = scandir($dir); foreach ($objects as $object) { if ($object != "." && $object != "..") { - if (is_dir($dir.DIRECTORY_SEPARATOR.$object)) + if (is_dir($dir.DIRECTORY_SEPARATOR.$object)) { $this->RecurseRmdir($dir.DIRECTORY_SEPARATOR.$object); - else + } else { unlink($dir.DIRECTORY_SEPARATOR.$object); + } } } rmdir($dir); diff --git a/tests/php-unit-tests/unitary-tests/application/cmdbAbstractObjectTest.php b/tests/php-unit-tests/unitary-tests/application/cmdbAbstractObjectTest.php new file mode 100644 index 000000000..f2395d9a0 --- /dev/null +++ b/tests/php-unit-tests/unitary-tests/application/cmdbAbstractObjectTest.php @@ -0,0 +1,479 @@ +GetObjectsAwaitingFireEventDbLinksChanged(); + $this->assertSame([], $aLinkModificationsStack); + + // retain events + cmdbAbstractObject::SetEventDBLinksChangedBlocked(true); + + // Create the person + $oPerson = $this->CreatePerson(1); + $this->assertIsObject($oPerson); + // Create the team + $oTeam = MetaModel::NewObject(Team::class, ['name' => 'TestTeam1', 'org_id' => $this->getTestOrgId()]); + $oTeam->DBInsert(); + // contact types + $oContactType1 = MetaModel::NewObject(ContactType::class, ['name' => 'test_'.rand(10000, 99999)]); + $oContactType1->DBInsert(); + $oContactType2 = MetaModel::NewObject(ContactType::class, ['name' => 'test_'.rand(10000, 99999)]); + $oContactType2->DBInsert(); + + // Prepare the link for the insertion with the team + + $aValues = [ + 'person_id' => $oPerson->GetKey(), + 'role_id' => $oContactType1->GetKey(), + 'team_id' => $oTeam->GetKey(), + ]; + $oLinkPersonToTeam1 = MetaModel::NewObject(lnkPersonToTeam::class, $aValues); + $oLinkPersonToTeam1->DBInsert(); + + $aLinkModificationsStack = $this->GetObjectsAwaitingFireEventDbLinksChanged(); + self::assertCount(3, $aLinkModificationsStack); + $aExpectedLinkStack = [ + 'Team' => [$oTeam->GetKey() => 1], + 'Person' => [$oPerson->GetKey() => 1], + 'ContactType' => [$oContactType1->GetKey() => 1], + ]; + self::assertSame($aExpectedLinkStack, $aLinkModificationsStack); + + $oLinkPersonToTeam1->Set('role_id', $oContactType2->GetKey()); + $oLinkPersonToTeam1->DBWrite(); + $aLinkModificationsStack = $this->GetObjectsAwaitingFireEventDbLinksChanged(); + self::assertCount(3, $aLinkModificationsStack); + $aExpectedLinkStack = [ + 'Team' => [$oTeam->GetKey() => 2], + 'Person' => [$oPerson->GetKey() => 2], + 'ContactType' => [ + $oContactType1->GetKey() => 2, + $oContactType2->GetKey() => 1, + ], + ]; + self::assertSame($aExpectedLinkStack, $aLinkModificationsStack); + } + + public function testProcessClassIdDeferredUpdate() + { + // Create the team + $oTeam = MetaModel::NewObject(Team::class, ['name' => 'TestTeam1', 'org_id' => $this->getTestOrgId()]); + $oTeam->DBInsert(); + + // --- Simulating modifications of : + // - lnkPersonToTeam:1 is sample data with : team_id=39 ; person_id=9 ; role_id=3 + // - lnkPersonToTeam:2 is sample data with : team_id=39 ; person_id=14 ; role_id=0 + $aLinkStack = [ + 'Team' => [$oTeam->GetKey() => 2], + 'Person' => [ + '9' => 1, + '14' => 1, + ], + 'ContactType' => [ + '1' => 1, + '0' => 1, + ], + ]; + $this->SetObjectsAwaitingFireEventDbLinksChanged($aLinkStack); + + // Processing deferred updates for Team + $this->InvokeNonPublicMethod(get_class($oTeam), 'FireEventDbLinksChangedForCurrentObject', $oTeam, []); + $aLinkModificationsStack = $this->GetObjectsAwaitingFireEventDbLinksChanged(); + $aExpectedLinkStack = [ + 'Team' => [], + 'Person' => [ + '9' => 1, + '14' => 1, + ], + 'ContactType' => [ + '1' => 1, + '0' => 1, + ], + ]; + self::assertSame($aExpectedLinkStack, $aLinkModificationsStack); + + + // --- Simulating modifications of : + // - lnkApplicationSolutionToFunctionalCI::2 : applicationsolution_id=13 ; functionalci_id=29 + // - lnkApplicationSolutionToFunctionalCI::8 : applicationsolution_id=13 ; functionalci_id=27 + // The lnkApplicationSolutionToFunctionalCI points on root classes, so we can test unstacking for a leaf class + $aLinkStack = [ + 'ApplicationSolution' => ['13' => 2], + 'FunctionalCI' => [ + '29' => 1, + '27' => 1, + ], + ]; + $this->SetObjectsAwaitingFireEventDbLinksChanged($aLinkStack); + + // Processing deferred updates for WebServer::29 + /** @var \cmdbAbstractObject $oLinkPersonToTeam1 */ + $oWebServer29 = MetaModel::GetObject(WebServer::class, 29); + $this->InvokeNonPublicMethod(get_class($oWebServer29), 'FireEventDbLinksChangedForCurrentObject', $oWebServer29, []); + $aLinkModificationsStack = $this->GetObjectsAwaitingFireEventDbLinksChanged(); + $aExpectedLinkStack = [ + 'ApplicationSolution' => ['13' => 2], + 'FunctionalCI' => [ + '27' => 1, + ], + ]; + self::assertSame($aExpectedLinkStack, $aLinkModificationsStack); + } + + private function GetObjectsAwaitingFireEventDbLinksChanged(): array + { + return $this->GetNonPublicStaticProperty(cmdbAbstractObject::class, 'aObjectsAwaitingEventDbLinksChanged'); + } + + private function SetObjectsAwaitingFireEventDbLinksChanged(array $aObjects): void + { + $this->SetNonPublicStaticProperty(cmdbAbstractObject::class, 'aObjectsAwaitingEventDbLinksChanged', $aObjects); + } + + /** + * Check that EVENT_DB_LINKS_CHANGED events are not sent to the current updated/created object (Team) + * the events are sent to the other side (Person) + * + * @return void + * @throws \ArchivedObjectException + * @throws \CoreCannotSaveObjectException + * @throws \CoreException + * @throws \CoreUnexpectedValue + * @throws \CoreWarning + * @throws \MySQLException + * @throws \OQLException + */ + public function testDBInsertTeam() + { + // Prepare the link set + $sLinkedClass = lnkPersonToTeam::class; + $aLinkedObjectsArray = []; + $oSet = DBObjectSet::FromArray($sLinkedClass, $aLinkedObjectsArray); + $oLinkSet = new ormLinkSet(Team::class, 'persons_list', $oSet); + + // Create the 3 persons + for ($i = 0; $i < 3; $i++) { + $oPerson = $this->CreatePerson($i); + $this->assertIsObject($oPerson); + // Add the person to the link + $oLink = MetaModel::NewObject(lnkPersonToTeam::class, ['person_id' => $oPerson->GetKey()]); + $oLinkSet->AddItem($oLink); + } + + $this->debug("\n-------------> Test Starts HERE\n"); + + $oEventReceiver = new LinksEventReceiver(); + $oEventReceiver->RegisterCRUDListeners(); + + $oTeam = MetaModel::NewObject(Team::class, ['name' => 'TestTeam1', 'persons_list' => $oLinkSet, 'org_id' => $this->getTestOrgId()]); + $oTeam->DBInsert(); + $this->assertIsObject($oTeam); + + // 3 links added to person (the Team side is ignored) + $this->assertEquals(3, self::$aEventCalls[EVENT_DB_LINKS_CHANGED]); + } + + /** + * Check that EVENT_DB_LINKS_CHANGED events are sent to all the linked objects when creating a new lnk object + * + * @return void + * @throws \ArchivedObjectException + * @throws \CoreCannotSaveObjectException + * @throws \CoreException + * @throws \CoreUnexpectedValue + * @throws \CoreWarning + * @throws \MySQLException + * @throws \OQLException + */ + public function testAddLinkToTeam() + { + // Create a person + $oPerson = $this->CreatePerson(1); + $this->assertIsObject($oPerson); + + // Create a Team + $oTeam = MetaModel::NewObject(Team::class, ['name' => 'TestTeam1', 'org_id' => $this->getTestOrgId()]); + $oTeam->DBInsert(); + $this->assertIsObject($oTeam); + + $this->debug("\n-------------> Test Starts HERE\n"); + $oEventReceiver = new LinksEventReceiver(); + $oEventReceiver->RegisterCRUDListeners(); + + // The link creation will signal both the Person an the Team + $oLink = MetaModel::NewObject(lnkPersonToTeam::class, ['person_id' => $oPerson->GetKey(), 'team_id' => $oTeam->GetKey()]); + $oLink->DBInsert(); + + // 2 events one for Person and One for Team + $this->assertEquals(2, self::$aEventCalls[EVENT_DB_LINKS_CHANGED]); + } + + /** + * Check that EVENT_DB_LINKS_CHANGED events are sent to all the linked objects when updating an existing lnk object + * + * @return void + * @throws \ArchivedObjectException + * @throws \CoreCannotSaveObjectException + * @throws \CoreException + * @throws \CoreUnexpectedValue + * @throws \CoreWarning + * @throws \MySQLException + * @throws \OQLException + */ + public function testUpdateLinkRole() + { + // Create a person + $oPerson = $this->CreatePerson(1); + $this->assertIsObject($oPerson); + + // Create a Team + $oTeam = MetaModel::NewObject(Team::class, ['name' => 'TestTeam1', 'org_id' => $this->getTestOrgId()]); + $oTeam->DBInsert(); + $this->assertIsObject($oTeam); + + // Create the link + $oLink = MetaModel::NewObject(lnkPersonToTeam::class, ['person_id' => $oPerson->GetKey(), 'team_id' => $oTeam->GetKey()]); + $oLink->DBInsert(); + + $this->debug("\n-------------> Test Starts HERE\n"); + $oEventReceiver = new LinksEventReceiver(); + $oEventReceiver->RegisterCRUDListeners(); + + // The link update will signal both the Person, the Team and the ContactType + // Change the role + $oContactType = MetaModel::NewObject(ContactType::class, ['name' => 'test_'.$oLink->GetKey()]); + $oContactType->DBInsert(); + $oLink->Set('role_id', $oContactType->GetKey()); + $oLink->DBUpdate(); + + // 3 events one for Person, one for Team and one for ContactType + $this->assertEquals(3, self::$aEventCalls[EVENT_DB_LINKS_CHANGED]); + } + + /** + * Check that when a link changes from an object to another, then both objects are notified + * + * @return void + * @throws \ArchivedObjectException + * @throws \CoreCannotSaveObjectException + * @throws \CoreException + * @throws \CoreUnexpectedValue + * @throws \CoreWarning + * @throws \MySQLException + * @throws \OQLException + */ + public function testUpdateLinkPerson() + { + // Create 2 person + $oPerson1 = $this->CreatePerson(1); + $this->assertIsObject($oPerson1); + + $oPerson2 = $this->CreatePerson(2); + $this->assertIsObject($oPerson2); + + // Create a Team + $oTeam = MetaModel::NewObject(Team::class, ['name' => 'TestTeam1', 'org_id' => $this->getTestOrgId()]); + $oTeam->DBInsert(); + $this->assertIsObject($oTeam); + + // Create the link between Person1 and Team + $oLink = MetaModel::NewObject(lnkPersonToTeam::class, ['person_id' => $oPerson1->GetKey(), 'team_id' => $oTeam->GetKey()]); + $oLink->DBInsert(); + + $this->debug("\n-------------> Test Starts HERE\n"); + $oEventReceiver = new LinksEventReceiver(); + $oEventReceiver->RegisterCRUDListeners(); + + // The link update will signal both the Persons and the Team + // Change the person + $oLink->Set('person_id', $oPerson2->GetKey()); + $oLink->DBUpdate(); + + // 3 events 2 for Person, one for Team + $this->assertEquals(3, self::$aEventCalls[EVENT_DB_LINKS_CHANGED]); + } + + /** + * Check that EVENT_DB_LINKS_CHANGED events are sent to all the linked objects when deleting an existing lnk object + * + * @return void + * @throws \ArchivedObjectException + * @throws \CoreCannotSaveObjectException + * @throws \CoreException + * @throws \CoreUnexpectedValue + * @throws \CoreWarning + * @throws \MySQLException + * @throws \OQLException + */ + public function testDeleteLink() + { + // Create a person + $oPerson = $this->CreatePerson(1); + $this->assertIsObject($oPerson); + + // Create a Team + $oTeam = MetaModel::NewObject(Team::class, ['name' => 'TestTeam1', 'org_id' => $this->getTestOrgId()]); + $oTeam->DBInsert(); + $this->assertIsObject($oTeam); + + // Create the link + $oLink = MetaModel::NewObject(lnkPersonToTeam::class, ['person_id' => $oPerson->GetKey(), 'team_id' => $oTeam->GetKey()]); + $oLink->DBInsert(); + + $this->debug("\n-------------> Test Starts HERE\n"); + $oEventReceiver = new LinksEventReceiver(); + $oEventReceiver->RegisterCRUDListeners(); + + // The link delete will signal both the Person an the Team + $oLink->DBDelete(); + + // 3 events one for Person, one for Team + $this->assertEquals(2, self::$aEventCalls[EVENT_DB_LINKS_CHANGED]); + } + + /** + * Debug called by event receivers + * + * @param $sMsg + * + * @return void + */ + public static function DebugStatic($sMsg) + { + if (static::$DEBUG_UNIT_TEST) { + if (is_string($sMsg)) { + echo "$sMsg\n"; + } else { + print_r($sMsg); + } + } + } +} + + +/** + * Count events received + * And allow callbacks on events + */ +class LinksEventReceiver +{ + private $aCallbacks = []; + + public static $bIsObjectInCrudStack; + + public function AddCallback(string $sEvent, string $sClass, string $sFct, int $iCount = 1): void + { + $this->aCallbacks[$sEvent][$sClass] = [ + 'callback' => [$this, $sFct], + 'count' => $iCount, + ]; + } + + public function CleanCallbacks() + { + $this->aCallbacks = []; + } + + // Event callbacks + public function OnEvent(EventData $oData) + { + $sEvent = $oData->GetEvent(); + $oObject = $oData->Get('object'); + $sClass = get_class($oObject); + $iKey = $oObject->GetKey(); + $this->Debug(__METHOD__.": received event '$sEvent' for $sClass::$iKey"); + cmdbAbstractObjectTest::IncrementCallCount($sEvent); + + if (isset($this->aCallbacks[$sEvent][$sClass])) { + $aCallBack = $this->aCallbacks[$sEvent][$sClass]; + if ($aCallBack['count'] > 0) { + $this->aCallbacks[$sEvent][$sClass]['count']--; + call_user_func($this->aCallbacks[$sEvent][$sClass]['callback'], $oObject); + } + } + } + + public function RegisterCRUDListeners(string $sEvent = null, $mEventSource = null) + { + $this->Debug('Registering Test event listeners'); + if (is_null($sEvent)) { + EventService::RegisterListener(EVENT_DB_LINKS_CHANGED, [$this, 'OnEvent']); + return; + } + EventService::RegisterListener($sEvent, [$this, 'OnEvent'], $mEventSource); + } + + /** + * @param $oObject + * + * @return void + * @throws \ArchivedObjectException + * @throws \CoreCannotSaveObjectException + * @throws \CoreException + * @throws \CoreUnexpectedValue + * @throws \CoreWarning + * @throws \MySQLException + * @throws \OQLException + */ + private function AddRoleToLink($oObject): void + { + $this->Debug(__METHOD__); + $oContactType = MetaModel::NewObject(ContactType::class, ['name' => 'test_'.$oObject->GetKey()]); + $oContactType->DBInsert(); + $oObject->Set('role_id', $oContactType->GetKey()); + } + + private function SetPersonFunction($oObject): void + { + $this->Debug(__METHOD__); + $oObject->Set('function', 'CRUD_function_'.rand()); + } + + private function SetPersonFirstName($oObject): void + { + $this->Debug(__METHOD__); + $oObject->Set('first_name', 'CRUD_first_name_'.rand()); + } + + private function CheckCrudStack(DBObject $oObject): void + { + self::$bIsObjectInCrudStack = DBObject::IsObjectCurrentlyInCrud(get_class($oObject), $oObject->GetKey()); + } + + private function CheckUpdateInLnk(lnkPersonToTeam $oLnkPersonToTeam) + { + $iTeamId = $oLnkPersonToTeam->Get('team_id'); + self::$bIsObjectInCrudStack = DBObject::IsObjectCurrentlyInCrud(Team::class, $iTeamId); + } + + private function Debug($msg) + { + cmdbAbstractObjectTest::DebugStatic($msg); + } +} diff --git a/tests/php-unit-tests/unitary-tests/core/CRUDEventTest.php b/tests/php-unit-tests/unitary-tests/core/CRUDEventTest.php index 0a4f0949f..c54dcbdd1 100644 --- a/tests/php-unit-tests/unitary-tests/core/CRUDEventTest.php +++ b/tests/php-unit-tests/unitary-tests/core/CRUDEventTest.php @@ -11,6 +11,7 @@ use Combodo\iTop\Service\Events\EventService; use Combodo\iTop\Test\UnitTest\ItopDataTestCase; use ContactType; use CoreException; +use DBObject; use DBObjectSet; use DBSearch; use lnkPersonToTeam; @@ -440,6 +441,45 @@ class CRUDEventTest extends ItopDataTestCase } } } + + public function testCrudStack() + { + $oEventReceiver = new CRUDEventReceiver(); + // Modify the person's function + $oEventReceiver->AddCallback(EVENT_DB_COMPUTE_VALUES, Person::class, 'CheckCrudStack'); + $oEventReceiver->RegisterCRUDListeners(EVENT_DB_COMPUTE_VALUES); + $oPerson1 = $this->CreatePerson(1); + $this->assertTrue(CRUDEventReceiver::$bIsObjectInCrudStack); + $oEventReceiver->CleanCallbacks(); + + $oEventReceiver->AddCallback(EVENT_DB_CHECK_TO_WRITE, Person::class, 'CheckCrudStack'); + $oEventReceiver->RegisterCRUDListeners(EVENT_DB_CHECK_TO_WRITE); + $this->CreatePerson(2); + $this->assertTrue(CRUDEventReceiver::$bIsObjectInCrudStack); + $oEventReceiver->CleanCallbacks(); + + $oEventReceiver->AddCallback(EVENT_DB_CREATE_DONE, Person::class, 'CheckCrudStack'); + $oEventReceiver->RegisterCRUDListeners(EVENT_DB_CREATE_DONE); + $this->CreatePerson(3); + $this->assertTrue(CRUDEventReceiver::$bIsObjectInCrudStack); + $oEventReceiver->CleanCallbacks(); + + // Insert a Team with new lnkPersonToTeam - in the lnkPersonToTeam event we check that Team CRUD operation is ongoing + $oEventReceiver->AddCallback(EVENT_DB_CREATE_DONE, Person::class, 'CheckUpdateInLnk'); + $sLinkedClass = lnkPersonToTeam::class; + $oEventReceiver->RegisterCRUDListeners(EVENT_DB_CREATE_DONE, $sLinkedClass); + // Prepare the link for the insertion with the team + $aLinkedObjectsArray = []; + $oSet = DBObjectSet::FromArray($sLinkedClass, $aLinkedObjectsArray); + $oLinkSet = new ormLinkSet(Team::class, 'persons_list', $oSet); + $oLink = MetaModel::NewObject(lnkPersonToTeam::class, ['person_id' => $oPerson1->GetKey()]); + $oLinkSet->AddItem($oLink); + // Create the team + $oTeam = MetaModel::NewObject(Team::class, ['name' => 'TestTeamWithLinkToAPerson', 'persons_list' => $oLinkSet, 'org_id' => $this->getTestOrgId()]); + $oTeam->DBInsert(); + $this->assertTrue(CRUDEventReceiver::$bIsObjectInCrudStack); + } + } @@ -472,6 +512,8 @@ class CRUDEventReceiver extends ClassesWithDebug { private $aCallbacks = []; + public static $bIsObjectInCrudStack; + public function AddCallback(string $sEvent, string $sClass, string $sFct, int $iCount = 1): void { $this->aCallbacks[$sEvent][$sClass] = [ @@ -480,6 +522,11 @@ class CRUDEventReceiver extends ClassesWithDebug ]; } + public function CleanCallbacks() + { + $this->aCallbacks = []; + } + // Event callbacks public function OnEvent(EventData $oData) { @@ -499,7 +546,7 @@ class CRUDEventReceiver extends ClassesWithDebug } } - public function RegisterCRUDListeners(string $sEvent = null) + public function RegisterCRUDListeners(string $sEvent = null, $mEventSource = null) { $this->Debug('Registering Test event listeners'); if (is_null($sEvent)) { @@ -512,7 +559,7 @@ class CRUDEventReceiver extends ClassesWithDebug return; } - EventService::RegisterListener($sEvent, [$this, 'OnEvent']); + EventService::RegisterListener($sEvent, [$this, 'OnEvent'], $mEventSource); } /** @@ -547,4 +594,14 @@ class CRUDEventReceiver extends ClassesWithDebug $oObject->Set('first_name', 'CRUD_first_name_'.rand()); } + private function CheckCrudStack(DBObject $oObject): void + { + self::$bIsObjectInCrudStack = DBObject::IsObjectCurrentlyInCrud(get_class($oObject), $oObject->GetKey()); + } + + private function CheckUpdateInLnk(lnkPersonToTeam $oLnkPersonToTeam) + { + $iTeamId = $oLnkPersonToTeam->Get('team_id'); + self::$bIsObjectInCrudStack = DBObject::IsObjectCurrentlyInCrud(Team::class, $iTeamId); + } }