Merge pull request #16895 from eileenmcnaughton/case
[civicrm-core.git] / CRM / Extension / Manager.php
CommitLineData
6a488035
TO
1<?php
2/*
3 +--------------------------------------------------------------------+
bc77d7c0 4 | Copyright CiviCRM LLC. All rights reserved. |
6a488035 5 | |
bc77d7c0
TO
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 |
6a488035 9 +--------------------------------------------------------------------+
d25dd0ee 10 */
6a488035
TO
11
12/**
13 * The extension manager handles installing, disabling enabling, and
14 * uninstalling extensions.
15 *
20429eb9
RL
16 * You should obtain a singleton of this class via
17 *
18 * $manager = CRM_Extension_Manager::singleton()->getManager();
19 *
6a488035 20 * @package CRM
ca5cec67 21 * @copyright CiviCRM LLC https://civicrm.org/licensing
6a488035
TO
22 */
23class CRM_Extension_Manager {
24 /**
fe482240 25 * The extension is fully installed and enabled.
6a488035
TO
26 */
27 const STATUS_INSTALLED = 'installed';
28
29 /**
fe482240 30 * The extension config has been applied to database but deactivated.
6a488035
TO
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 /**
25fcba46 45 * The extension is installed but the code is not accessible
6a488035
TO
46 */
47 const STATUS_INSTALLED_MISSING = 'installed-missing';
48
49 /**
25fcba46 50 * The extension was installed and is now disabled; the code is not accessible
6a488035
TO
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 /**
041ecc95 62 * Default container.
63 *
64 * @var CRM_Extension_Container_Basic|false
6a488035
TO
65 *
66 * Note: Treat as private. This is only public to facilitate debugging.
67 */
68 public $defaultContainer;
69
70 /**
041ecc95 71 * Mapper.
72 *
6a488035
TO
73 * @var CRM_Extension_Mapper
74 *
75 * Note: Treat as private. This is only public to facilitate debugging.
76 */
77 public $mapper;
78
79 /**
041ecc95 80 * Type managers.
81 *
82 * @var array
83 *
84 * Format is (typeName => CRM_Extension_Manager_Interface)
6a488035
TO
85 *
86 * Note: Treat as private. This is only public to facilitate debugging.
87 */
88 public $typeManagers;
89
90 /**
041ecc95 91 * Statuses.
92 *
93 * @var array
94 *
95 * Format is (extensionKey => statusConstant)
6a488035
TO
96 *
97 * Note: Treat as private. This is only public to facilitate debugging.
98 */
99 public $statuses;
100
20429eb9
RL
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
6a488035 129 /**
041ecc95 130 * Class constructor.
131 *
6c8f6e67 132 * @param CRM_Extension_Container_Interface $fullContainer
6a488035 133 * @param CRM_Extension_Container_Basic|FALSE $defaultContainer
6c8f6e67 134 * @param CRM_Extension_Mapper $mapper
041ecc95 135 * @param array $typeManagers
6a488035 136 */
00be9182 137 public function __construct(CRM_Extension_Container_Interface $fullContainer, $defaultContainer, CRM_Extension_Mapper $mapper, $typeManagers) {
6a488035
TO
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 *
f41911fd
TO
150 * @param string $tmpCodeDir
151 * Path to a local directory containing a copy of the new (inert) code.
6a488035
TO
152 * @throws CRM_Extension_Exception
153 */
154 public function replace($tmpCodeDir) {
353ffa53 155 if (!$this->defaultContainer) {
6a488035
TO
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
7b966967
SL
168 // throws Exception
169 list ($oldInfo, $typeManager) = $this->_getInfoTypeHandler($newInfo->key);
6a488035 170 $tgtPath = $this->fullContainer->getPath($newInfo->key);
353ffa53 171 if (!CRM_Utils_File::isChildPath($this->defaultContainer->getBaseDir(), $tgtPath)) {
6a488035 172 // force installation in the default-container
615841de 173 $oldPath = $tgtPath;
6a488035 174 $tgtPath = $this->defaultContainer->getBaseDir() . DIRECTORY_SEPARATOR . $newInfo->key;
be2fb01f 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.', [
6a488035
TO
176 1 => $newInfo->key,
177 2 => $oldPath,
be2fb01f 178 ]));
6a488035
TO
179 }
180 break;
b3a4b879 181
6a488035
TO
182 case self::STATUS_INSTALLED_MISSING:
183 case self::STATUS_DISABLED_MISSING:
b3a4b879 184 // the extension does not exist in any container; we're free to put it anywhere
6a488035 185 $tgtPath = $this->defaultContainer->getBaseDir() . DIRECTORY_SEPARATOR . $newInfo->key;
7b966967
SL
186 // throws Exception
187 list ($oldInfo, $typeManager) = $this->_getMissingInfoTypeHandler($newInfo->key);
6a488035 188 break;
b3a4b879 189
6a488035 190 case self::STATUS_UNKNOWN:
b3a4b879 191 // the extension does not exist in any container; we're free to put it anywhere
6a488035
TO
192 $tgtPath = $this->defaultContainer->getBaseDir() . DIRECTORY_SEPARATOR . $newInfo->key;
193 $oldInfo = $typeManager = NULL;
194 break;
b3a4b879 195
6a488035 196 default:
615841de 197 throw new CRM_Extension_Exception("Cannot install or enable extension: {$newInfo->key}");
6a488035
TO
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;
b3a4b879 209
6a488035
TO
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;
b3a4b879 222
6a488035 223 default:
615841de 224 throw new CRM_Extension_Exception("Cannot install or enable extension: {$newInfo->key}");
6a488035
TO
225 }
226
227 $this->refresh();
a618b2b8 228 CRM_Core_Invoke::rebuildMenuAndCaches(TRUE);
6a488035
TO
229 }
230
231 /**
232 * Add records of the extension to the database -- and enable it
233 *
df7a1988
CW
234 * @param string|array $keys
235 * One or more extension keys.
20429eb9 236 * @param string $mode install|enable
6a488035
TO
237 * @throws CRM_Extension_Exception
238 */
20429eb9 239 public function install($keys, $mode = 'install') {
df7a1988 240 $keys = (array) $keys;
6a488035
TO
241 $origStatuses = $this->getStatuses();
242
243 // TODO: to mitigate the risk of crashing during installation, scan
244 // keys/statuses/types before doing anything
245
25fcba46
CW
246 // Check compatibility
247 $incompatible = [];
6a488035 248 foreach ($keys as $key) {
25fcba46
CW
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
20429eb9
RL
257 // Keep state for these operations.
258 $this->addProcess($keys, $mode);
259
25fcba46
CW
260 foreach ($keys as $key) {
261 /** @var CRM_Extension_Info $info */
262 /** @var CRM_Extension_Manager_Base $typeManager */
7b966967 263 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
6a488035
TO
264
265 switch ($origStatuses[$key]) {
266 case self::STATUS_INSTALLED:
20429eb9
RL
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]);
6a488035 273 break;
b3a4b879 274
6a488035
TO
275 case self::STATUS_DISABLED:
276 // re-enable it
20429eb9 277 $this->addProcess([$key], 'enabling');
6a488035
TO
278 $typeManager->onPreEnable($info);
279 $this->_setExtensionActive($info, 1);
280 $typeManager->onPostEnable($info);
34ba82e8
TO
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();
20429eb9
RL
286
287 $this->popProcess([$key]);
6a488035 288 break;
b3a4b879 289
6a488035
TO
290 case self::STATUS_UNINSTALLED:
291 // install anew
20429eb9 292 $this->addProcess([$key], 'installing');
6a488035
TO
293 $typeManager->onPreInstall($info);
294 $this->_createExtensionEntry($info);
295 $typeManager->onPostInstall($info);
34ba82e8
TO
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();
20429eb9
RL
301
302 $this->popProcess([$key]);
6a488035 303 break;
b3a4b879 304
6a488035
TO
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);
20429eb9 314
7ddd5e56
DK
315 $schema = new CRM_Logging_Schema();
316 $schema->fixSchemaDifferences();
3d0e24ec 317
318 foreach ($keys as $key) {
7b966967
SL
319 // throws Exception
320 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
3d0e24ec 321
322 switch ($origStatuses[$key]) {
323 case self::STATUS_INSTALLED:
324 // ok, nothing to do
325 break;
b3a4b879 326
3d0e24ec 327 case self::STATUS_DISABLED:
328 // re-enable it
329 break;
b3a4b879 330
3d0e24ec 331 case self::STATUS_UNINSTALLED:
332 // install anew
20429eb9 333 $this->addProcess([$key], 'installing');
3d0e24ec 334 $typeManager->onPostPostInstall($info);
20429eb9 335 $this->popProcess([$key]);
3d0e24ec 336 break;
b3a4b879 337
3d0e24ec 338 case self::STATUS_UNKNOWN:
339 default:
340 throw new CRM_Extension_Exception("Cannot install or enable extension: $key");
341 }
342 }
343
20429eb9
RL
344 // All processes for these keys
345 $this->popProcess($keys);
6a488035
TO
346 }
347
348 /**
349 * Add records of the extension to the database -- and enable it
350 *
f41911fd
TO
351 * @param array $keys
352 * List of extension keys.
6a488035
TO
353 * @throws CRM_Extension_Exception
354 */
355 public function enable($keys) {
20429eb9 356 $this->install($keys, 'enable');
6a488035
TO
357 }
358
359 /**
25fcba46 360 * Disable extension without removing record from db.
6a488035 361 *
df7a1988
CW
362 * @param string|array $keys
363 * One or more extension keys.
6a488035
TO
364 * @throws CRM_Extension_Exception
365 */
366 public function disable($keys) {
df7a1988 367 $keys = (array) $keys;
6a488035
TO
368 $origStatuses = $this->getStatuses();
369
370 // TODO: to mitigate the risk of crashing during installation, scan
371 // keys/statuses/types before doing anything
372
f8a7cfff
TO
373 sort($keys);
374 $disableRequirements = $this->findDisableRequirements($keys);
7b966967
SL
375 // This munges order, but makes it comparable.
376 sort($disableRequirements);
f8a7cfff 377 if ($keys !== $disableRequirements) {
41d113e9 378 throw new CRM_Extension_Exception_DependencyException("Cannot disable extension due to dependencies. Consider disabling all these: " . implode(',', $disableRequirements));
f8a7cfff
TO
379 }
380
20429eb9
RL
381 $this->addProcess($keys, 'disable');
382
6a488035
TO
383 foreach ($keys as $key) {
384 switch ($origStatuses[$key]) {
385 case self::STATUS_INSTALLED:
20429eb9 386 $this->addProcess([$key], 'disabling');
7b966967
SL
387 // throws Exception
388 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
6a488035
TO
389 $typeManager->onPreDisable($info);
390 $this->_setExtensionActive($info, 0);
391 $typeManager->onPostDisable($info);
20429eb9 392 $this->popProcess([$key]);
6a488035 393 break;
b3a4b879 394
6a488035 395 case self::STATUS_INSTALLED_MISSING:
7b966967
SL
396 // throws Exception
397 list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key);
6a488035
TO
398 $typeManager->onPreDisable($info);
399 $this->_setExtensionActive($info, 0);
400 $typeManager->onPostDisable($info);
401 break;
b3a4b879 402
6a488035
TO
403 case self::STATUS_DISABLED:
404 case self::STATUS_DISABLED_MISSING:
405 case self::STATUS_UNINSTALLED:
406 // ok, nothing to do
20429eb9
RL
407 // Remove the 'disable' process as we're not doing that.
408 $this->popProcess([$key]);
6a488035 409 break;
b3a4b879 410
6a488035
TO
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);
20429eb9
RL
420
421 $this->popProcess($keys);
6a488035
TO
422 }
423
424 /**
fe482240 425 * Remove all database references to an extension.
6a488035 426 *
df7a1988
CW
427 * @param string|array $keys
428 * One or more extension keys.
6a488035
TO
429 * @throws CRM_Extension_Exception
430 */
431 public function uninstall($keys) {
df7a1988 432 $keys = (array) $keys;
6a488035
TO
433 $origStatuses = $this->getStatuses();
434
435 // TODO: to mitigate the risk of crashing during installation, scan
436 // keys/statuses/types before doing anything
437
20429eb9
RL
438 $this->addProcess($keys, 'uninstall');
439
b3a4b879 440 foreach ($keys as $key) {
6a488035
TO
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");
b3a4b879 445
6a488035 446 case self::STATUS_DISABLED:
20429eb9 447 $this->addProcess([$key], 'uninstalling');
7b966967
SL
448 // throws Exception
449 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
6a488035
TO
450 $typeManager->onPreUninstall($info);
451 $this->_removeExtensionEntry($info);
452 $typeManager->onPostUninstall($info);
453 break;
b3a4b879 454
6a488035 455 case self::STATUS_DISABLED_MISSING:
7b966967
SL
456 // throws Exception
457 list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key);
6a488035
TO
458 $typeManager->onPreUninstall($info);
459 $this->_removeExtensionEntry($info);
460 $typeManager->onPostUninstall($info);
461 break;
b3a4b879 462
6a488035
TO
463 case self::STATUS_UNINSTALLED:
464 // ok, nothing to do
20429eb9
RL
465 // remove the 'uninstall' process since we're not doing that.
466 $this->popProcess([$key]);
6a488035 467 break;
b3a4b879 468
6a488035
TO
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);
20429eb9 478 $this->popProcess($keys);
6a488035
TO
479 }
480
481 /**
fe482240 482 * Determine the status of an extension.
6a488035 483 *
77b97be7
EM
484 * @param $key
485 *
a6c01b45 486 * @return string
25fcba46 487 * constant self::STATUS_*
6a488035
TO
488 */
489 public function getStatus($key) {
490 $statuses = $this->getStatuses();
491 if (array_key_exists($key, $statuses)) {
492 return $statuses[$key];
0db6c3e1
TO
493 }
494 else {
6a488035
TO
495 return self::STATUS_UNKNOWN;
496 }
497 }
498
25fcba46
CW
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
6a488035 510 /**
fe482240 511 * Determine the status of all extensions.
6a488035 512 *
a6c01b45
CW
513 * @return array
514 * ($key => status_constant)
6a488035
TO
515 */
516 public function getStatuses() {
517 if (!is_array($this->statuses)) {
6542d699
TO
518 $compat = CRM_Extension_System::getCompatibilityInfo();
519
be2fb01f 520 $this->statuses = [];
6a488035
TO
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);
0db6c3e1
TO
535 }
536 catch (CRM_Extension_Exception $e) {
6a488035
TO
537 $codeExists = FALSE;
538 }
6542d699
TO
539 if (!empty($compat[$dao->full_name]['force-uninstall'])) {
540 $this->statuses[$dao->full_name] = self::STATUS_UNINSTALLED;
541 }
542 elseif ($dao->is_active) {
6a488035 543 $this->statuses[$dao->full_name] = $codeExists ? self::STATUS_INSTALLED : self::STATUS_INSTALLED_MISSING;
0db6c3e1
TO
544 }
545 else {
6a488035
TO
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;
7b966967
SL
555 // and, indirectly, defaultContainer
556 $this->fullContainer->refresh();
6a488035
TO
557 $this->mapper->refresh();
558 }
559
20429eb9
RL
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
6a488035
TO
588 // ----------------------
589
590 /**
591 * Find the $info and $typeManager for a $key
592 *
77b97be7
EM
593 * @param $key
594 *
6a488035 595 * @throws CRM_Extension_Exception
a6c01b45 596 * @return array
25fcba46 597 * [CRM_Extension_Info, CRM_Extension_Manager_Interface]
6a488035
TO
598 */
599 private function _getInfoTypeHandler($key) {
7b966967
SL
600 // throws Exception
601 $info = $this->mapper->keyToInfo($key);
6a488035 602 if (array_key_exists($info->type, $this->typeManagers)) {
be2fb01f 603 return [$info, $this->typeManagers[$info->type]];
0db6c3e1
TO
604 }
605 else {
6a488035
TO
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 *
2a6da8d7
EM
613 * @param $key
614 *
6a488035 615 * @throws CRM_Extension_Exception
a6c01b45 616 * @return array
25fcba46 617 * [CRM_Extension_Info, CRM_Extension_Manager_Interface]
6a488035
TO
618 */
619 private function _getMissingInfoTypeHandler($key) {
620 $info = $this->createInfoFromDB($key);
621 if ($info) {
622 if (array_key_exists($info->type, $this->typeManagers)) {
be2fb01f 623 return [$info, $this->typeManagers[$info->type]];
0db6c3e1
TO
624 }
625 else {
6a488035
TO
626 throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type);
627 }
0db6c3e1
TO
628 }
629 else {
6a488035
TO
630 throw new CRM_Extension_Exception("Failed to reconstruct missing extension: " . $key);
631 }
632 }
633
e0ef6999
EM
634 /**
635 * @param CRM_Extension_Info $info
636 *
637 * @return bool
638 */
6a488035
TO
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
e0ef6999
EM
650 /**
651 * @param CRM_Extension_Info $info
652 *
653 * @return bool
654 */
6a488035
TO
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());
0db6c3e1
TO
666 }
667 else {
6a488035
TO
668 return $this->_createExtensionEntry($info);
669 }
670 }
671
e0ef6999
EM
672 /**
673 * @param CRM_Extension_Info $info
674 *
675 * @throws CRM_Extension_Exception
676 */
6a488035
TO
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');
0db6c3e1
TO
683 }
684 else {
6a488035
TO
685 throw new CRM_Extension_Exception("Failed to remove extension entry");
686 }
687 } // else: post-condition already satisified
688 }
689
e0ef6999
EM
690 /**
691 * @param CRM_Extension_Info $info
692 * @param $isActive
693 */
6a488035 694 private function _setExtensionActive(CRM_Extension_Info $info, $isActive) {
be2fb01f
CW
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 ]);
6a488035
TO
699 }
700
701 /**
702 * Auto-generate a place-holder for a missing extension using info from
703 * database.
704 *
2a6da8d7 705 * @param $key
6a488035
TO
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;
0db6c3e1
TO
714 }
715 else {
6a488035
TO
716 return NULL;
717 }
718 }
96025800 719
f8a7cfff
TO
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.
19ec0aa5
MWMC
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 *
f8a7cfff
TO
728 * @return array
729 * List of extension keys, including dependencies, in order of installation.
19ec0aa5
MWMC
730 * @throws \CRM_Extension_Exception
731 * @throws \MJS\TopSort\CircularDependencyException
732 * @throws \MJS\TopSort\ElementNotFoundException
f8a7cfff 733 */
19ec0aa5
MWMC
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 }
7b966967
SL
742 // array(string $key).
743 $todoKeys = array_unique($keys);
744 // array(string $key => 1);
745 $doneKeys = [];
f8a7cfff
TO
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
19ec0aa5 758 if ($info && $info->requires) {
f8a7cfff 759 $sorter->add($key, $info->requires);
3ea86448 760 $todoKeys = array_merge($todoKeys, $info->requires);
f8a7cfff
TO
761 }
762 else {
be2fb01f 763 $sorter->add($key, []);
f8a7cfff
TO
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) {
be2fb01f 778 $INSTALLED = [
f8a7cfff
TO
779 self::STATUS_INSTALLED,
780 self::STATUS_INSTALLED_MISSING,
be2fb01f 781 ];
f8a7cfff
TO
782 $installedInfos = $this->filterInfosByStatus($this->mapper->getAllInfos(), $INSTALLED);
783 $revMap = CRM_Extension_Info::buildReverseMap($installedInfos);
784 $todoKeys = array_unique($keys);
be2fb01f 785 $doneKeys = [];
f8a7cfff
TO
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 {
be2fb01f 802 $sorter->add($key, []);
f8a7cfff
TO
803 }
804 }
805 return $sorter->sort();
806 }
807
20429eb9
RL
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
f8a7cfff
TO
817 /**
818 * @param $infos
819 * @param $filterStatuses
820 * @return array
821 */
822 protected function filterInfosByStatus($infos, $filterStatuses) {
be2fb01f 823 $matches = [];
f8a7cfff
TO
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
20429eb9
RL
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
6a488035 857}