Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Optional Dependencies #2579

Merged
merged 7 commits into from
Feb 21, 2021
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
SychO9 marked this conversation as resolved.
Show resolved Hide resolved
})->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
102 changes: 97 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());
SychO9 marked this conversation as resolved.
Show resolved Hide resolved

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,86 @@ 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;
$extensionGraph[$extension->getId()] = array_merge($extension->getExtensionDependencyIds(), $extension->getOptionalDependencyIds());
SychO9 marked this conversation as resolved.
Show resolved Hide resolved

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
];
}
}
128 changes: 128 additions & 0 deletions tests/unit/Foundation/ExtensionDependencyResolutionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?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));
}
}

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