From f8a7cfff7a2e3d97eaa3fdedc85ff0f946614718 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Thu, 24 Mar 2016 20:45:28 -0700 Subject: [PATCH] CRM-16243 - CRM_Extension_Manager - Add helper functions to resolve installation and removal deps --- CRM/Extension/Manager.php | 99 +++++++++++++++++++++ CRM/Extension/Mapper.php | 13 +++ tests/phpunit/CRM/Extension/ManagerTest.php | 83 +++++++++++++++++ 3 files changed, 195 insertions(+) diff --git a/CRM/Extension/Manager.php b/CRM/Extension/Manager.php index 6ef8c6e55f..3872b268f8 100644 --- a/CRM/Extension/Manager.php +++ b/CRM/Extension/Manager.php @@ -292,6 +292,13 @@ class CRM_Extension_Manager { // TODO: to mitigate the risk of crashing during installation, scan // keys/statuses/types before doing anything + sort($keys); + $disableRequirements = $this->findDisableRequirements($keys); + sort($disableRequirements); // This munges order, but makes it comparable. + if ($keys !== $disableRequirements) { + throw new CRM_Extension_Exception_DependencyException("Cannot disable extension due dependencies. Consider disabling all these: " . implode(',', $disableRequirements)); + } + foreach ($keys as $key) { switch ($origStatuses[$key]) { case self::STATUS_INSTALLED: @@ -568,4 +575,96 @@ class CRM_Extension_Manager { } } + /** + * Build a list of extensions to install, in an order that will satisfy dependencies. + * + * @param array $keys + * List of extensions to install. + * @return array + * List of extension keys, including dependencies, in order of installation. + */ + public function findInstallRequirements($keys) { + $infos = $this->mapper->getAllInfos(); + $todoKeys = array_unique($keys); // array(string $key). + $doneKeys = array(); // array(string $key => 1); + $sorter = new \MJS\TopSort\Implementations\FixedArraySort(); + + while (!empty($todoKeys)) { + $key = array_shift($todoKeys); + if (isset($doneKeys[$key])) { + continue; + } + $doneKeys[$key] = 1; + + /** @var CRM_Extension_Info $info */ + $info = @$infos[$key]; + + if ($this->getStatus($key) === self::STATUS_INSTALLED) { + // skip + } + elseif ($info && $info->requires) { + $sorter->add($key, $info->requires); + $todoKeys = array_merge($todoKeys, $info->requires); + } + else { + $sorter->add($key, array()); + } + } + return $sorter->sort(); + } + + /** + * Build a list of extensions to remove, in an order that will satisfy dependencies. + * + * @param array $keys + * List of extensions to install. + * @return array + * List of extension keys, including dependencies, in order of removal. + */ + public function findDisableRequirements($keys) { + $INSTALLED = array( + self::STATUS_INSTALLED, + self::STATUS_INSTALLED_MISSING, + ); + $installedInfos = $this->filterInfosByStatus($this->mapper->getAllInfos(), $INSTALLED); + $revMap = CRM_Extension_Info::buildReverseMap($installedInfos); + $todoKeys = array_unique($keys); + $doneKeys = array(); + $sorter = new \MJS\TopSort\Implementations\FixedArraySort(); + + while (!empty($todoKeys)) { + $key = array_shift($todoKeys); + if (isset($doneKeys[$key])) { + continue; + } + $doneKeys[$key] = 1; + + if (isset($revMap[$key])) { + $requiredBys = CRM_Utils_Array::collect('key', + $this->filterInfosByStatus($revMap[$key], $INSTALLED)); + $sorter->add($key, $requiredBys); + $todoKeys = array_merge($todoKeys, $requiredBys); + } + else { + $sorter->add($key, array()); + } + } + return $sorter->sort(); + } + + /** + * @param $infos + * @param $filterStatuses + * @return array + */ + protected function filterInfosByStatus($infos, $filterStatuses) { + $matches = array(); + foreach ($infos as $k => $v) { + if (in_array($this->getStatus($v->key), $filterStatuses)) { + $matches[$k] = $v; + } + } + return $matches; + } + } diff --git a/CRM/Extension/Mapper.php b/CRM/Extension/Mapper.php index de31e82d91..74fb01ab92 100644 --- a/CRM/Extension/Mapper.php +++ b/CRM/Extension/Mapper.php @@ -340,6 +340,19 @@ class CRM_Extension_Mapper { return $urls; } + /** + * @return array + * Ex: $result['org.civicrm.foobar'] = new CRM_Extension_Info(...). + * @throws \CRM_Extension_Exception + * @throws \Exception + */ + public function getAllInfos() { + foreach ($this->container->getKeys() as $key) { + $this->keyToInfo($key); + } + return $this->infos; + } + /** * @param string $name * diff --git a/tests/phpunit/CRM/Extension/ManagerTest.php b/tests/phpunit/CRM/Extension/ManagerTest.php index 53af7cff48..b8cf3f352a 100644 --- a/tests/phpunit/CRM/Extension/ManagerTest.php +++ b/tests/phpunit/CRM/Extension/ManagerTest.php @@ -104,6 +104,86 @@ class CRM_Extension_ManagerTest extends CiviUnitTestCase { $this->assertEquals('installed', $manager->getStatus('test.whiz.bang')); // no side-effect } + /** + * This is the same as testInstall_Disable_Uninstall, but we also install and remove a dependency. + * + * @throws \CRM_Extension_Exception + */ + public function test_InstallAuto_DisableDownstream_UninstallDownstream() { + $testingTypeManager = $this->getMock('CRM_Extension_Manager_Interface'); + $manager = $this->_createManager(array( + self::TESTING_TYPE => $testingTypeManager, + )); + $this->assertEquals('uninstalled', $manager->getStatus('test.foo.bar')); + $this->assertEquals('uninstalled', $manager->getStatus('test.foo.downstream')); + $this->assertEquals('uninstalled', $manager->getStatus('test.whiz.bang')); + + $testingTypeManager->expects($this->exactly(2))->method('onPreInstall'); + $testingTypeManager->expects($this->exactly(2))->method('onPostInstall'); + $this->assertEquals(array('test.foo.bar', 'test.foo.downstream'), + $manager->findInstallRequirements(array('test.foo.downstream'))); + $manager->install( + $manager->findInstallRequirements(array('test.foo.downstream'))); + $this->assertEquals('installed', $manager->getStatus('test.foo.bar')); + $this->assertEquals('installed', $manager->getStatus('test.foo.downstream')); + $this->assertEquals('uninstalled', $manager->getStatus('test.whiz.bang')); + + $testingTypeManager->expects($this->once())->method('onPreDisable'); + $testingTypeManager->expects($this->once())->method('onPostDisable'); + $this->assertEquals(array('test.foo.downstream'), + $manager->findDisableRequirements(array('test.foo.downstream'))); + $manager->disable(array('test.foo.downstream')); + $this->assertEquals('installed', $manager->getStatus('test.foo.bar')); + $this->assertEquals('disabled', $manager->getStatus('test.foo.downstream')); + $this->assertEquals('uninstalled', $manager->getStatus('test.whiz.bang')); + + $testingTypeManager->expects($this->once())->method('onPreUninstall'); + $testingTypeManager->expects($this->once())->method('onPostUninstall'); + $manager->uninstall(array('test.foo.downstream')); + $this->assertEquals('installed', $manager->getStatus('test.foo.bar')); + $this->assertEquals('uninstalled', $manager->getStatus('test.foo.downstream')); + $this->assertEquals('uninstalled', $manager->getStatus('test.whiz.bang')); + } + + public function test_InstallAuto_DisableUpstream() { + $testingTypeManager = $this->getMock('CRM_Extension_Manager_Interface'); + $manager = $this->_createManager(array( + self::TESTING_TYPE => $testingTypeManager, + )); + $this->assertEquals('uninstalled', $manager->getStatus('test.foo.bar')); + $this->assertEquals('uninstalled', $manager->getStatus('test.foo.downstream')); + $this->assertEquals('uninstalled', $manager->getStatus('test.whiz.bang')); + + $testingTypeManager->expects($this->exactly(2))->method('onPreInstall'); + $testingTypeManager->expects($this->exactly(2))->method('onPostInstall'); + $this->assertEquals(array('test.foo.bar', 'test.foo.downstream'), + $manager->findInstallRequirements(array('test.foo.downstream'))); + $manager->install( + $manager->findInstallRequirements(array('test.foo.downstream'))); + $this->assertEquals('installed', $manager->getStatus('test.foo.bar')); + $this->assertEquals('installed', $manager->getStatus('test.foo.downstream')); + $this->assertEquals('uninstalled', $manager->getStatus('test.whiz.bang')); + + $testingTypeManager->expects($this->never())->method('onPreDisable'); + $testingTypeManager->expects($this->never())->method('onPostDisable'); + $this->assertEquals(array('test.foo.downstream', 'test.foo.bar'), + $manager->findDisableRequirements(array('test.foo.bar'))); + + try { + $manager->disable(array('test.foo.bar')); + $this->fail('Expected disable to fail due to dependency'); + } + catch (CRM_Extension_Exception $e) { + $this->assertRegExp('/test.foo.downstream/', $e->getMessage()); + } + + // Status unchanged + $this->assertEquals('installed', $manager->getStatus('test.foo.bar')); + $this->assertEquals('installed', $manager->getStatus('test.foo.downstream')); + $this->assertEquals('uninstalled', $manager->getStatus('test.whiz.bang')); + } + + /** * Install an extension and then harshly remove the underlying source. * Subseuently disable and uninstall. @@ -420,6 +500,9 @@ class CRM_Extension_ManagerTest extends CiviUnitTestCase { mkdir("$basedir/weird/whizbang"); file_put_contents("$basedir/weird/whizbang/info.xml", "oddball"); // not needed for now // file_put_contents("$basedir/weird/whizbang/oddball.php", "oddballtest.foo.bar"); + // not needed for now // file_put_contents("$basedir/weird/downstream/oddball.php", "