Remove extraneous opportunistic cache flush.
[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_Manager::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 CRM_Core_Invoke::rebuildMenuAndCaches(TRUE);
314
315 $schema = new CRM_Logging_Schema();
316 $schema->fixSchemaDifferences();
317
318 foreach ($keys as $key) {
319 // throws Exception
320 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
321
322 switch ($origStatuses[$key]) {
323 case self::STATUS_INSTALLED:
324 // ok, nothing to do
325 break;
326
327 case self::STATUS_DISABLED:
328 // re-enable it
329 break;
330
331 case self::STATUS_UNINSTALLED:
332 // install anew
333 $this->addProcess([$key], 'installing');
334 $typeManager->onPostPostInstall($info);
335 $this->popProcess([$key]);
336 break;
337
338 case self::STATUS_UNKNOWN:
339 default:
340 throw new CRM_Extension_Exception("Cannot install or enable extension: $key");
341 }
342 }
343
344 // All processes for these keys
345 $this->popProcess($keys);
346 }
347
348 /**
349 * Add records of the extension to the database -- and enable it
350 *
351 * @param array $keys
352 * List of extension keys.
353 * @throws CRM_Extension_Exception
354 */
355 public function enable($keys) {
356 $this->install($keys, 'enable');
357 }
358
359 /**
360 * Disable extension without removing record from db.
361 *
362 * @param string|array $keys
363 * One or more extension keys.
364 * @throws CRM_Extension_Exception
365 */
366 public function disable($keys) {
367 $keys = (array) $keys;
368 $origStatuses = $this->getStatuses();
369
370 // TODO: to mitigate the risk of crashing during installation, scan
371 // keys/statuses/types before doing anything
372
373 sort($keys);
374 $disableRequirements = $this->findDisableRequirements($keys);
375 // This munges order, but makes it comparable.
376 sort($disableRequirements);
377 if ($keys !== $disableRequirements) {
378 throw new CRM_Extension_Exception_DependencyException("Cannot disable extension due to dependencies. Consider disabling all these: " . implode(',', $disableRequirements));
379 }
380
381 $this->addProcess($keys, 'disable');
382
383 foreach ($keys as $key) {
384 switch ($origStatuses[$key]) {
385 case self::STATUS_INSTALLED:
386 $this->addProcess([$key], 'disabling');
387 // throws Exception
388 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
389 $typeManager->onPreDisable($info);
390 $this->_setExtensionActive($info, 0);
391 $typeManager->onPostDisable($info);
392 $this->popProcess([$key]);
393 break;
394
395 case self::STATUS_INSTALLED_MISSING:
396 // throws Exception
397 list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key);
398 $typeManager->onPreDisable($info);
399 $this->_setExtensionActive($info, 0);
400 $typeManager->onPostDisable($info);
401 break;
402
403 case self::STATUS_DISABLED:
404 case self::STATUS_DISABLED_MISSING:
405 case self::STATUS_UNINSTALLED:
406 // ok, nothing to do
407 // Remove the 'disable' process as we're not doing that.
408 $this->popProcess([$key]);
409 break;
410
411 case self::STATUS_UNKNOWN:
412 default:
413 throw new CRM_Extension_Exception("Cannot disable unknown extension: $key");
414 }
415 }
416
417 $this->statuses = NULL;
418 $this->mapper->refresh();
419 CRM_Core_Invoke::rebuildMenuAndCaches(TRUE);
420
421 $this->popProcess($keys);
422 }
423
424 /**
425 * Remove all database references to an extension.
426 *
427 * @param string|array $keys
428 * One or more extension keys.
429 * @throws CRM_Extension_Exception
430 */
431 public function uninstall($keys) {
432 $keys = (array) $keys;
433 $origStatuses = $this->getStatuses();
434
435 // TODO: to mitigate the risk of crashing during installation, scan
436 // keys/statuses/types before doing anything
437
438 $this->addProcess($keys, 'uninstall');
439
440 foreach ($keys as $key) {
441 switch ($origStatuses[$key]) {
442 case self::STATUS_INSTALLED:
443 case self::STATUS_INSTALLED_MISSING:
444 throw new CRM_Extension_Exception("Cannot uninstall extension; disable it first: $key");
445
446 case self::STATUS_DISABLED:
447 $this->addProcess([$key], 'uninstalling');
448 // throws Exception
449 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
450 $typeManager->onPreUninstall($info);
451 $this->_removeExtensionEntry($info);
452 $typeManager->onPostUninstall($info);
453 break;
454
455 case self::STATUS_DISABLED_MISSING:
456 // throws Exception
457 list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key);
458 $typeManager->onPreUninstall($info);
459 $this->_removeExtensionEntry($info);
460 $typeManager->onPostUninstall($info);
461 break;
462
463 case self::STATUS_UNINSTALLED:
464 // ok, nothing to do
465 // remove the 'uninstall' process since we're not doing that.
466 $this->popProcess([$key]);
467 break;
468
469 case self::STATUS_UNKNOWN:
470 default:
471 throw new CRM_Extension_Exception("Cannot disable unknown extension: $key");
472 }
473 }
474
475 $this->statuses = NULL;
476 $this->mapper->refresh();
477 CRM_Core_Invoke::rebuildMenuAndCaches(TRUE);
478 $this->popProcess($keys);
479 }
480
481 /**
482 * Determine the status of an extension.
483 *
484 * @param $key
485 *
486 * @return string
487 * constant self::STATUS_*
488 */
489 public function getStatus($key) {
490 $statuses = $this->getStatuses();
491 if (array_key_exists($key, $statuses)) {
492 return $statuses[$key];
493 }
494 else {
495 return self::STATUS_UNKNOWN;
496 }
497 }
498
499 /**
500 * Check if a given extension is incompatible with this version of CiviCRM
501 *
502 * @param $key
503 * @return bool|array
504 */
505 public function isIncompatible($key) {
506 $info = CRM_Extension_System::getCompatibilityInfo();
507 return $info[$key] ?? FALSE;
508 }
509
510 /**
511 * Determine the status of all extensions.
512 *
513 * @return array
514 * ($key => status_constant)
515 */
516 public function getStatuses() {
517 if (!is_array($this->statuses)) {
518 $compat = CRM_Extension_System::getCompatibilityInfo();
519
520 $this->statuses = [];
521
522 foreach ($this->fullContainer->getKeys() as $key) {
523 $this->statuses[$key] = self::STATUS_UNINSTALLED;
524 }
525
526 $sql = '
527 SELECT full_name, is_active
528 FROM civicrm_extension
529 ';
530 $dao = CRM_Core_DAO::executeQuery($sql);
531 while ($dao->fetch()) {
532 try {
533 $path = $this->fullContainer->getPath($dao->full_name);
534 $codeExists = !empty($path) && is_dir($path);
535 }
536 catch (CRM_Extension_Exception $e) {
537 $codeExists = FALSE;
538 }
539 if (!empty($compat[$dao->full_name]['force-uninstall'])) {
540 $this->statuses[$dao->full_name] = self::STATUS_UNINSTALLED;
541 }
542 elseif ($dao->is_active) {
543 $this->statuses[$dao->full_name] = $codeExists ? self::STATUS_INSTALLED : self::STATUS_INSTALLED_MISSING;
544 }
545 else {
546 $this->statuses[$dao->full_name] = $codeExists ? self::STATUS_DISABLED : self::STATUS_DISABLED_MISSING;
547 }
548 }
549 }
550 return $this->statuses;
551 }
552
553 public function refresh() {
554 $this->statuses = NULL;
555 // and, indirectly, defaultContainer
556 $this->fullContainer->refresh();
557 $this->mapper->refresh();
558 }
559
560 /**
561 * Return current processes for given extension.
562 *
563 * @param String $key extension key
564 *
565 * @return array
566 */
567 public function getActiveProcesses(string $key) :Array {
568 return $this->processes[$key] ?? [];
569 }
570
571 /**
572 * Determine if the extension specified is currently involved in an install
573 * or enable process. Just sugar code to make things more readable.
574 *
575 * @param String $key extension key
576 *
577 * @return bool
578 */
579 public function extensionIsBeingInstalledOrEnabled($key) :bool {
580 foreach ($this->getActiveProcesses($key) as $process) {
581 if (in_array($process, ['install', 'installing', 'enable', 'enabling'])) {
582 return TRUE;
583 }
584 }
585 return FALSE;
586 }
587
588 // ----------------------
589
590 /**
591 * Find the $info and $typeManager for a $key
592 *
593 * @param $key
594 *
595 * @throws CRM_Extension_Exception
596 * @return array
597 * [CRM_Extension_Info, CRM_Extension_Manager_Interface]
598 */
599 private function _getInfoTypeHandler($key) {
600 // throws Exception
601 $info = $this->mapper->keyToInfo($key);
602 if (array_key_exists($info->type, $this->typeManagers)) {
603 return [$info, $this->typeManagers[$info->type]];
604 }
605 else {
606 throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type);
607 }
608 }
609
610 /**
611 * Find the $info and $typeManager for a $key
612 *
613 * @param $key
614 *
615 * @throws CRM_Extension_Exception
616 * @return array
617 * [CRM_Extension_Info, CRM_Extension_Manager_Interface]
618 */
619 private function _getMissingInfoTypeHandler($key) {
620 $info = $this->createInfoFromDB($key);
621 if ($info) {
622 if (array_key_exists($info->type, $this->typeManagers)) {
623 return [$info, $this->typeManagers[$info->type]];
624 }
625 else {
626 throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type);
627 }
628 }
629 else {
630 throw new CRM_Extension_Exception("Failed to reconstruct missing extension: " . $key);
631 }
632 }
633
634 /**
635 * @param CRM_Extension_Info $info
636 *
637 * @return bool
638 */
639 private function _createExtensionEntry(CRM_Extension_Info $info) {
640 $dao = new CRM_Core_DAO_Extension();
641 $dao->label = $info->label;
642 $dao->name = $info->name;
643 $dao->full_name = $info->key;
644 $dao->type = $info->type;
645 $dao->file = $info->file;
646 $dao->is_active = 1;
647 return (bool) ($dao->insert());
648 }
649
650 /**
651 * @param CRM_Extension_Info $info
652 *
653 * @return bool
654 */
655 private function _updateExtensionEntry(CRM_Extension_Info $info) {
656 $dao = new CRM_Core_DAO_Extension();
657 $dao->full_name = $info->key;
658 if ($dao->find(TRUE)) {
659 $dao->label = $info->label;
660 $dao->name = $info->name;
661 $dao->full_name = $info->key;
662 $dao->type = $info->type;
663 $dao->file = $info->file;
664 $dao->is_active = 1;
665 return (bool) ($dao->update());
666 }
667 else {
668 return $this->_createExtensionEntry($info);
669 }
670 }
671
672 /**
673 * @param CRM_Extension_Info $info
674 *
675 * @throws CRM_Extension_Exception
676 */
677 private function _removeExtensionEntry(CRM_Extension_Info $info) {
678 $dao = new CRM_Core_DAO_Extension();
679 $dao->full_name = $info->key;
680 if ($dao->find(TRUE)) {
681 if (CRM_Core_BAO_Extension::del($dao->id)) {
682 CRM_Core_Session::setStatus(ts('Selected option value has been deleted.'), ts('Deleted'), 'success');
683 }
684 else {
685 throw new CRM_Extension_Exception("Failed to remove extension entry");
686 }
687 } // else: post-condition already satisified
688 }
689
690 /**
691 * @param CRM_Extension_Info $info
692 * @param $isActive
693 */
694 private function _setExtensionActive(CRM_Extension_Info $info, $isActive) {
695 CRM_Core_DAO::executeQuery('UPDATE civicrm_extension SET is_active = %1 where full_name = %2', [
696 1 => [$isActive, 'Integer'],
697 2 => [$info->key, 'String'],
698 ]);
699 }
700
701 /**
702 * Auto-generate a place-holder for a missing extension using info from
703 * database.
704 *
705 * @param $key
706 * @return CRM_Extension_Info|NULL
707 */
708 public function createInfoFromDB($key) {
709 $dao = new CRM_Core_DAO_Extension();
710 $dao->full_name = $key;
711 if ($dao->find(TRUE)) {
712 $info = new CRM_Extension_Info($dao->full_name, $dao->type, $dao->name, $dao->label, $dao->file);
713 return $info;
714 }
715 else {
716 return NULL;
717 }
718 }
719
720 /**
721 * Build a list of extensions to install, in an order that will satisfy dependencies.
722 *
723 * @param array $keys
724 * List of extensions to install.
725 * @param \CRM_Extension_Info $info
726 * An extension info object that we should use instead of our local versions (eg. when checking for upgradeability).
727 *
728 * @return array
729 * List of extension keys, including dependencies, in order of installation.
730 * @throws \CRM_Extension_Exception
731 * @throws \MJS\TopSort\CircularDependencyException
732 * @throws \MJS\TopSort\ElementNotFoundException
733 */
734 public function findInstallRequirements($keys, $info = NULL) {
735 // Use our passed in info, or get the local versions
736 if ($info) {
737 $infos[$info->key] = $info;
738 }
739 else {
740 $infos = $this->mapper->getAllInfos();
741 }
742 // array(string $key).
743 $todoKeys = array_unique($keys);
744 // array(string $key => 1);
745 $doneKeys = [];
746 $sorter = new \MJS\TopSort\Implementations\FixedArraySort();
747
748 while (!empty($todoKeys)) {
749 $key = array_shift($todoKeys);
750 if (isset($doneKeys[$key])) {
751 continue;
752 }
753 $doneKeys[$key] = 1;
754
755 /** @var CRM_Extension_Info $info */
756 $info = @$infos[$key];
757
758 if ($info && $info->requires) {
759 $sorter->add($key, $info->requires);
760 $todoKeys = array_merge($todoKeys, $info->requires);
761 }
762 else {
763 $sorter->add($key, []);
764 }
765 }
766 return $sorter->sort();
767 }
768
769 /**
770 * Build a list of extensions to remove, in an order that will satisfy dependencies.
771 *
772 * @param array $keys
773 * List of extensions to install.
774 * @return array
775 * List of extension keys, including dependencies, in order of removal.
776 */
777 public function findDisableRequirements($keys) {
778 $INSTALLED = [
779 self::STATUS_INSTALLED,
780 self::STATUS_INSTALLED_MISSING,
781 ];
782 $installedInfos = $this->filterInfosByStatus($this->mapper->getAllInfos(), $INSTALLED);
783 $revMap = CRM_Extension_Info::buildReverseMap($installedInfos);
784 $todoKeys = array_unique($keys);
785 $doneKeys = [];
786 $sorter = new \MJS\TopSort\Implementations\FixedArraySort();
787
788 while (!empty($todoKeys)) {
789 $key = array_shift($todoKeys);
790 if (isset($doneKeys[$key])) {
791 continue;
792 }
793 $doneKeys[$key] = 1;
794
795 if (isset($revMap[$key])) {
796 $requiredBys = CRM_Utils_Array::collect('key',
797 $this->filterInfosByStatus($revMap[$key], $INSTALLED));
798 $sorter->add($key, $requiredBys);
799 $todoKeys = array_merge($todoKeys, $requiredBys);
800 }
801 else {
802 $sorter->add($key, []);
803 }
804 }
805 return $sorter->sort();
806 }
807
808 /**
809 * Provides way to set processes property for phpunit tests - not for general use.
810 *
811 * @param $processes
812 */
813 public function setProcessesForTesting(array $processes) {
814 $this->processes = $processes;
815 }
816
817 /**
818 * @param $infos
819 * @param $filterStatuses
820 * @return array
821 */
822 protected function filterInfosByStatus($infos, $filterStatuses) {
823 $matches = [];
824 foreach ($infos as $k => $v) {
825 if (in_array($this->getStatus($v->key), $filterStatuses)) {
826 $matches[$k] = $v;
827 }
828 }
829 return $matches;
830 }
831
832 /**
833 * Add a process to the stacks for the extensions.
834 *
835 * @param array $keys extensionKey
836 * @param string $process one of: install|uninstall|enable|disable|installing|uninstalling|enabling|disabling
837 */
838 protected function addProcess(array $keys, string $process) {
839 foreach ($keys as $key) {
840 $this->processes[$key][] = $process;
841 }
842 }
843
844 /**
845 * Pop the top op from the stacks for the extensions.
846 *
847 * @param array $keys extensionKey
848 */
849 protected function popProcess(array $keys) {
850 foreach ($keys as $key) {
851 if (!empty($this->process[$key])) {
852 array_pop($this->process[$key]);
853 }
854 }
855 }
856
857 }