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