diff --git a/core/metamodel.class.php b/core/metamodel.class.php index 4953299f1..08a1bb3e8 100644 --- a/core/metamodel.class.php +++ b/core/metamodel.class.php @@ -30,6 +30,7 @@ require_once APPROOT.'core/computing.inc.php'; require_once APPROOT.'core/relationgraph.class.inc.php'; require_once APPROOT.'core/apc-compat.php'; require_once APPROOT.'core/expressioncache.class.inc.php'; +require_once APPROOT.'core/metamodel/IncludeFileReader.php'; /** * We need to have all iLoginFSMExtension/iLoginUIExtension impl loaded ! Cannot use autoloader... @@ -288,6 +289,10 @@ abstract class MetaModel * @var array */ private static $m_aRootClasses = []; + + //used to populate DM create_in field for classes declared in 'datamodel' + public static array $m_aCreatedIn = []; + /** * array of ("classname" => array of "parentclass") * @@ -472,7 +477,7 @@ abstract class MetaModel { self::_check_subclass($sClass); - return self::$m_aClassParams[$sClass]["created_in"] ?? ""; + return self::$m_aClassParams[$sClass]['created_in'] ?? ""; } /** @@ -3185,6 +3190,10 @@ abstract class MetaModel self::$m_aParentClasses[$sClass] = []; self::$m_aChildClasses[$sClass] = []; + if (! array_key_exists('created_in', $aParams)) { + $aParams['created_in'] = self::$m_aCreatedIn[$sClass] ?? ''; + } + self::$m_aClassParams[$sClass] = $aParams; self::$m_aAttribDefs[$sClass] = []; @@ -5864,6 +5873,7 @@ abstract class MetaModel // todo - verifier que toutes les classes mentionnees ici sont chargees dans InitClasses() self::$m_aExtensionClassNames = $result['m_aExtensionClassNames']; self::$m_Category2Class = $result['m_Category2Class']; + self::$m_aCreatedIn = $result['m_aCreatedIn']; self::$m_aRootClasses = $result['m_aRootClasses']; self::$m_aParentClasses = $result['m_aParentClasses']; self::$m_aChildClasses = $result['m_aChildClasses']; @@ -5899,6 +5909,7 @@ abstract class MetaModel $aCache = []; $aCache['m_aExtensionClassNames'] = self::$m_aExtensionClassNames; $aCache['m_Category2Class'] = self::$m_Category2Class; + $aCache['m_aCreatedIn'] = self::$m_aCreatedIn; // array of "classname" => "created_in" $aCache['m_aRootClasses'] = self::$m_aRootClasses; // array of "classname" => "rootclass" $aCache['m_aParentClasses'] = self::$m_aParentClasses; // array of ("classname" => array of "parentclass") $aCache['m_aChildClasses'] = self::$m_aChildClasses; // array of ("classname" => array of "childclass") @@ -5999,11 +6010,12 @@ abstract class MetaModel /** * @param string $sToInclude - * @param string $sModuleType + * @param string|null $sModuleType + * @param string|null $sModuleName * * @throws \CoreException */ - public static function IncludeModule($sToInclude, $sModuleType = null) + public static function IncludeModule($sToInclude, $sModuleType = null, $sModuleName = null) { $sFirstChar = substr($sToInclude, 0, 1); $sSecondChar = substr($sToInclude, 1, 1); @@ -6034,6 +6046,17 @@ abstract class MetaModel } } + if (! is_null($sModuleName)) { + try { + $aClasses = IncludeFileReader::GetInstance()->GetClasses($sFile); + foreach ($aClasses as $sFoundClass) { + MetaModel::$m_aCreatedIn[$sFoundClass] = $sModuleName; + } + } catch (\Exception $e) { + SetupLog::Error(__METHOD__, null, [$e]); + } + } + // Note: We do not expect the modules to output characters while loading them. // Therefore, and because unexpected characters can corrupt the output, // they must be trashed here. diff --git a/core/metamodel/IncludeFileReader.php b/core/metamodel/IncludeFileReader.php new file mode 100644 index 000000000..8cc0b2d60 --- /dev/null +++ b/core/metamodel/IncludeFileReader.php @@ -0,0 +1,52 @@ +createForNewestSupportedVersion(); + $aNodes = $oParser->parse(file_get_contents($sPath)); + } catch (Error $e) { + throw new Exception("PHP Class Parsing of $sPath caused an exception: ".$e->getMessage(), 0, $e); + } + + $aRes = []; + try { + foreach ($aNodes as $sKey => $oNode) { + if ($oNode instanceof PhpParser\Node\Stmt\Class_) { + /** @var PhpParser\Node\Stmt\Class_ $oNode */ + //var_dump($oNode->name); + $aRes[] = $oNode->name->name; + } + + } + return $aRes; + } catch (Exception $e) { + throw new Exception("PHP Class Discovery of $sPath caused an exception: ".$e->getMessage(), 0, $e); + } + } +} diff --git a/setup/compiler.class.inc.php b/setup/compiler.class.inc.php index edbc37214..823ad8980 100644 --- a/setup/compiler.class.inc.php +++ b/setup/compiler.class.inc.php @@ -626,7 +626,7 @@ EOF; // files to include (PHP datamodels) foreach ($oModule->GetFilesToInclude('business') as $sRelFileName) { if (file_exists("{$sTempTargetDir}/{$sRelativeDir}/{$sRelFileName}")) { - $aDataModelFiles[] = "MetaModel::IncludeModule(MODULESROOT.'/$sRelativeDir/$sRelFileName');"; + $aDataModelFiles[] = sprintf("MetaModel::IncludeModule(MODULESROOT.'/$sRelativeDir/$sRelFileName', null, '%s');", $oModule->GetName()); } else { /** @noinspection NestedPositiveIfStatementsInspection */ if (utils::IsDevelopmentEnvironment()) { @@ -643,7 +643,7 @@ EOF; } // files to include (PHP webservices providers) foreach ($oModule->GetFilesToInclude('webservices') as $sRelFileName) { - $aWebservicesFiles[] = "MetaModel::IncludeModule(MODULESROOT.'/$sRelativeDir/$sRelFileName');"; + $aWebservicesFiles[] = sprintf("MetaModel::IncludeModule(MODULESROOT.'/$sRelativeDir/$sRelFileName', null, '%s');", $oModule->GetName()); } } // foreach module diff --git a/tests/php-static-analysis/config/base.dist.neon b/tests/php-static-analysis/config/base.dist.neon index eaf30b872..3c1d8a66f 100644 --- a/tests/php-static-analysis/config/base.dist.neon +++ b/tests/php-static-analysis/config/base.dist.neon @@ -1,7 +1,7 @@ includes: - php-includes/set-php-version-from-process.php # Workaround to set PHP version to the on running the CLI # for an explanation of the baseline concept, see: https://phpstan.org/user-guide/baseline - #baseline HERE DO NOT REMOVE FOR CI + #baseline HERE DO NOT REMOVE FOR CI parameters: level: 0 diff --git a/tests/php-unit-tests/unitary-tests/core/MetaModelTest.php b/tests/php-unit-tests/unitary-tests/core/MetaModelTest.php index 74c947731..c9cb24ea6 100644 --- a/tests/php-unit-tests/unitary-tests/core/MetaModelTest.php +++ b/tests/php-unit-tests/unitary-tests/core/MetaModelTest.php @@ -498,6 +498,26 @@ class MetaModelTest extends ItopDataTestCase 'Purge 10 items with a max_chunk_size of 1000 (default value) should be perfomed in 1 step' => [1000, 3], ]; } + + public function testGetCreatedIn_ClassComingFromCorePhpFile() + { + $this->assertEquals('', MetaModel::GetCreatedIn('BackgroundTask')); + } + + public function testGetCreatedIn_ClassComingFromCorePhpFile2() + { + $this->assertEquals('core', MetaModel::GetCreatedIn('lnkActionNotificationToContact')); + } + + public function testGetCreatedIn_ClassComingFromModulePhpFile() + { + $this->assertEquals('itop-attachments', MetaModel::GetCreatedIn('CMDBChangeOpAttachmentAdded')); + } + + public function testGetCreatedIn_ClassComingFromXmlDataModelFile() + { + $this->assertEquals('authent-ldap', MetaModel::GetCreatedIn('UserLDAP')); + } } abstract class Wizzard diff --git a/tests/php-unit-tests/unitary-tests/core/metamodel/IncludeFileReaderTest.php b/tests/php-unit-tests/unitary-tests/core/metamodel/IncludeFileReaderTest.php new file mode 100644 index 000000000..e6c0c4fec --- /dev/null +++ b/tests/php-unit-tests/unitary-tests/core/metamodel/IncludeFileReaderTest.php @@ -0,0 +1,22 @@ +RequireOnceItopFile('core/metamodel/IncludeFileReader.php'); + } + + public function testGetClasses() + { + $expected = [ + 'iTopOwnershipToken', + 'iTopOwnershipLock', + ]; + $this->assertEquals($expected, IncludeFileReader::GetInstance()->GetClasses(__DIR__.'/resources/ownershiplock.class.inc.php')); + } +} diff --git a/tests/php-unit-tests/unitary-tests/core/metamodel/resources/ownershiplock.class.inc.php b/tests/php-unit-tests/unitary-tests/core/metamodel/resources/ownershiplock.class.inc.php new file mode 100644 index 000000000..a72131adc --- /dev/null +++ b/tests/php-unit-tests/unitary-tests/core/metamodel/resources/ownershiplock.class.inc.php @@ -0,0 +1,328 @@ + + +/** + * Mechanism to obtain an exclusive lock while editing an object + * + * @package iTopORM + */ + +/** + * Persistent storage (in the database) for remembering that an object is locked + */ +class iTopOwnershipToken extends DBObject +{ + public static function Init() + { + $aParams = + [ + 'category' => '', + 'key_type' => 'autoincrement', + 'name_attcode' => ['obj_class', 'obj_key'], + 'state_attcode' => '', + 'reconc_keys' => [''], + 'db_table' => 'priv_ownership_token', + 'db_key_field' => 'id', + 'db_finalclass_field' => '', + ]; + MetaModel::Init_Params($aParams); + MetaModel::Init_InheritAttributes(); + MetaModel::Init_AddAttribute(new AttributeDateTime("acquired", ["allowed_values" => null, "sql" => 'acquired', "default_value" => 'NOW()', "is_null_allowed" => false, "depends_on" => []])); + MetaModel::Init_AddAttribute(new AttributeDateTime("last_seen", ["allowed_values" => null, "sql" => 'last_seen', "default_value" => 'NOW()', "is_null_allowed" => false, "depends_on" => []])); + MetaModel::Init_AddAttribute(new AttributeString("obj_class", ["allowed_values" => null, "sql" => 'obj_class', "default_value" => '', "is_null_allowed" => false, "depends_on" => []])); + MetaModel::Init_AddAttribute(new AttributeInteger("obj_key", ["allowed_values" => null, "sql" => 'obj_key', "default_value" => '', "is_null_allowed" => true, "depends_on" => []])); + MetaModel::Init_AddAttribute(new AttributeString("token", ["allowed_values" => null, "sql" => 'token', "default_value" => '', "is_null_allowed" => true, "depends_on" => []])); + MetaModel::Init_AddAttribute(new AttributeExternalKey("user_id", ["targetclass" => "User", "jointype" => '', "allowed_values" => null, "sql" => "user_id", "is_null_allowed" => true, "on_target_delete" => DEL_SILENT, "depends_on" => []])); + + MetaModel::Init_SetZListItems('details', ['obj_class', 'obj_key', 'last_seen', 'token']); + MetaModel::Init_SetZListItems('standard_search', ['obj_class', 'obj_key', 'last_seen', 'token']); + MetaModel::Init_SetZListItems('list', ['obj_class', 'obj_key', 'last_seen', 'token']); + + } +} + +/** + * Utility class to acquire/extend/release/kill an exclusive lock on a given persistent object, + * for example to prevent concurrent edition of the same object. + * Each lock has an expiration delay of 120 seconds (tunable via the configuration parameter 'concurrent_lock_expiration_delay') + * A watchdog (called twice during this delay) is in charge of keeping the lock "alive" while an object is being edited. + */ +class iTopOwnershipLock +{ + protected $sObjClass; + protected $iObjKey; + protected $oToken; + + /** + * Acquires an exclusive lock on the specified DBObject. Once acquired, the lock is identified + * by a unique "token" string. + * @param string $sObjClass The class of the object for which to acquire the lock + * @param integer $iObjKey The identifier of the object for which to acquire the lock + * @return multitype:boolean iTopOwnershipLock Ambigous + */ + public static function AcquireLock($sObjClass, $iObjKey) + { + $oMutex = new iTopMutex('lock_'.$sObjClass.'::'.$iObjKey); + + $oMutex->Lock(); + $oOwnershipLock = new iTopOwnershipLock($sObjClass, $iObjKey); + $token = $oOwnershipLock->Acquire(); + $oMutex->Unlock(); + + return ['success' => $token !== false, 'token' => $token, 'lock' => $oOwnershipLock, 'acquired' => $oOwnershipLock->oToken->Get('acquired')]; + } + + /** + * Extends the ownership lock or acquires it if none exists + * Returns a hash array with 3 elements: + * 'status': either true or false, tells if the lock is still owned + * 'owner': is status is false, the User object currently owning the lock + * 'operation': whether the lock was 'renewed' (i.e. the lock was valid, its duration has been extended) or 'acquired' (there was no valid lock for this object and a new one was created) + * @param string $sToken + * @return multitype:boolean string User + */ + public static function ExtendLock($sObjClass, $iObjKey, $sToken) + { + $oMutex = new iTopMutex('lock_'.$sObjClass.'::'.$iObjKey); + + $oMutex->Lock(); + $oOwnershipLock = new iTopOwnershipLock($sObjClass, $iObjKey); + $aResult = $oOwnershipLock->Extend($sToken); + $oMutex->Unlock(); + + return $aResult; + } + + /** + * Releases the given lock for the specified object + * + * @param string $sObjClass The class of the object + * @param int $iObjKey The identifier of the object + * @param string $sToken The string identifying the lock + * @return boolean + */ + public static function ReleaseLock($sObjClass, $iObjKey, $sToken) + { + $oMutex = new iTopMutex('lock_'.$sObjClass.'::'.$iObjKey); + + $oMutex->Lock(); + $oOwnershipLock = new iTopOwnershipLock($sObjClass, $iObjKey); + $bResult = $oOwnershipLock->Release($sToken); + self::DeleteExpiredLocks(); // Cleanup orphan locks + $oMutex->Unlock(); + + return $bResult; + } + + /** + * Kills the lock for the specified object + * + * @param string $sObjClass The class of the object + * @param int $iObjKey The identifier of the object + * @return boolean + */ + public static function KillLock($sObjClass, $iObjKey) + { + $oMutex = new iTopMutex('lock_'.$sObjClass.'::'.$iObjKey); + + $oMutex->Lock(); + $sOQL = "SELECT iTopOwnershipToken WHERE obj_class = :obj_class AND obj_key = :obj_key"; + $oSet = new DBObjectSet(DBObjectSearch::FromOQL($sOQL, ['obj_class' => $sObjClass, 'obj_key' => $iObjKey])); + while ($oLock = $oSet->Fetch()) { + $oLock->DBDelete(); + } + $oMutex->Unlock(); + } + + /** + * Checks if an exclusive lock exists on the specified DBObject. + * @param string $sObjClass The class of the object for which to acquire the lock + * @param integer $iObjKey The identifier of the object for which to acquire the lock + * @return multitype:boolean iTopOwnershipLock Ambigous + */ + public static function IsLocked($sObjClass, $iObjKey) + { + $bLocked = false; + $oMutex = new iTopMutex('lock_'.$sObjClass.'::'.$iObjKey); + + $oMutex->Lock(); + $oOwnershipLock = new iTopOwnershipLock($sObjClass, $iObjKey); + if ($oOwnershipLock->IsOwned()) { + $bLocked = true; + } + $oMutex->Unlock(); + + return ['locked' => $bLocked, 'owner' => $oOwnershipLock->GetOwner()]; + } + + /** + * Get the current owner of the lock + * @return User + */ + public function GetOwner() + { + if ($this->IsTokenValid()) { + return MetaModel::GetObject('User', $this->oToken->Get('user_id'), false, true); + } + return null; + } + + /** + * The constructor is protected. Use the static methods AcquireLock / ExtendLock / ReleaseLock / KillLock + * which are protected against concurrent access by a Mutex. + * @param string $sObjClass The class of the object for which to create a lock + * @param integer $iObjKey The identifier of the object for which to create a lock + */ + protected function __construct($sObjClass, $iObjKey) + { + $sOQL = "SELECT iTopOwnershipToken WHERE obj_class = :obj_class AND obj_key = :obj_key"; + $oSet = new DBObjectSet(DBObjectSearch::FromOQL($sOQL, ['obj_class' => $sObjClass, 'obj_key' => $iObjKey])); + $this->oToken = $oSet->Fetch(); + $this->sObjClass = $sObjClass; + $this->iObjKey = $iObjKey; + // IssueLog::Info("iTopOwnershipLock::__construct($sObjClass, $iObjKey) oToken::".($this->oToken ? $this->oToken->GetKey() : 'null')); + } + + protected function IsOwned() + { + return $this->IsTokenValid(); + } + + protected function Acquire($sToken = null) + { + if ($this->IsTokenValid()) { + // IssueLog::Info("Acquire($sToken) returns false"); + return false; + } else { + $sToken = $this->TakeOwnership($sToken); + // IssueLog::Info("Acquire($sToken) returns $sToken"); + return $sToken; + } + } + + /** + * Extends the ownership lock or acquires it if none exists + * Returns a hash array with 3 elements: + * 'status': either true or false, tells if the lock is still owned + * 'owner': is status is false, the User object currently owning the lock + * 'operation': whether the lock was 'renewed' (i.e. the lock was valid, its duration was extended) or 'expired' (there was no valid lock for this object) or 'lost' (someone else grabbed it) + * 'acquired': date at which the lock was initially acquired + * @param string $sToken + * @return multitype:boolean string User + */ + protected function Extend($sToken) + { + $aResult = ['status' => true, 'owner' => '', 'operation' => 'renewed']; + + if ($this->IsTokenValid()) { + if ($sToken === $this->oToken->Get('token')) { + $this->oToken->Set('last_seen', date(AttributeDateTime::GetSQLFormat())); + $this->oToken->DBUpdate(); + $aResult['acquired'] = $this->oToken->Get('acquired'); + } else { + // IssueLog::Info("Extend($sToken) returns false"); + $aResult['status'] = false; + $aResult['operation'] = 'lost'; + $aResult['owner'] = $this->GetOwner(); + $aResult['acquired'] = $this->oToken->Get('acquired'); + } + } else { + $aResult['status'] = false; + $aResult['operation'] = 'expired'; + } + // IssueLog::Info("Extend($sToken) returns true"); + return $aResult; + } + + protected function HasOwnership($sToken) + { + $bRet = false; + if ($this->IsTokenValid()) { + if ($sToken === $this->oToken->Get('token')) { + $bRet = true; + } + } + // IssueLog::Info("HasOwnership($sToken) return $bRet"); + return $bRet; + } + + protected function Release($sToken) + { + $bRet = false; + // IssueLog::Info("Release... begin [$sToken]"); + if (($this->oToken) && ($sToken === $this->oToken->Get('token'))) { + // IssueLog::Info("oToken::".$this->oToken->GetKey().' ('.$sToken.') to be deleted'); + $this->oToken->DBDelete(); + // IssueLog::Info("oToken deleted"); + $this->oToken = null; + $bRet = true; + } elseif ($this->oToken == null) { + // IssueLog::Info("Release FAILED oToken == null !!!"); + } else { + // IssueLog::Info("Release FAILED inconsistent tokens: sToken=\"".$sToken.'", oToken->Get(\'token\')="'.$this->oToken->Get('token').'"'); + } + // IssueLog::Info("Release... end"); + return $bRet; + } + + protected function IsTokenValid() + { + $bRet = false; + if ($this->oToken != null) { + $sToken = $this->oToken->Get('token'); + $sDate = $this->oToken->Get('last_seen'); + if (($sDate != '') && ($sToken != '')) { + $oLastSeenTime = new DateTime($sDate); + $iNow = date('U'); + if (($iNow - $oLastSeenTime->format('U')) < MetaModel::GetConfig()->Get('concurrent_lock_expiration_delay')) { + $bRet = true; + } + } + } + return $bRet; + } + + protected function TakeOwnership($sToken = null) + { + if ($this->oToken == null) { + $this->oToken = new iTopOwnershipToken(); + $this->oToken->Set('obj_class', $this->sObjClass); + $this->oToken->Set('obj_key', $this->iObjKey); + } + $this->oToken->Set('acquired', date(AttributeDateTime::GetSQLFormat())); + $this->oToken->Set('user_id', UserRights::GetUserId()); + $this->oToken->Set('last_seen', date(AttributeDateTime::GetSQLFormat())); + if ($sToken === null) { + $sToken = sprintf('%X', microtime(true)); + } + $this->oToken->Set('token', $sToken); + $this->oToken->DBWrite(); + return $this->oToken->Get('token'); + } + + protected static function DeleteExpiredLocks() + { + $sOQL = "SELECT iTopOwnershipToken WHERE last_seen < :last_seen_limit"; + $oSet = new DBObjectSet(DBObjectSearch::FromOQL($sOQL, ['last_seen_limit' => date(AttributeDateTime::GetSQLFormat(), time() - MetaModel::GetConfig()->Get('concurrent_lock_expiration_delay'))])); + while ($oToken = $oSet->Fetch()) { + $oToken->DBDelete(); + } + + } +}