Merge pull request #20082 from mattwire/paypalwarning
[civicrm-core.git] / CRM / Extension / Manager.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
10 */
11
12 /**
13 * The extension manager handles installing, disabling enabling, and
14 * uninstalling extensions.
15 *
16 * You should obtain a singleton of this class via
17 *
18 * $manager = CRM_Extension_System::singleton()->getManager();
19 *
20 * @package CRM
21 * @copyright CiviCRM LLC https://civicrm.org/licensing
22 */
23 class CRM_Extension_Manager {
24 /**
25 * The extension is fully installed and enabled.
26 */
27 const STATUS_INSTALLED = 'installed';
28
29 /**
30 * The extension config has been applied to database but deactivated.
31 */
32 const STATUS_DISABLED = 'disabled';
33
34 /**
35 * The extension code is visible, but nothing has been applied to DB
36 */
37 const STATUS_UNINSTALLED = 'uninstalled';
38
39 /**
40 * The extension code is not locally accessible
41 */
42 const STATUS_UNKNOWN = 'unknown';
43
44 /**
45 * The extension is installed but the code is not accessible
46 */
47 const STATUS_INSTALLED_MISSING = 'installed-missing';
48
49 /**
50 * The extension was installed and is now disabled; the code is not accessible
51 */
52 const STATUS_DISABLED_MISSING = 'disabled-missing';
53
54 /**
55 * @var CRM_Extension_Container_Interface
56 *
57 * Note: Treat as private. This is only public to facilitate debugging.
58 */
59 public $fullContainer;
60
61 /**
62 * Default container.
63 *
64 * @var CRM_Extension_Container_Basic|false
65 *
66 * Note: Treat as private. This is only public to facilitate debugging.
67 */
68 public $defaultContainer;
69
70 /**
71 * Mapper.
72 *
73 * @var CRM_Extension_Mapper
74 *
75 * Note: Treat as private. This is only public to facilitate debugging.
76 */
77 public $mapper;
78
79 /**
80 * Type managers.
81 *
82 * @var array
83 *
84 * Format is (typeName => CRM_Extension_Manager_Interface)
85 *
86 * Note: Treat as private. This is only public to facilitate debugging.
87 */
88 public $typeManagers;
89
90 /**
91 * Statuses.
92 *
93 * @var array
94 *
95 * Format is (extensionKey => statusConstant)
96 *
97 * Note: Treat as private. This is only public to facilitate debugging.
98 */
99 public $statuses;
100
101 /**
102 * Live process(es) per extension.
103 *
104 * @var array
105 *
106 * Format is: {
107 * extensionKey => [
108 * ['operation' => 'install|enable|uninstall|disable', 'phase' => 'queued|live|completed'
109 * ...
110 * ],
111 * ...
112 * }
113 *
114 * The inner array is a stack, so the most recent current operation is the
115 * last entry. As this manager handles multiple extensions at once, here's
116 * the flow for an install operation.
117 *
118 * $manager->install(['ext1', 'ext2']);
119 *
120 * 0. {}
121 * 1. { ext1: ['install'], ext2: ['install'] }
122 * 2. { ext1: ['install', 'installing'], ext2: ['install'] }
123 * 3. { ext1: ['install'], ext2: ['install', 'installing'] }
124 * 4. { ext1: ['install'], ext2: ['install'] }
125 * 5. {}
126 */
127 protected $processes = [];
128
129 /**
130 * Class constructor.
131 *
132 * @param CRM_Extension_Container_Interface $fullContainer
133 * @param CRM_Extension_Container_Basic|FALSE $defaultContainer
134 * @param CRM_Extension_Mapper $mapper
135 * @param array $typeManagers
136 */
137 public function __construct(CRM_Extension_Container_Interface $fullContainer, $defaultContainer, CRM_Extension_Mapper $mapper, $typeManagers) {
138 $this->fullContainer = $fullContainer;
139 $this->defaultContainer = $defaultContainer;
140 $this->mapper = $mapper;
141 $this->typeManagers = $typeManagers;
142 }
143
144 /**
145 * Install or upgrade the code for an extension -- and perform any
146 * necessary database changes (eg replacing extension metadata).
147 *
148 * This only works if the extension is stored in the default container.
149 *
150 * @param string $tmpCodeDir
151 * Path to a local directory containing a copy of the new (inert) code.
152 * @throws CRM_Extension_Exception
153 */
154 public function replace($tmpCodeDir) {
155 if (!$this->defaultContainer) {
156 throw new CRM_Extension_Exception("Default extension container is not configured");
157 }
158
159 $newInfo = CRM_Extension_Info::loadFromFile($tmpCodeDir . DIRECTORY_SEPARATOR . CRM_Extension_Info::FILENAME);
160 $oldStatus = $this->getStatus($newInfo->key);
161
162 // find $tgtPath, $oldInfo, $typeManager
163 switch ($oldStatus) {
164 case self::STATUS_UNINSTALLED:
165 case self::STATUS_INSTALLED:
166 case self::STATUS_DISABLED:
167 // There is an old copy of the extension. Try to install in the same place -- but it must go somewhere in the default-container
168 // throws Exception
169 list ($oldInfo, $typeManager) = $this->_getInfoTypeHandler($newInfo->key);
170 $tgtPath = $this->fullContainer->getPath($newInfo->key);
171 if (!CRM_Utils_File::isChildPath($this->defaultContainer->getBaseDir(), $tgtPath)) {
172 // force installation in the default-container
173 $oldPath = $tgtPath;
174 $tgtPath = $this->defaultContainer->getBaseDir() . DIRECTORY_SEPARATOR . $newInfo->key;
175 CRM_Core_Session::setStatus(ts('A copy of the extension (%1) is in a system folder (%2). The system copy will be preserved, but the new copy will be used.', [
176 1 => $newInfo->key,
177 2 => $oldPath,
178 ]));
179 }
180 break;
181
182 case self::STATUS_INSTALLED_MISSING:
183 case self::STATUS_DISABLED_MISSING:
184 // the extension does not exist in any container; we're free to put it anywhere
185 $tgtPath = $this->defaultContainer->getBaseDir() . DIRECTORY_SEPARATOR . $newInfo->key;
186 // throws Exception
187 list ($oldInfo, $typeManager) = $this->_getMissingInfoTypeHandler($newInfo->key);
188 break;
189
190 case self::STATUS_UNKNOWN:
191 // the extension does not exist in any container; we're free to put it anywhere
192 $tgtPath = $this->defaultContainer->getBaseDir() . DIRECTORY_SEPARATOR . $newInfo->key;
193 $oldInfo = $typeManager = NULL;
194 break;
195
196 default:
197 throw new CRM_Extension_Exception("Cannot install or enable extension: {$newInfo->key}");
198 }
199
200 // move the code!
201 switch ($oldStatus) {
202 case self::STATUS_UNINSTALLED:
203 case self::STATUS_UNKNOWN:
204 // There are no DB records to worry about, so we'll just put the files in place
205 if (!CRM_Utils_File::replaceDir($tmpCodeDir, $tgtPath)) {
206 throw new CRM_Extension_Exception("Failed to move $tmpCodeDir to $tgtPath");
207 }
208 break;
209
210 case self::STATUS_INSTALLED:
211 case self::STATUS_INSTALLED_MISSING:
212 case self::STATUS_DISABLED:
213 case self::STATUS_DISABLED_MISSING:
214 // There are DB records; coordinate the file placement with the DB updates
215 $typeManager->onPreReplace($oldInfo, $newInfo);
216 if (!CRM_Utils_File::replaceDir($tmpCodeDir, $tgtPath)) {
217 throw new CRM_Extension_Exception("Failed to move $tmpCodeDir to $tgtPath");
218 }
219 $this->_updateExtensionEntry($newInfo);
220 $typeManager->onPostReplace($oldInfo, $newInfo);
221 break;
222
223 default:
224 throw new CRM_Extension_Exception("Cannot install or enable extension: {$newInfo->key}");
225 }
226
227 $this->refresh();
228 CRM_Core_Invoke::rebuildMenuAndCaches(TRUE);
229 }
230
231 /**
232 * Add records of the extension to the database -- and enable it
233 *
234 * @param string|array $keys
235 * One or more extension keys.
236 * @param string $mode install|enable
237 * @throws CRM_Extension_Exception
238 */
239 public function install($keys, $mode = 'install') {
240 $keys = (array) $keys;
241 $origStatuses = $this->getStatuses();
242
243 // TODO: to mitigate the risk of crashing during installation, scan
244 // keys/statuses/types before doing anything
245
246 // Check compatibility
247 $incompatible = [];
248 foreach ($keys as $key) {
249 if ($this->isIncompatible($key)) {
250 $incompatible[] = $key;
251 }
252 }
253 if ($incompatible) {
254 throw new CRM_Extension_Exception('Cannot install incompatible extension: ' . implode(', ', $incompatible));
255 }
256
257 // Keep state for these operations.
258 $this->addProcess($keys, $mode);
259
260 foreach ($keys as $key) {
261 /** @var CRM_Extension_Info $info */
262 /** @var CRM_Extension_Manager_Base $typeManager */
263 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
264
265 switch ($origStatuses[$key]) {
266 case self::STATUS_INSTALLED:
267 // ok, nothing to do. As such the status of this process is no longer
268 // 'install' install was the intent, which might have resulted in
269 // changes but these changes will not be happening, so processes that
270 // are sensitive to installs (like the managed entities reconcile
271 // operation) should not assume that these changes have happened.
272 $this->popProcess([$key]);
273 break;
274
275 case self::STATUS_DISABLED:
276 // re-enable it
277 $this->addProcess([$key], 'enabling');
278 $typeManager->onPreEnable($info);
279 $this->_setExtensionActive($info, 1);
280 $typeManager->onPostEnable($info);
281
282 // A full refresh would be preferrable but very slow. This at least allows
283 // later extensions to access classes from earlier extensions.
284 $this->statuses = NULL;
285 $this->mapper->refresh();
286
287 $this->popProcess([$key]);
288 break;
289
290 case self::STATUS_UNINSTALLED:
291 // install anew
292 $this->addProcess([$key], 'installing');
293 $typeManager->onPreInstall($info);
294 $this->_createExtensionEntry($info);
295 $typeManager->onPostInstall($info);
296
297 // A full refresh would be preferrable but very slow. This at least allows
298 // later extensions to access classes from earlier extensions.
299 $this->statuses = NULL;
300 $this->mapper->refresh();
301
302 $this->popProcess([$key]);
303 break;
304
305 case self::STATUS_UNKNOWN:
306 default:
307 throw new CRM_Extension_Exception("Cannot install or enable extension: $key");
308 }
309 }
310
311 $this->statuses = NULL;
312 $this->mapper->refresh();
313 if (!CRM_Core_Config::isUpgradeMode()) {
314 CRM_Core_Invoke::rebuildMenuAndCaches(TRUE);
315
316 $schema = new CRM_Logging_Schema();
317 $schema->fixSchemaDifferences();
318 }
319 foreach ($keys as $key) {
320 // throws Exception
321 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
322
323 switch ($origStatuses[$key]) {
324 case self::STATUS_INSTALLED:
325 // ok, nothing to do
326 break;
327
328 case self::STATUS_DISABLED:
329 // re-enable it
330 break;
331
332 case self::STATUS_UNINSTALLED:
333 // install anew
334 $this->addProcess([$key], 'installing');
335 $typeManager->onPostPostInstall($info);
336 $this->popProcess([$key]);
337 break;
338
339 case self::STATUS_UNKNOWN:
340 default:
341 throw new CRM_Extension_Exception("Cannot install or enable extension: $key");
342 }
343 }
344
345 // All processes for these keys
346 $this->popProcess($keys);
347 }
348
349 /**
350 * Add records of the extension to the database -- and enable it
351 *
352 * @param array $keys
353 * List of extension keys.
354 * @throws CRM_Extension_Exception
355 */
356 public function enable($keys) {
357 $this->install($keys, 'enable');
358 }
359
360 /**
361 * Disable extension without removing record from db.
362 *
363 * @param string|array $keys
364 * One or more extension keys.
365 * @throws CRM_Extension_Exception
366 */
367 public function disable($keys) {
368 $keys = (array) $keys;
369 $origStatuses = $this->getStatuses();
370
371 // TODO: to mitigate the risk of crashing during installation, scan
372 // keys/statuses/types before doing anything
373
374 sort($keys);
375 $disableRequirements = $this->findDisableRequirements($keys);
376 // This munges order, but makes it comparable.
377 sort($disableRequirements);
378 if ($keys !== $disableRequirements) {
379 throw new CRM_Extension_Exception_DependencyException("Cannot disable extension due to dependencies. Consider disabling all these: " . implode(',', $disableRequirements));
380 }
381
382 $this->addProcess($keys, 'disable');
383
384 foreach ($keys as $key) {
385 switch ($origStatuses[$key]) {
386 case self::STATUS_INSTALLED:
387 $this->addProcess([$key], 'disabling');
388 // throws Exception
389 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
390 $typeManager->onPreDisable($info);
391 $this->_setExtensionActive($info, 0);
392 $typeManager->onPostDisable($info);
393 $this->popProcess([$key]);
394 break;
395
396 case self::STATUS_INSTALLED_MISSING:
397 // throws Exception
398 list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key);
399 $typeManager->onPreDisable($info);
400 $this->_setExtensionActive($info, 0);
401 $typeManager->onPostDisable($info);
402 break;
403
404 case self::STATUS_DISABLED:
405 case self::STATUS_DISABLED_MISSING:
406 case self::STATUS_UNINSTALLED:
407 // ok, nothing to do
408 // Remove the 'disable' process as we're not doing that.
409 $this->popProcess([$key]);
410 break;
411
412 case self::STATUS_UNKNOWN:
413 default:
414 throw new CRM_Extension_Exception("Cannot disable unknown extension: $key");
415 }
416 }
417
418 $this->statuses = NULL;
419 $this->mapper->refresh();
420 CRM_Core_Invoke::rebuildMenuAndCaches(TRUE);
421
422 $this->popProcess($keys);
423 }
424
425 /**
426 * Remove all database references to an extension.
427 *
428 * @param string|array $keys
429 * One or more extension keys.
430 * @throws CRM_Extension_Exception
431 */
432 public function uninstall($keys) {
433 $keys = (array) $keys;
434 $origStatuses = $this->getStatuses();
435
436 // TODO: to mitigate the risk of crashing during installation, scan
437 // keys/statuses/types before doing anything
438
439 $this->addProcess($keys, 'uninstall');
440
441 foreach ($keys as $key) {
442 switch ($origStatuses[$key]) {
443 case self::STATUS_INSTALLED:
444 case self::STATUS_INSTALLED_MISSING:
445 throw new CRM_Extension_Exception("Cannot uninstall extension; disable it first: $key");
446
447 case self::STATUS_DISABLED:
448 $this->addProcess([$key], 'uninstalling');
449 // throws Exception
450 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
451 $typeManager->onPreUninstall($info);
452 $this->_removeExtensionEntry($info);
453 $typeManager->onPostUninstall($info);
454 break;
455
456 case self::STATUS_DISABLED_MISSING:
457 // throws Exception
458 list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key);
459 $typeManager->onPreUninstall($info);
460 $this->_removeExtensionEntry($info);
461 $typeManager->onPostUninstall($info);
462 break;
463
464 case self::STATUS_UNINSTALLED:
465 // ok, nothing to do
466 // remove the 'uninstall' process since we're not doing that.
467 $this->popProcess([$key]);
468 break;
469
470 case self::STATUS_UNKNOWN:
471 default:
472 throw new CRM_Extension_Exception("Cannot disable unknown extension: $key");
473 }
474 }
475
476 $this->statuses = NULL;
477 $this->mapper->refresh();
478 CRM_Core_Invoke::rebuildMenuAndCaches(TRUE);
479 $this->popProcess($keys);
480 }
481
482 /**
483 * Determine the status of an extension.
484 *
485 * @param $key
486 *
487 * @return string
488 * constant self::STATUS_*
489 */
490 public function getStatus($key) {
491 $statuses = $this->getStatuses();
492 if (array_key_exists($key, $statuses)) {
493 return $statuses[$key];
494 }
495 else {
496 return self::STATUS_UNKNOWN;
497 }
498 }
499
500 /**
501 * Check if a given extension is incompatible with this version of CiviCRM
502 *
503 * @param $key
504 * @return bool|array
505 */
506 public function isIncompatible($key) {
507 $info = CRM_Extension_System::getCompatibilityInfo();
508 return $info[$key] ?? FALSE;
509 }
510
511 /**
512 * Determine the status of all extensions.
513 *
514 * @return array
515 * ($key => status_constant)
516 */
517 public function getStatuses() {
518 if (!is_array($this->statuses)) {
519 $compat = CRM_Extension_System::getCompatibilityInfo();
520
521 $this->statuses = [];
522
523 foreach ($this->fullContainer->getKeys() as $key) {
524 $this->statuses[$key] = self::STATUS_UNINSTALLED;
525 }
526
527 $sql = '
528 SELECT full_name, is_active
529 FROM civicrm_extension
530 ';
531 $dao = CRM_Core_DAO::executeQuery($sql);
532 while ($dao->fetch()) {
533 try {
534 $path = $this->fullContainer->getPath($dao->full_name);
535 $codeExists = !empty($path) && is_dir($path);
536 }
537 catch (CRM_Extension_Exception $e) {
538 $codeExists = FALSE;
539 }
540 if (!empty($compat[$dao->full_name]['force-uninstall'])) {
541 $this->statuses[$dao->full_name] = self::STATUS_UNINSTALLED;
542 }
543 elseif ($dao->is_active) {
544 $this->statuses[$dao->full_name] = $codeExists ? self::STATUS_INSTALLED : self::STATUS_INSTALLED_MISSING;
545 }
546 else {
547 $this->statuses[$dao->full_name] = $codeExists ? self::STATUS_DISABLED : self::STATUS_DISABLED_MISSING;
548 }
549 }
550 }
551 return $this->statuses;
552 }
553
554 public function refresh() {
555 $this->statuses = NULL;
556 // and, indirectly, defaultContainer
557 $this->fullContainer->refresh();
558 $this->mapper->refresh();
559 }
560
561 /**
562 * Return current processes for given extension.
563 *
564 * @param String $key extension key
565 *
566 * @return array
567 */
568 public function getActiveProcesses(string $key) :Array {
569 return $this->processes[$key] ?? [];
570 }
571
572 /**
573 * Determine if the extension specified is currently involved in an install
574 * or enable process. Just sugar code to make things more readable.
575 *
576 * @param String $key extension key
577 *
578 * @return bool
579 */
580 public function extensionIsBeingInstalledOrEnabled($key) :bool {
581 foreach ($this->getActiveProcesses($key) as $process) {
582 if (in_array($process, ['install', 'installing', 'enable', 'enabling'])) {
583 return TRUE;
584 }
585 }
586 return FALSE;
587 }
588
589 // ----------------------
590
591 /**
592 * Find the $info and $typeManager for a $key
593 *
594 * @param $key
595 *
596 * @throws CRM_Extension_Exception
597 * @return array
598 * [CRM_Extension_Info, CRM_Extension_Manager_Interface]
599 */
600 private function _getInfoTypeHandler($key) {
601 // throws Exception
602 $info = $this->mapper->keyToInfo($key);
603 if (array_key_exists($info->type, $this->typeManagers)) {
604 return [$info, $this->typeManagers[$info->type]];
605 }
606 else {
607 throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type);
608 }
609 }
610
611 /**
612 * Find the $info and $typeManager for a $key
613 *
614 * @param $key
615 *
616 * @throws CRM_Extension_Exception
617 * @return array
618 * [CRM_Extension_Info, CRM_Extension_Manager_Interface]
619 */
620 private function _getMissingInfoTypeHandler($key) {
621 $info = $this->createInfoFromDB($key);
622 if ($info) {
623 if (array_key_exists($info->type, $this->typeManagers)) {
624 return [$info, $this->typeManagers[$info->type]];
625 }
626 else {
627 throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type);
628 }
629 }
630 else {
631 throw new CRM_Extension_Exception("Failed to reconstruct missing extension: " . $key);
632 }
633 }
634
635 /**
636 * @param CRM_Extension_Info $info
637 *
638 * @return bool
639 */
640 private function _createExtensionEntry(CRM_Extension_Info $info) {
641 $dao = new CRM_Core_DAO_Extension();
642 $dao->label = $info->label;
643 $dao->name = $info->name;
644 $dao->full_name = $info->key;
645 $dao->type = $info->type;
646 $dao->file = $info->file;
647 $dao->is_active = 1;
648 return (bool) ($dao->insert());
649 }
650
651 /**
652 * @param CRM_Extension_Info $info
653 *
654 * @return bool
655 */
656 private function _updateExtensionEntry(CRM_Extension_Info $info) {
657 $dao = new CRM_Core_DAO_Extension();
658 $dao->full_name = $info->key;
659 if ($dao->find(TRUE)) {
660 $dao->label = $info->label;
661 $dao->name = $info->name;
662 $dao->full_name = $info->key;
663 $dao->type = $info->type;
664 $dao->file = $info->file;
665 $dao->is_active = 1;
666 return (bool) ($dao->update());
667 }
668 else {
669 return $this->_createExtensionEntry($info);
670 }
671 }
672
673 /**
674 * @param CRM_Extension_Info $info
675 *
676 * @throws CRM_Extension_Exception
677 */
678 private function _removeExtensionEntry(CRM_Extension_Info $info) {
679 $dao = new CRM_Core_DAO_Extension();
680 $dao->full_name = $info->key;
681 if ($dao->find(TRUE)) {
682 if (CRM_Core_BAO_Extension::del($dao->id)) {
683 CRM_Core_Session::setStatus(ts('Selected option value has been deleted.'), ts('Deleted'), 'success');
684 }
685 else {
686 throw new CRM_Extension_Exception("Failed to remove extension entry");
687 }
688 } // else: post-condition already satisified
689 }
690
691 /**
692 * @param CRM_Extension_Info $info
693 * @param $isActive
694 */
695 private function _setExtensionActive(CRM_Extension_Info $info, $isActive) {
696 CRM_Core_DAO::executeQuery('UPDATE civicrm_extension SET is_active = %1 where full_name = %2', [
697 1 => [$isActive, 'Integer'],
698 2 => [$info->key, 'String'],
699 ]);
700 }
701
702 /**
703 * Auto-generate a place-holder for a missing extension using info from
704 * database.
705 *
706 * @param $key
707 * @return CRM_Extension_Info|NULL
708 */
709 public function createInfoFromDB($key) {
710 $dao = new CRM_Core_DAO_Extension();
711 $dao->full_name = $key;
712 if ($dao->find(TRUE)) {
713 $info = new CRM_Extension_Info($dao->full_name, $dao->type, $dao->name, $dao->label, $dao->file);
714 return $info;
715 }
716 else {
717 return NULL;
718 }
719 }
720
721 /**
722 * Build a list of extensions to install, in an order that will satisfy dependencies.
723 *
724 * @param array $keys
725 * List of extensions to install.
726 * @param \CRM_Extension_Info $info
727 * An extension info object that we should use instead of our local versions (eg. when checking for upgradeability).
728 *
729 * @return array
730 * List of extension keys, including dependencies, in order of installation.
731 * @throws \CRM_Extension_Exception
732 * @throws \MJS\TopSort\CircularDependencyException
733 * @throws \MJS\TopSort\ElementNotFoundException
734 */
735 public function findInstallRequirements($keys, $info = NULL) {
736 // Use our passed in info, or get the local versions
737 if ($info) {
738 $infos[$info->key] = $info;
739 }
740 else {
741 $infos = $this->mapper->getAllInfos();
742 }
743 // array(string $key).
744 $todoKeys = array_unique($keys);
745 // array(string $key => 1);
746 $doneKeys = [];
747 $sorter = new \MJS\TopSort\Implementations\FixedArraySort();
748
749 while (!empty($todoKeys)) {
750 $key = array_shift($todoKeys);
751 if (isset($doneKeys[$key])) {
752 continue;
753 }
754 $doneKeys[$key] = 1;
755
756 /** @var CRM_Extension_Info $info */
757 $info = @$infos[$key];
758
759 if ($info && $info->requires) {
760 $sorter->add($key, $info->requires);
761 $todoKeys = array_merge($todoKeys, $info->requires);
762 }
763 else {
764 $sorter->add($key, []);
765 }
766 }
767 return $sorter->sort();
768 }
769
770 /**
771 * Build a list of extensions to remove, in an order that will satisfy dependencies.
772 *
773 * @param array $keys
774 * List of extensions to install.
775 * @return array
776 * List of extension keys, including dependencies, in order of removal.
777 */
778 public function findDisableRequirements($keys) {
779 $INSTALLED = [
780 self::STATUS_INSTALLED,
781 self::STATUS_INSTALLED_MISSING,
782 ];
783 $installedInfos = $this->filterInfosByStatus($this->mapper->getAllInfos(), $INSTALLED);
784 $revMap = CRM_Extension_Info::buildReverseMap($installedInfos);
785 $todoKeys = array_unique($keys);
786 $doneKeys = [];
787 $sorter = new \MJS\TopSort\Implementations\FixedArraySort();
788
789 while (!empty($todoKeys)) {
790 $key = array_shift($todoKeys);
791 if (isset($doneKeys[$key])) {
792 continue;
793 }
794 $doneKeys[$key] = 1;
795
796 if (isset($revMap[$key])) {
797 $requiredBys = CRM_Utils_Array::collect('key',
798 $this->filterInfosByStatus($revMap[$key], $INSTALLED));
799 $sorter->add($key, $requiredBys);
800 $todoKeys = array_merge($todoKeys, $requiredBys);
801 }
802 else {
803 $sorter->add($key, []);
804 }
805 }
806 return $sorter->sort();
807 }
808
809 /**
810 * Provides way to set processes property for phpunit tests - not for general use.
811 *
812 * @param $processes
813 */
814 public function setProcessesForTesting(array $processes) {
815 $this->processes = $processes;
816 }
817
818 /**
819 * @param $infos
820 * @param $filterStatuses
821 * @return array
822 */
823 protected function filterInfosByStatus($infos, $filterStatuses) {
824 $matches = [];
825 foreach ($infos as $k => $v) {
826 if (in_array($this->getStatus($v->key), $filterStatuses)) {
827 $matches[$k] = $v;
828 }
829 }
830 return $matches;
831 }
832
833 /**
834 * Add a process to the stacks for the extensions.
835 *
836 * @param array $keys extensionKey
837 * @param string $process one of: install|uninstall|enable|disable|installing|uninstalling|enabling|disabling
838 */
839 protected function addProcess(array $keys, string $process) {
840 foreach ($keys as $key) {
841 $this->processes[$key][] = $process;
842 }
843 }
844
845 /**
846 * Pop the top op from the stacks for the extensions.
847 *
848 * @param array $keys extensionKey
849 */
850 protected function popProcess(array $keys) {
851 foreach ($keys as $key) {
852 if (!empty($this->process[$key])) {
853 array_pop($this->process[$key]);
854 }
855 }
856 }
857
858 }