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:
Alexander Skvortsov 2021-02-21 13:49:33 -05:00 committed by GitHub
parent 40ede179cd
commit fa10d794a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 283 additions and 10 deletions

View File

@ -64,7 +64,7 @@ class Extension implements Arrayable
* Unique Id of the extension. * Unique Id of the extension.
* *
* @info Identical to the directory in the extensions directory. * @info Identical to the directory in the extensions directory.
* @example flarum_suspend * @example flarum-suspend
* *
* @var string * @var string
*/ */
@ -91,6 +91,14 @@ class Extension implements Arrayable
*/ */
protected $extensionDependencyIds; 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. * 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 * @param array $extensionSet: An associative array where keys are the composer package names
* of installed extensions. Used to figure out which dependencies * of installed extensions. Used to figure out which dependencies
* are flarum extensions. * 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', []))) $this->extensionDependencyIds = (new Collection(Arr::get($this->composerJson, 'require', [])))
->keys() ->keys()
->filter(function ($key) use ($extensionSet) { ->filter(function ($key) use ($extensionSet) {
return array_key_exists($key, $extensionSet); return array_key_exists($key, $extensionSet);
})->map(function ($key) { })
->map(function ($key) {
return static::nameToId($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 * @return array
*/ */
public function getExtensionDependencyIds() public function getExtensionDependencyIds(): array
{ {
return $this->extensionDependencyIds; 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 private function getExtenders(): array
{ {
$extenderFile = $this->getExtenderFile(); $extenderFile = $this->getExtenderFile();
@ -455,6 +487,7 @@ class Extension implements Arrayable
'hasAssets' => $this->hasAssets(), 'hasAssets' => $this->hasAssets(),
'hasMigrations' => $this->hasMigrations(), 'hasMigrations' => $this->hasMigrations(),
'extensionDependencyIds' => $this->getExtensionDependencyIds(), 'extensionDependencyIds' => $this->getExtensionDependencyIds(),
'optionalDependencyIds' => $this->getOptionalDependencyIds(),
'links' => $this->getLinks(), 'links' => $this->getLinks(),
], $this->composerJson); ], $this->composerJson);
} }

View File

@ -86,7 +86,9 @@ class ExtensionManager
// We calculate and store a set of composer package names for all installed Flarum extensions, // 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`. // 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. // 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 = []; $installedSet = [];
$enabledIds = array_flip($this->getEnabled());
foreach ($installed as $package) { foreach ($installed as $package) {
if (Arr::get($package, 'type') != 'flarum-extension' || empty(Arr::get($package, 'name'))) { if (Arr::get($package, 'type') != 'flarum-extension' || empty(Arr::get($package, 'name'))) {
@ -110,7 +112,7 @@ class ExtensionManager
} }
foreach ($extensions as $extension) { foreach ($extensions as $extension) {
$extension->calculateDependencies($installedSet); $extension->calculateDependencies($installedSet, $enabledIds);
} }
$this->extensions = $extensions->sortBy(function ($extension, $name) { $this->extensions = $extensions->sortBy(function ($extension, $name) {
@ -348,13 +350,21 @@ class ExtensionManager
/** /**
* Persist the currently enabled extensions. * 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(); return $extension->getTitle();
}, $exts); }, $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
];
}
} }

View 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;
}
}