mirror of
https://github.com/flarum/framework.git
synced 2024-11-25 09:41:49 +08:00
Optional Dependencies (#2579)
* Add and calculate optional dependencies * Add extension dependency resolver (Kahn's algorithm), plus unit tests * Resolve extension dependency on enable/disable
This commit is contained in:
parent
40ede179cd
commit
fa10d794a4
|
@ -64,7 +64,7 @@ class Extension implements Arrayable
|
|||
* Unique Id of the extension.
|
||||
*
|
||||
* @info Identical to the directory in the extensions directory.
|
||||
* @example flarum_suspend
|
||||
* @example flarum-suspend
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
|
@ -91,6 +91,14 @@ class Extension implements Arrayable
|
|||
*/
|
||||
protected $extensionDependencyIds;
|
||||
|
||||
/**
|
||||
* The IDs of all Flarum extensions that this extension should be booted after
|
||||
* if enabled.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected $optionalDependencyIds;
|
||||
|
||||
/**
|
||||
* Whether the extension is installed.
|
||||
*
|
||||
|
@ -203,16 +211,29 @@ class Extension implements Arrayable
|
|||
* @param array $extensionSet: An associative array where keys are the composer package names
|
||||
* of installed extensions. Used to figure out which dependencies
|
||||
* are flarum extensions.
|
||||
* @param array $enabledIds: An associative array where keys are the composer package names
|
||||
* of enabled extensions. Used to figure out optional dependencies.
|
||||
*/
|
||||
public function calculateDependencies($extensionSet)
|
||||
public function calculateDependencies($extensionSet, $enabledIds)
|
||||
{
|
||||
$this->extensionDependencyIds = (new Collection(Arr::get($this->composerJson, 'require', [])))
|
||||
->keys()
|
||||
->filter(function ($key) use ($extensionSet) {
|
||||
return array_key_exists($key, $extensionSet);
|
||||
})->map(function ($key) {
|
||||
})
|
||||
->map(function ($key) {
|
||||
return static::nameToId($key);
|
||||
})->toArray();
|
||||
})
|
||||
->toArray();
|
||||
|
||||
$this->optionalDependencyIds = (new Collection(Arr::get($this->composerJson, 'extra.flarum-extension.optional-dependencies', [])))
|
||||
->map(function ($key) {
|
||||
return static::nameToId($key);
|
||||
})
|
||||
->filter(function ($key) use ($enabledIds) {
|
||||
return array_key_exists($key, $enabledIds);
|
||||
})
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -299,11 +320,22 @@ class Extension implements Arrayable
|
|||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getExtensionDependencyIds()
|
||||
public function getExtensionDependencyIds(): array
|
||||
{
|
||||
return $this->extensionDependencyIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* The IDs of all Flarum extensions that this extension should be booted after
|
||||
* if enabled.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getOptionalDependencyIds(): array
|
||||
{
|
||||
return $this->optionalDependencyIds;
|
||||
}
|
||||
|
||||
private function getExtenders(): array
|
||||
{
|
||||
$extenderFile = $this->getExtenderFile();
|
||||
|
@ -455,6 +487,7 @@ class Extension implements Arrayable
|
|||
'hasAssets' => $this->hasAssets(),
|
||||
'hasMigrations' => $this->hasMigrations(),
|
||||
'extensionDependencyIds' => $this->getExtensionDependencyIds(),
|
||||
'optionalDependencyIds' => $this->getOptionalDependencyIds(),
|
||||
'links' => $this->getLinks(),
|
||||
], $this->composerJson);
|
||||
}
|
||||
|
|
|
@ -86,7 +86,9 @@ class ExtensionManager
|
|||
// We calculate and store a set of composer package names for all installed Flarum extensions,
|
||||
// so we know what is and isn't a flarum extension in `calculateDependencies`.
|
||||
// Using keys of an associative array allows us to do these checks in constant time.
|
||||
// We do the same for enabled extensions, for optional dependencies.
|
||||
$installedSet = [];
|
||||
$enabledIds = array_flip($this->getEnabled());
|
||||
|
||||
foreach ($installed as $package) {
|
||||
if (Arr::get($package, 'type') != 'flarum-extension' || empty(Arr::get($package, 'name'))) {
|
||||
|
@ -110,7 +112,7 @@ class ExtensionManager
|
|||
}
|
||||
|
||||
foreach ($extensions as $extension) {
|
||||
$extension->calculateDependencies($installedSet);
|
||||
$extension->calculateDependencies($installedSet, $enabledIds);
|
||||
}
|
||||
|
||||
$this->extensions = $extensions->sortBy(function ($extension, $name) {
|
||||
|
@ -348,13 +350,21 @@ class ExtensionManager
|
|||
/**
|
||||
* Persist the currently enabled extensions.
|
||||
*
|
||||
* @param array $enabled
|
||||
* @param array $enabledIds
|
||||
*/
|
||||
protected function setEnabled(array $enabled)
|
||||
protected function setEnabled(array $enabledIds)
|
||||
{
|
||||
$enabled = array_values(array_unique($enabled));
|
||||
$enabled = array_map(function ($id) {
|
||||
return $this->getExtension($id);
|
||||
}, array_unique($enabledIds));
|
||||
|
||||
$this->config->set('extensions_enabled', json_encode($enabled));
|
||||
$sortedEnabled = static::resolveExtensionOrder($enabled)['valid'];
|
||||
|
||||
$sortedEnabledIds = array_map(function (Extension $extension) {
|
||||
return $extension->getId();
|
||||
}, $sortedEnabled);
|
||||
|
||||
$this->config->set('extensions_enabled', json_encode($sortedEnabledIds));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -382,4 +392,92 @@ class ExtensionManager
|
|||
return $extension->getTitle();
|
||||
}, $exts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort a list of extensions so that they are properly resolved in respect to order.
|
||||
* Effectively just topological sorting.
|
||||
*
|
||||
* @param Extension[] $extensionList: an array of \Flarum\Extension\Extension objects
|
||||
*
|
||||
* @return array with 2 keys: 'valid' points to an ordered array of \Flarum\Extension\Extension
|
||||
* 'missingDependencies' points to an associative array of extensions that could not be resolved due
|
||||
* to missing dependencies, in the format extension id => array of missing dependency IDs.
|
||||
* 'circularDependencies' points to an array of extensions ids of extensions
|
||||
* that cannot be processed due to circular dependencies
|
||||
*/
|
||||
public static function resolveExtensionOrder($extensionList)
|
||||
{
|
||||
$extensionIdMapping = []; // Used for caching so we don't rerun ->getExtensions every time.
|
||||
|
||||
// This is an implementation of Kahn's Algorithm (https://dl.acm.org/doi/10.1145/368996.369025)
|
||||
$extensionGraph = [];
|
||||
$output = [];
|
||||
$missingDependencies = []; // Extensions are invalid if they are missing dependencies, or have circular dependencies.
|
||||
$circularDependencies = [];
|
||||
$pendingQueue = [];
|
||||
$inDegreeCount = []; // How many extensions are dependent on a given extension?
|
||||
|
||||
foreach ($extensionList as $extension) {
|
||||
$extensionIdMapping[$extension->getId()] = $extension;
|
||||
}
|
||||
|
||||
foreach ($extensionList as $extension) {
|
||||
$optionalDependencies = array_filter($extension->getOptionalDependencyIds(), function ($id) use ($extensionIdMapping) {
|
||||
return array_key_exists($id, $extensionIdMapping);
|
||||
});
|
||||
$extensionGraph[$extension->getId()] = array_merge($extension->getExtensionDependencyIds(), $optionalDependencies);
|
||||
|
||||
foreach ($extensionGraph[$extension->getId()] as $dependency) {
|
||||
$inDegreeCount[$dependency] = array_key_exists($dependency, $inDegreeCount) ? $inDegreeCount[$dependency] + 1 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($extensionList as $extension) {
|
||||
if (! array_key_exists($extension->getId(), $inDegreeCount)) {
|
||||
$inDegreeCount[$extension->getId()] = 0;
|
||||
$pendingQueue[] = $extension->getId();
|
||||
}
|
||||
}
|
||||
|
||||
while (! empty($pendingQueue)) {
|
||||
$activeNode = array_shift($pendingQueue);
|
||||
$output[] = $activeNode;
|
||||
|
||||
foreach ($extensionGraph[$activeNode] as $dependency) {
|
||||
$inDegreeCount[$dependency] -= 1;
|
||||
|
||||
if ($inDegreeCount[$dependency] === 0) {
|
||||
if (! array_key_exists($dependency, $extensionGraph)) {
|
||||
// Missing Dependency
|
||||
$missingDependencies[$activeNode] = array_merge(
|
||||
Arr::get($missingDependencies, $activeNode, []),
|
||||
[$dependency]
|
||||
);
|
||||
} else {
|
||||
$pendingQueue[] = $dependency;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$validOutput = array_filter($output, function ($extension) use ($missingDependencies) {
|
||||
return ! array_key_exists($extension, $missingDependencies);
|
||||
});
|
||||
|
||||
$validExtensions = array_reverse(array_map(function ($extensionId) use ($extensionIdMapping) {
|
||||
return $extensionIdMapping[$extensionId];
|
||||
}, $validOutput)); // Reversed as required by Kahn's algorithm.
|
||||
|
||||
foreach ($inDegreeCount as $id => $count) {
|
||||
if ($count != 0) {
|
||||
$circularDependencies[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => $validExtensions,
|
||||
'missingDependencies' => $missingDependencies,
|
||||
'circularDependencies' => $circularDependencies
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
142
tests/unit/Foundation/ExtensionDependencyResolutionTest.php
Normal file
142
tests/unit/Foundation/ExtensionDependencyResolutionTest.php
Normal file
|
@ -0,0 +1,142 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tests\unit\Foundation;
|
||||
|
||||
use Flarum\Extension\ExtensionManager;
|
||||
use Flarum\Tests\unit\TestCase;
|
||||
|
||||
class ExtensionDependencyResolutionTest extends TestCase
|
||||
{
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->tags = new FakeExtension('flarum-tags', []);
|
||||
$this->categories = new FakeExtension('flarum-categories', ['flarum-tags', 'flarum-tag-backgrounds']);
|
||||
$this->tagBackgrounds = new FakeExtension('flarum-tag-backgrounds', ['flarum-tags']);
|
||||
$this->something = new FakeExtension('flarum-something', ['flarum-categories', 'flarum-help']);
|
||||
$this->help = new FakeExtension('flarum-help', []);
|
||||
$this->missing = new FakeExtension('flarum-missing', ['this-does-not-exist', 'flarum-tags', 'also-not-exists']);
|
||||
$this->circular1 = new FakeExtension('circular1', ['circular2']);
|
||||
$this->circular2 = new FakeExtension('circular2', ['circular1']);
|
||||
$this->optionalDependencyCategories = new FakeExtension('flarum-categories', ['flarum-tags'], ['flarum-tag-backgrounds']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function works_with_empty_set()
|
||||
{
|
||||
$expected = [
|
||||
'valid' => [],
|
||||
'missingDependencies' => [],
|
||||
'circularDependencies' => [],
|
||||
];
|
||||
|
||||
$this->assertEquals($expected, ExtensionManager::resolveExtensionOrder([]));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function works_with_proper_data()
|
||||
{
|
||||
$exts = [$this->tags, $this->categories, $this->tagBackgrounds, $this->something, $this->help];
|
||||
|
||||
$expected = [
|
||||
'valid' => [$this->tags, $this->tagBackgrounds, $this->help, $this->categories, $this->something],
|
||||
'missingDependencies' => [],
|
||||
'circularDependencies' => [],
|
||||
];
|
||||
|
||||
$this->assertEquals($expected, ExtensionManager::resolveExtensionOrder($exts));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function works_with_missing_dependencies()
|
||||
{
|
||||
$exts = [$this->tags, $this->categories, $this->tagBackgrounds, $this->something, $this->help, $this->missing];
|
||||
|
||||
$expected = [
|
||||
'valid' => [$this->tags, $this->tagBackgrounds, $this->help, $this->categories, $this->something],
|
||||
'missingDependencies' => ['flarum-missing' => ['this-does-not-exist', 'also-not-exists']],
|
||||
'circularDependencies' => [],
|
||||
];
|
||||
|
||||
$this->assertEquals($expected, ExtensionManager::resolveExtensionOrder($exts));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function works_with_circular_dependencies()
|
||||
{
|
||||
$exts = [$this->tags, $this->categories, $this->tagBackgrounds, $this->something, $this->help, $this->circular1, $this->circular2];
|
||||
|
||||
$expected = [
|
||||
'valid' => [$this->tags, $this->tagBackgrounds, $this->help, $this->categories, $this->something],
|
||||
'missingDependencies' => [],
|
||||
'circularDependencies' => ['circular2', 'circular1'],
|
||||
];
|
||||
|
||||
$this->assertEquals($expected, ExtensionManager::resolveExtensionOrder($exts));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function works_with_optional_dependencies()
|
||||
{
|
||||
$exts = [$this->tags, $this->optionalDependencyCategories, $this->tagBackgrounds, $this->something, $this->help];
|
||||
|
||||
$expected = [
|
||||
'valid' => [$this->tags, $this->tagBackgrounds, $this->help, $this->optionalDependencyCategories, $this->something],
|
||||
'missingDependencies' => [],
|
||||
'circularDependencies' => [],
|
||||
];
|
||||
|
||||
$this->assertEquals($expected, ExtensionManager::resolveExtensionOrder($exts));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function works_with_optional_dependencies_if_optional_dependency_missing()
|
||||
{
|
||||
$exts = [$this->tags, $this->optionalDependencyCategories, $this->something, $this->help];
|
||||
|
||||
$expected = [
|
||||
'valid' => [$this->tags, $this->help, $this->optionalDependencyCategories, $this->something],
|
||||
'missingDependencies' => [],
|
||||
'circularDependencies' => [],
|
||||
];
|
||||
|
||||
$this->assertEquals($expected, ExtensionManager::resolveExtensionOrder($exts));
|
||||
}
|
||||
}
|
||||
|
||||
class FakeExtension
|
||||
{
|
||||
protected $id;
|
||||
protected $extensionDependencies;
|
||||
protected $optionalDependencies;
|
||||
|
||||
public function __construct($id, $extensionDependencies, $optionalDependencies = [])
|
||||
{
|
||||
$this->id = $id;
|
||||
$this->extensionDependencies = $extensionDependencies;
|
||||
$this->optionalDependencies = $optionalDependencies;
|
||||
}
|
||||
|
||||
public function getId()
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getExtensionDependencyIds()
|
||||
{
|
||||
return $this->extensionDependencies;
|
||||
}
|
||||
|
||||
public function getOptionalDependencyIds()
|
||||
{
|
||||
return $this->optionalDependencies;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user