Skip to content

Commit

Permalink
Optional Dependencies (#2579)
Browse files Browse the repository at this point in the history
* Add and calculate optional dependencies
* Add extension dependency resolver (Kahn's algorithm), plus unit tests
* Resolve extension dependency on enable/disable
  • Loading branch information
askvortsov1 committed Feb 21, 2021
1 parent 20cc16f commit 869e824
Show file tree
Hide file tree
Showing 3 changed files with 283 additions and 10 deletions.
43 changes: 38 additions & 5 deletions src/Extension/Extension.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ protected static function nameToId($name)
* Unique Id of the extension.
*
* @info Identical to the directory in the extensions directory.
* @example flarum_suspend
* @example flarum-suspend
*
* @var string
*/
Expand All @@ -91,6 +91,14 @@ protected static function nameToId($name)
*/
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.
*
Expand Down Expand Up @@ -203,16 +211,29 @@ public function setVersion($version)
* @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();

$this->optionalDependencyIds = (new Collection(Arr::get($this->composerJson, 'extra.flarum-extension.optional-dependencies', [])))
->map(function ($key) {
return static::nameToId($key);
})->toArray();
})
->filter(function ($key) use ($enabledIds) {
return array_key_exists($key, $enabledIds);
})
->toArray();
}

/**
Expand Down Expand Up @@ -299,11 +320,22 @@ public function getPath()
*
* @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();
Expand Down Expand Up @@ -455,6 +487,7 @@ public function toArray()
'hasAssets' => $this->hasAssets(),
'hasMigrations' => $this->hasMigrations(),
'extensionDependencyIds' => $this->getExtensionDependencyIds(),
'optionalDependencyIds' => $this->getOptionalDependencyIds(),
'links' => $this->getLinks(),
], $this->composerJson);
}
Expand Down
108 changes: 103 additions & 5 deletions src/Extension/ExtensionManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ public function getExtensions()
// 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'))) {
Expand All @@ -110,7 +112,7 @@ public function getExtensions()
}

foreach ($extensions as $extension) {
$extension->calculateDependencies($installedSet);
$extension->calculateDependencies($installedSet, $enabledIds);
}

$this->extensions = $extensions->sortBy(function ($extension, $name) {
Expand Down Expand Up @@ -348,13 +350,21 @@ public function getEnabled()
/**
* 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));
}

/**
Expand Down Expand Up @@ -382,4 +392,92 @@ public static function pluckTitles(array $exts)
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 changes: 142 additions & 0 deletions tests/unit/Foundation/ExtensionDependencyResolutionTest.php
Original file line number Diff line number Diff line change
@@ -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;
}
}

0 comments on commit 869e824

Please sign in to comment.